forked from farhoodlabs/paperclip
Harden tailnet bind setup
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -56,6 +56,9 @@ type OnboardOptions = {
|
||||
|
||||
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 = [
|
||||
"PAPERCLIP_PUBLIC_URL",
|
||||
"DATABASE_URL",
|
||||
@@ -476,6 +479,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||
});
|
||||
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") {
|
||||
|
||||
@@ -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<ServerConfig>): 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<BindMode, "custom">,
|
||||
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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user