mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
Harden tailnet bind setup
This commit is contained in:
parent
6208899d0a
commit
a77206812e
6 changed files with 88 additions and 8 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared";
|
import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared";
|
||||||
|
import { buildPresetServerConfig } from "../config/server-bind.js";
|
||||||
|
|
||||||
describe("network bind helpers", () => {
|
describe("network bind helpers", () => {
|
||||||
it("rejects non-loopback bind modes in local_trusted", () => {
|
it("rejects non-loopback bind modes in local_trusted", () => {
|
||||||
|
|
@ -32,4 +33,30 @@ describe("network bind helpers", () => {
|
||||||
|
|
||||||
expect(resolved.errors).toContain("server.customBindHost is required when server.bind=custom");
|
expect(resolved.errors).toContain("server.customBindHost is required when server.bind=custom");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stores the detected tailscale address for tailnet presets", () => {
|
||||||
|
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
|
||||||
|
|
||||||
|
const preset = buildPresetServerConfig("tailnet", {
|
||||||
|
port: 3100,
|
||||||
|
allowedHostnames: [],
|
||||||
|
serveUi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preset.server.host).toBe("100.64.0.8");
|
||||||
|
|
||||||
|
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to loopback when no tailscale address is available for tailnet presets", () => {
|
||||||
|
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
||||||
|
|
||||||
|
const preset = buildPresetServerConfig("tailnet", {
|
||||||
|
port: 3100,
|
||||||
|
allowedHostnames: [],
|
||||||
|
serveUi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preset.server.host).toBe("127.0.0.1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ describe("onboard", () => {
|
||||||
|
|
||||||
it("supports authenticated/private quickstart bind presets", async () => {
|
it("supports authenticated/private quickstart bind presets", async () => {
|
||||||
const configPath = createFreshConfigPath();
|
const configPath = createFreshConfigPath();
|
||||||
|
process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8";
|
||||||
|
|
||||||
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
|
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
|
||||||
|
|
||||||
|
|
@ -134,7 +135,20 @@ describe("onboard", () => {
|
||||||
expect(raw.server.deploymentMode).toBe("authenticated");
|
expect(raw.server.deploymentMode).toBe("authenticated");
|
||||||
expect(raw.server.exposure).toBe("private");
|
expect(raw.server.exposure).toBe("private");
|
||||||
expect(raw.server.bind).toBe("tailnet");
|
expect(raw.server.bind).toBe("tailnet");
|
||||||
expect(raw.server.host).toBe("0.0.0.0");
|
expect(raw.server.host).toBe("100.64.0.8");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps tailnet quickstart on loopback until tailscale is available", async () => {
|
||||||
|
const configPath = createFreshConfigPath();
|
||||||
|
delete process.env.PAPERCLIP_TAILNET_BIND_HOST;
|
||||||
|
|
||||||
|
await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" });
|
||||||
|
|
||||||
|
const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig;
|
||||||
|
expect(raw.server.deploymentMode).toBe("authenticated");
|
||||||
|
expect(raw.server.exposure).toBe("private");
|
||||||
|
expect(raw.server.bind).toBe("tailnet");
|
||||||
|
expect(raw.server.host).toBe("127.0.0.1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores deployment env overrides during --yes quickstart", async () => {
|
it("ignores deployment env overrides during --yes quickstart", async () => {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ type OnboardOptions = {
|
||||||
|
|
||||||
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
|
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
|
||||||
|
|
||||||
|
const TAILNET_BIND_WARNING =
|
||||||
|
"No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or PAPERCLIP_TAILNET_BIND_HOST is set.";
|
||||||
|
|
||||||
const ONBOARD_ENV_KEYS = [
|
const ONBOARD_ENV_KEYS = [
|
||||||
"PAPERCLIP_PUBLIC_URL",
|
"PAPERCLIP_PUBLIC_URL",
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
|
|
@ -476,6 +479,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
});
|
});
|
||||||
server = preset.server;
|
server = preset.server;
|
||||||
auth = preset.auth;
|
auth = preset.auth;
|
||||||
|
if (opts.bind === "tailnet" && server.host === "127.0.0.1") {
|
||||||
|
p.log.warn(TAILNET_BIND_WARNING);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setupMode === "advanced") {
|
if (setupMode === "advanced") {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import {
|
import {
|
||||||
ALL_INTERFACES_BIND_HOST,
|
ALL_INTERFACES_BIND_HOST,
|
||||||
LOOPBACK_BIND_HOST,
|
LOOPBACK_BIND_HOST,
|
||||||
|
|
@ -10,6 +11,8 @@ import {
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import type { AuthConfig, ServerConfig } from "./schema.js";
|
import type { AuthConfig, ServerConfig } from "./schema.js";
|
||||||
|
|
||||||
|
const TAILSCALE_DETECT_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
type BaseServerInput = {
|
type BaseServerInput = {
|
||||||
port: number;
|
port: number;
|
||||||
allowedHostnames: string[];
|
allowedHostnames: string[];
|
||||||
|
|
@ -21,11 +24,35 @@ export function inferConfiguredBind(server?: Partial<ServerConfig>): BindMode {
|
||||||
return inferBindModeFromHost(server?.customBindHost ?? server?.host);
|
return inferBindModeFromHost(server?.customBindHost ?? server?.host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function detectTailnetBindHost(): string | undefined {
|
||||||
|
const explicit = process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stdout = execFileSync("tailscale", ["ip", "-4"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
timeout: TAILSCALE_DETECT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
return stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPresetServerConfig(
|
export function buildPresetServerConfig(
|
||||||
bind: Exclude<BindMode, "custom">,
|
bind: Exclude<BindMode, "custom">,
|
||||||
input: BaseServerInput,
|
input: BaseServerInput,
|
||||||
): { server: ServerConfig; auth: AuthConfig } {
|
): { server: ServerConfig; auth: AuthConfig } {
|
||||||
const host = bind === "loopback" ? LOOPBACK_BIND_HOST : ALL_INTERFACES_BIND_HOST;
|
const host =
|
||||||
|
bind === "loopback"
|
||||||
|
? LOOPBACK_BIND_HOST
|
||||||
|
: bind === "tailnet"
|
||||||
|
? (detectTailnetBindHost() ?? LOOPBACK_BIND_HOST)
|
||||||
|
: ALL_INTERFACES_BIND_HOST;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server: {
|
server: {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@ import * as p from "@clack/prompts";
|
||||||
import { isLoopbackHost, type BindMode } from "@paperclipai/shared";
|
import { isLoopbackHost, type BindMode } from "@paperclipai/shared";
|
||||||
import type { AuthConfig, ServerConfig } from "../config/schema.js";
|
import type { AuthConfig, ServerConfig } from "../config/schema.js";
|
||||||
import { parseHostnameCsv } from "../config/hostnames.js";
|
import { parseHostnameCsv } from "../config/hostnames.js";
|
||||||
import {
|
import { buildCustomServerConfig, buildPresetServerConfig, inferConfiguredBind } from "../config/server-bind.js";
|
||||||
buildCustomServerConfig,
|
|
||||||
buildPresetServerConfig,
|
const TAILNET_BIND_WARNING =
|
||||||
inferConfiguredBind,
|
"No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or PAPERCLIP_TAILNET_BIND_HOST is set.";
|
||||||
} from "../config/server-bind.js";
|
|
||||||
|
|
||||||
function cancelled(): never {
|
function cancelled(): never {
|
||||||
p.cancel("Setup cancelled.");
|
p.cancel("Setup cancelled.");
|
||||||
|
|
@ -95,11 +94,15 @@ export async function promptServer(opts?: {
|
||||||
|
|
||||||
if (p.isCancel(allowedHostnamesInput)) cancelled();
|
if (p.isCancel(allowedHostnamesInput)) cancelled();
|
||||||
|
|
||||||
return buildPresetServerConfig(bind, {
|
const preset = buildPresetServerConfig(bind, {
|
||||||
port,
|
port,
|
||||||
allowedHostnames: parseHostnameCsv(allowedHostnamesInput),
|
allowedHostnames: parseHostnameCsv(allowedHostnamesInput),
|
||||||
serveUi,
|
serveUi,
|
||||||
});
|
});
|
||||||
|
if (bind === "tailnet" && isLoopbackHost(preset.server.host)) {
|
||||||
|
p.log.warn(TAILNET_BIND_WARNING);
|
||||||
|
}
|
||||||
|
return preset;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deploymentModeSelection = await p.select({
|
const deploymentModeSelection = await p.select({
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ if (!isSameFile && existsSync(CWD_ENV_PATH)) {
|
||||||
|
|
||||||
maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
maybeRepairLegacyWorktreeConfigAndEnvFiles();
|
||||||
|
|
||||||
|
const TAILSCALE_DETECT_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
type DatabaseMode = "embedded-postgres" | "postgres";
|
type DatabaseMode = "embedded-postgres" | "postgres";
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
|
|
@ -94,6 +96,7 @@ function detectTailnetBindHost(): string | undefined {
|
||||||
const stdout = execFileSync("tailscale", ["ip", "-4"], {
|
const stdout = execFileSync("tailscale", ["ip", "-4"], {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
stdio: ["ignore", "pipe", "ignore"],
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
timeout: TAILSCALE_DETECT_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
return stdout
|
return stdout
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue