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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Fable 5 committed 2026-08-16 19:26:07 +00:00
1 parent 90cf40bf0f
commit 78b5ac1ce5
3 files changed
+67 -12

No files matched your search

+15 -5
View File
@@ -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
```
@@ -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);
});
});
@@ -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<string>(["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;
}