Harden tailnet bind setup

This commit is contained in:
Dotta 2026-04-11 07:13:41 -05:00
parent 6208899d0a
commit a77206812e
6 changed files with 88 additions and 8 deletions

View file

@ -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");
});
}); });

View file

@ -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 () => {

View file

@ -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") {

View file

@ -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: {

View file

@ -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({

View file

@ -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/)