From a77206812e2b58ca45f7440935c107f912eddc5e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 07:13:41 -0500 Subject: [PATCH] Harden tailnet bind setup --- cli/src/__tests__/network-bind.test.ts | 27 ++++++++++++++++++++++++ cli/src/__tests__/onboard.test.ts | 16 +++++++++++++- cli/src/commands/onboard.ts | 6 ++++++ cli/src/config/server-bind.ts | 29 +++++++++++++++++++++++++- cli/src/prompts/server.ts | 15 +++++++------ server/src/config.ts | 3 +++ 6 files changed, 88 insertions(+), 8 deletions(-) diff --git a/cli/src/__tests__/network-bind.test.ts b/cli/src/__tests__/network-bind.test.ts index 4c2bc555..d75452ab 100644 --- a/cli/src/__tests__/network-bind.test.ts +++ b/cli/src/__tests__/network-bind.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared"; +import { buildPresetServerConfig } from "../config/server-bind.js"; describe("network bind helpers", () => { 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"); }); + + 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"); + }); }); diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index f686e071..b66c20fa 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -127,6 +127,7 @@ describe("onboard", () => { it("supports authenticated/private quickstart bind presets", async () => { const configPath = createFreshConfigPath(); + process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8"; 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.exposure).toBe("private"); 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 () => { diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 0fabfd0a..62158e05 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -56,6 +56,9 @@ type OnboardOptions = { type OnboardDefaults = Pick; +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 = [ "PAPERCLIP_PUBLIC_URL", "DATABASE_URL", @@ -476,6 +479,9 @@ export async function onboard(opts: OnboardOptions): Promise { }); server = preset.server; auth = preset.auth; + if (opts.bind === "tailnet" && server.host === "127.0.0.1") { + p.log.warn(TAILNET_BIND_WARNING); + } } if (setupMode === "advanced") { diff --git a/cli/src/config/server-bind.ts b/cli/src/config/server-bind.ts index 93cd3eff..3bcc8015 100644 --- a/cli/src/config/server-bind.ts +++ b/cli/src/config/server-bind.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { ALL_INTERFACES_BIND_HOST, LOOPBACK_BIND_HOST, @@ -10,6 +11,8 @@ import { } from "@paperclipai/shared"; import type { AuthConfig, ServerConfig } from "./schema.js"; +const TAILSCALE_DETECT_TIMEOUT_MS = 3000; + type BaseServerInput = { port: number; allowedHostnames: string[]; @@ -21,11 +24,35 @@ export function inferConfiguredBind(server?: Partial): BindMode { 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( bind: Exclude, input: BaseServerInput, ): { 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 { server: { diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index 78810188..404d4ea5 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -2,11 +2,10 @@ import * as p from "@clack/prompts"; import { isLoopbackHost, type BindMode } from "@paperclipai/shared"; import type { AuthConfig, ServerConfig } from "../config/schema.js"; import { parseHostnameCsv } from "../config/hostnames.js"; -import { - buildCustomServerConfig, - buildPresetServerConfig, - inferConfiguredBind, -} from "../config/server-bind.js"; +import { buildCustomServerConfig, buildPresetServerConfig, inferConfiguredBind } from "../config/server-bind.js"; + +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."; function cancelled(): never { p.cancel("Setup cancelled."); @@ -95,11 +94,15 @@ export async function promptServer(opts?: { if (p.isCancel(allowedHostnamesInput)) cancelled(); - return buildPresetServerConfig(bind, { + const preset = buildPresetServerConfig(bind, { port, allowedHostnames: parseHostnameCsv(allowedHostnamesInput), serveUi, }); + if (bind === "tailnet" && isLoopbackHost(preset.server.host)) { + p.log.warn(TAILNET_BIND_WARNING); + } + return preset; } const deploymentModeSelection = await p.select({ diff --git a/server/src/config.ts b/server/src/config.ts index 9be5c21a..21271c98 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -45,6 +45,8 @@ if (!isSameFile && existsSync(CWD_ENV_PATH)) { maybeRepairLegacyWorktreeConfigAndEnvFiles(); +const TAILSCALE_DETECT_TIMEOUT_MS = 3000; + type DatabaseMode = "embedded-postgres" | "postgres"; export interface Config { @@ -94,6 +96,7 @@ function detectTailnetBindHost(): string | undefined { const stdout = execFileSync("tailscale", ["ip", "-4"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + timeout: TAILSCALE_DETECT_TIMEOUT_MS, }); return stdout .split(/\r?\n/)