From 78b5ac1ce557c0a437e8a9fa4cd177f3e7431a33 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 11 Aug 2026 18:27:12 +0000 Subject: [PATCH] fix(tailscale-https-broker): version floor + install docs (PAP-17079) Replace the pinned Tailscale minor allowlist with a numeric minimum-version comparison (floor 1.80) so current releases like 1.102.2 pass --doctor while 1.78 and malformed output are still rejected; add boundary + malformed unit coverage. Correct broker lifecycle commands to the installed unit name paperclip-tailscale-https-broker and document copying the built package to /opt/paperclip/packages/tailscale-https-broker to match ExecStart and doctor. Co-Authored-By: Claude Fable 5 --- packages/tailscale-https-broker/README.md | 20 +++++++--- .../src/tailscale-cli.test.ts | 39 +++++++++++++++++++ .../src/tailscale-cli.ts | 20 ++++++---- 3 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 packages/tailscale-https-broker/src/tailscale-cli.test.ts diff --git a/packages/tailscale-https-broker/README.md b/packages/tailscale-https-broker/README.md index 0bb0ed024dd..9bbd8813ef1 100644 --- a/packages/tailscale-https-broker/README.md +++ b/packages/tailscale-https-broker/README.md @@ -87,10 +87,18 @@ Tailscale-operator service account distinct from the Paperclip app account. The broker refuses to start if the registry path's parent is group/other writable. -5. **Build and install the packaged systemd unit.** +5. **Build, install the package under `/opt/paperclip`, and install the + packaged systemd unit.** The unit's `ExecStart` (and the doctor command + below) run the build output from + `/opt/paperclip/packages/tailscale-https-broker/dist`, so copy it there + explicitly. The output is self-contained (Node builtins only; no + `node_modules` needed). ```sh pnpm --filter @paperclipai/tailscale-https-broker build + sudo install -d -m 0755 /opt/paperclip/packages/tailscale-https-broker + sudo cp -r packages/tailscale-https-broker/dist \ + /opt/paperclip/packages/tailscale-https-broker/ sudo install -D -m 0644 \ packages/tailscale-https-broker/deploy/paperclip-tailscale-https-broker.service \ /etc/systemd/system/paperclip-tailscale-https-broker.service @@ -154,12 +162,14 @@ Tailscale-operator service account distinct from the Paperclip app account. node identity. Exit 0 = ready. It never mutates Serve state. 7. **Enable.** `sudo systemctl daemon-reload && sudo systemctl enable --now - paperclip-tailscale-broker`. Confirm the socket is `0660 + paperclip-tailscale-https-broker`. Confirm the socket is `0660 paperclip-tsbroker:paperclip-tsbroker-sock`. ## Upgrade -Deploy new package output, `systemctl restart paperclip-tailscale-broker`. On +Deploy new package output to +`/opt/paperclip/packages/tailscale-https-broker/dist`, then +`sudo systemctl restart paperclip-tailscale-https-broker`. On restart the broker re-reads its root-owned registry and adopts only exact-lease matches; a changed `BROKER_NODE_IDENTITY` (host reimage / boot-id change) forces quarantine and operator reconciliation rather than silently re-adopting. @@ -173,7 +183,7 @@ resets Serve or changes the primary route. `expose`). Existing previews drain on runtime stop. 2. Drain owned listeners: stop each managed runtime so Paperclip issues `remove` for its own leases (proven by handle). -3. `sudo systemctl disable --now paperclip-tailscale-broker`. +3. `sudo systemctl disable --now paperclip-tailscale-https-broker`. 4. Optional cleanup: remove the state dirs and `sudo tailscale set --operator=` to drop the operator grant. Do **not** run `tailscale serve reset` — remove only the specific per-port Serve entries if any remain. @@ -192,7 +202,7 @@ redacted. ## Tests ```sh -pnpm --filter @paperclipai/tailscale-https-broker test # 60 tests +pnpm --filter @paperclipai/tailscale-https-broker test # 65 tests pnpm --filter @paperclipai/tailscale-https-broker typecheck pnpm --filter @paperclipai/tailscale-https-broker build ``` diff --git a/packages/tailscale-https-broker/src/tailscale-cli.test.ts b/packages/tailscale-https-broker/src/tailscale-cli.test.ts new file mode 100644 index 00000000000..8d9a66fdf1f --- /dev/null +++ b/packages/tailscale-https-broker/src/tailscale-cli.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { isSupportedTailscaleVersion, parseTailscaleVersion } from "./tailscale-cli.js"; + +describe("parseTailscaleVersion", () => { + it("parses the numeric major.minor prefix of CLI output", () => { + expect(parseTailscaleVersion("1.102.2\n tailscale commit: abc\n")).toEqual({ major: 1, minor: 102 }); + expect(parseTailscaleVersion(" 1.80.3")).toEqual({ major: 1, minor: 80 }); + }); + + it("returns null for malformed output", () => { + expect(parseTailscaleVersion("")).toBeNull(); + expect(parseTailscaleVersion("garbage")).toBeNull(); + expect(parseTailscaleVersion("v1.80.1")).toBeNull(); + expect(parseTailscaleVersion("1")).toBeNull(); + expect(parseTailscaleVersion("tailscale 1.80")).toBeNull(); + }); +}); + +describe("isSupportedTailscaleVersion", () => { + it("accepts the minimum version and anything newer", () => { + expect(isSupportedTailscaleVersion("1.80.0")).toBe(true); + expect(isSupportedTailscaleVersion("1.84.1")).toBe(true); + // Numeric, not lexicographic: "1.102" < "1.80" as strings. + expect(isSupportedTailscaleVersion("1.102.2\n tailscale commit: abc\n")).toBe(true); + expect(isSupportedTailscaleVersion("2.0.0")).toBe(true); + }); + + it("rejects versions below the minimum", () => { + expect(isSupportedTailscaleVersion("1.78")).toBe(false); + expect(isSupportedTailscaleVersion("1.79.9")).toBe(false); + expect(isSupportedTailscaleVersion("0.99.0")).toBe(false); + }); + + it("rejects malformed version output", () => { + expect(isSupportedTailscaleVersion("")).toBe(false); + expect(isSupportedTailscaleVersion("garbage")).toBe(false); + expect(isSupportedTailscaleVersion("v1.102.2")).toBe(false); + }); +}); diff --git a/packages/tailscale-https-broker/src/tailscale-cli.ts b/packages/tailscale-https-broker/src/tailscale-cli.ts index 529f68e1647..228516f00ea 100644 --- a/packages/tailscale-https-broker/src/tailscale-cli.ts +++ b/packages/tailscale-https-broker/src/tailscale-cli.ts @@ -10,8 +10,12 @@ import type { CliResult } from "./broker-core.js"; const MAX_OUTPUT_BYTES = 256 * 1024; const DEFAULT_TIMEOUT_MS = 10_000; -/** Tailscale CLI versions this broker has been validated against (pinned). */ -export const SUPPORTED_TAILSCALE_VERSIONS = new Set(["1.80", "1.82", "1.84"]); +/** + * Minimum Tailscale CLI version the broker accepts. Serve subcommand syntax and + * JSON status output are stable from this release onward, so newer releases are + * accepted by numeric comparison rather than a pinned allowlist. + */ +export const MIN_TAILSCALE_VERSION = { major: 1, minor: 80 } as const; export function createTailscaleRunner(options: { timeoutMs?: number } = {}) { const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -39,13 +43,15 @@ export function createTailscaleRunner(options: { timeoutMs?: number } = {}) { }; } -/** Parse the `major.minor` prefix of `tailscale version` output. */ -export function parseTailscaleMinor(versionOutput: string): string | null { +/** Parse the numeric `major.minor` prefix of `tailscale version` output. */ +export function parseTailscaleVersion(versionOutput: string): { major: number; minor: number } | null { const match = /^\s*(\d+)\.(\d+)/.exec(versionOutput); - return match ? `${match[1]}.${match[2]}` : null; + return match ? { major: Number(match[1]), minor: Number(match[2]) } : null; } export function isSupportedTailscaleVersion(versionOutput: string): boolean { - const minor = parseTailscaleMinor(versionOutput); - return minor !== null && SUPPORTED_TAILSCALE_VERSIONS.has(minor); + const version = parseTailscaleVersion(versionOutput); + if (version === null) return false; + if (version.major !== MIN_TAILSCALE_VERSION.major) return version.major > MIN_TAILSCALE_VERSION.major; + return version.minor >= MIN_TAILSCALE_VERSION.minor; }