diff --git a/AGENTS.md b/AGENTS.md index ce247a25..815316f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,9 +94,10 @@ valibot schemas, and Headplane's real Headscale client. switches or cast route arguments. - For anything that talks to Headscale, use `createFakeHeadscale(version)` (`tests/support/fake-headscale.ts`), not stubs of individual methods. It - answers at the HTTP level for every supported version. `app.fake.join()`, - `pendingNode()`, `setOnline()`, and `setReachable()` set up state a real - tailnet would produce. + answers at the HTTP level, so Headplane's real client runs on it. + `app.fake.join()` and `setReachable()` set up the rest. It only models what + unit tests use. An endpoint it doesn't model throws, so a route that needs + more fails loudly instead of passing on made-up data. - `tests/unit/routes/permissions.test.ts` calls every loader and action intent in `app/routes.ts` as each role. A new route is covered automatically. Only add it to `PUBLIC` or `MEMBER_VISIBLE` if it truly returns no tailnet data to @@ -113,11 +114,13 @@ valibot schemas, and Headplane's real Headscale client. ### The Headscale contract (`tests/contract/`) `headscale.ts` is what Headplane relies on from Headscale's API, asserted -through Headplane's client. It runs against the fake in `tests/unit` and against -real Headscale in `tests/integration`, so the fake can't drift. When you need -the fake to do something new, write the contract test first, run it against -real Headscale (`HP_HEADSCALE_VERSIONS=all`), then make the fake match. Record -real quirks there too, like 0.28.0 deleting every auth key along with any user. +through Headplane's client. All of it runs against real Headscale in +`tests/integration`. The part outside `if (isReal)` also runs against the fake +in `tests/unit`, so the fake can't drift. Grow the fake only when a unit test +needs it: move the contract test for that behavior out of `if (isReal)` (or +write one and run it against real Headscale with `HP_HEADSCALE_VERSIONS=all`), +then make the fake pass it. Record real quirks in the real-only part, like +0.28.0 deleting every auth key along with any user. ### Integration tests (`tests/integration/`) diff --git a/docs/development/testing.md b/docs/development/testing.md index f004abf0..1f6f68a9 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -40,16 +40,19 @@ test("a viewer can't create a tag-only key", async () => { ``` `createFakeHeadscale(version)` is an in-memory Headscale that answers at the -HTTP level, so Headplane's own API client runs against it unchanged. It copies -real Headscale's responses for each supported version, including their quirks. +HTTP level, so Headplane's own API client runs against it unchanged. It only +models the parts of Headscale the unit tests use: users, pre-auth keys, API +keys, and nodes joined with a key. Anything else throws, so a test that needs +more fails instead of passing on made-up data. ### The Headscale contract -The fake is only useful if it behaves like the real thing. `tests/contract/headscale.ts` -describes what Headplane relies on from Headscale's API. The same suite runs -against the fake in the unit tests and against real Headscale in the -integration tests, so the two can't drift apart. When the fake needs a new -behavior, add it to the contract first and check it against real Headscale. +`tests/contract/headscale.ts` describes what Headplane relies on from +Headscale's API. All of it runs against real Headscale in the integration +tests. The part the fake models also runs against the fake in the unit tests, +so the two can't drift apart. When a unit test needs the fake to do more, move +the contract test for that behavior into the shared part, check it against real +Headscale, then make the fake pass it. ## Integration tests diff --git a/tests/contract/headscale.ts b/tests/contract/headscale.ts index a8305f59..72433e8c 100644 --- a/tests/contract/headscale.ts +++ b/tests/contract/headscale.ts @@ -6,11 +6,12 @@ import type { Machine, User } from "~/types"; import type { HeadscaleVersion } from "../harness/versions"; -// Runs against real Headscale and the fake, so the fake can't drift. +// The shared half runs against real Headscale and the fake, so the fake can't +// drift. The rest only runs against real Headscale. export interface JoinOptions { name: string; - user?: string; + user: string; tags?: string[]; routes?: string[]; exitNode?: boolean; @@ -22,7 +23,7 @@ export interface ContractBackend { apiKey: string; join(options: JoinOptions): Promise<{ id: string }>; // The key from the register URL a waiting Tailscale client prints. - pendingNode(name: string): Promise; + pendingNode?(name: string): Promise; dispose(): Promise; } @@ -57,6 +58,7 @@ function gte(version: HeadscaleVersion, target: string) { export function headscaleContract( version: HeadscaleVersion, createBackend: () => Promise, + { isReal }: { isReal: boolean }, ) { const hasStableKeyIds = gte(version, "0.28.0"); const canDisableExpiry = gte(version, "0.29.0"); @@ -122,26 +124,28 @@ export function headscaleContract( expect(await api.users.list({ name: "nobody-by-this-name" })).toEqual([]); }); - test("names are unique on create and rename", async () => { - const first = await api.users.create({ name: unique("taken") }); - const second = await api.users.create({ name: unique("free") }); + if (isReal) { + test("names are unique on create and rename", async () => { + const first = await api.users.create({ name: unique("taken") }); + const second = await api.users.create({ name: unique("free") }); - expect(await rejection(api.users.create({ name: first.name }))).toContain("UNIQUE"); - expect(await rejection(api.users.rename(second.id, first.name))).toContain("UNIQUE"); - }); + expect(await rejection(api.users.create({ name: first.name }))).toContain("UNIQUE"); + expect(await rejection(api.users.rename(second.id, first.name))).toContain("UNIQUE"); + }); - test("rename changes the name", async () => { - const user = await api.users.create({ name: unique("before") }); - const name = unique("after"); - await api.users.rename(user.id, name); + test("rename changes the name", async () => { + const user = await api.users.create({ name: unique("before") }); + const name = unique("after"); + await api.users.rename(user.id, name); - expect((await api.users.list({ id: user.id }))[0].name).toBe(name); - }); + expect((await api.users.list({ id: user.id }))[0].name).toBe(name); + }); - test("missing users are reported", async () => { - expect(await rejection(api.users.rename("999999", "x"))).toBe("User not found"); - expect(await rejection(api.users.delete("999999"))).toBe("User not found"); - }); + test("missing users are reported", async () => { + expect(await rejection(api.users.rename("999999", "x"))).toBe("User not found"); + expect(await rejection(api.users.delete("999999"))).toBe("User not found"); + }); + } }); describe("pre-auth keys", () => { @@ -209,35 +213,37 @@ export function headscaleContract( expect(key.expiration).toBe("0001-01-01T00:00:00Z"); }); - test("tags must start with tag:", async () => { - const user = await api.users.create({ name: unique("badtag") }); - const message = await rejection( - api.preAuthKeys.create({ + if (isReal) { + test("tags must start with tag:", async () => { + const user = await api.users.create({ name: unique("badtag") }); + const message = await rejection( + api.preAuthKeys.create({ + user: user.id, + reusable: false, + ephemeral: false, + expiration: null, + aclTags: ["server"], + }), + ); + + expect(message).toBe("Tag must start with the string 'tag:'"); + }); + + test("an expired key reports an expiration in the past", async () => { + const user = await api.users.create({ name: unique("expire") }); + const key = await api.preAuthKeys.create({ user: user.id, reusable: false, ephemeral: false, - expiration: null, - aclTags: ["server"], - }), - ); + expiration: new Date(Date.now() + 3_600_000), + aclTags: null, + }); - expect(message).toBe("Tag must start with the string 'tag:'"); - }); - - test("an expired key reports an expiration in the past", async () => { - const user = await api.users.create({ name: unique("expire") }); - const key = await api.preAuthKeys.create({ - user: user.id, - reusable: false, - ephemeral: false, - expiration: new Date(Date.now() + 3_600_000), - aclTags: null, + await api.preAuthKeys.expire(key); + const listed = (await api.preAuthKeys.listForUser(user.id)).find((k) => k.id === key.id)!; + expect(new Date(listed.expiration).getTime()).toBeLessThanOrEqual(Date.now()); }); - - await api.preAuthKeys.expire(key); - const listed = (await api.preAuthKeys.listForUser(user.id)).find((k) => k.id === key.id)!; - expect(new Date(listed.expiration).getTime()).toBeLessThanOrEqual(Date.now()); - }); + } test.runIf(hasStableKeyIds)("tag-only keys have no user and show up in listAll", async () => { const key = await api.preAuthKeys.create({ @@ -310,150 +316,164 @@ export function headscaleContract( ]); expect(node.expiry).toBe(canDisableExpiry ? null : "0001-01-01T00:00:00Z"); expect((await api.nodes.list()).map((n) => n.id)).toContain(id); - - await api.nodes.approveRoutes(id, ["10.0.0.0/24", "0.0.0.0/0", "::/0"]); - expect((await api.nodes.get(id)).approvedRoutes).toEqual([ - "0.0.0.0/0", - "10.0.0.0/24", - "::/0", - ]); }); - test("a node joined with a tagged key is owned by its tags", async () => { - const user = await api.users.create({ name: unique("tagger") }); - const { id } = await backend.join({ - name: unique("db"), - user: user.name, - tags: ["tag:db"], + if (isReal) { + test("approved routes replace the node's approved routes", async () => { + const user = await api.users.create({ name: unique("approver") }); + const { id } = await backend.join({ + name: unique("router"), + user: user.name, + routes: ["10.0.0.0/24"], + exitNode: true, + }); + + await api.nodes.approveRoutes(id, ["10.0.0.0/24", "0.0.0.0/0", "::/0"]); + expect((await api.nodes.get(id)).approvedRoutes).toEqual([ + "0.0.0.0/0", + "10.0.0.0/24", + "::/0", + ]); }); - const node = await api.nodes.get(id); - const listed = (await api.nodes.list()).find((n) => n.id === id)!; - expect(node.tags).toEqual(["tag:db"]); - expect(listed.tags).toEqual(["tag:db"]); - expectTaggedOwner(version, listed, node, user); - }); + test("a node joined with a tagged key is owned by its tags", async () => { + const user = await api.users.create({ name: unique("tagger") }); + const { id } = await backend.join({ + name: unique("db"), + user: user.name, + tags: ["tag:db"], + }); - test("rename sets the given name and keeps the hostname", async () => { - const user = await api.users.create({ name: unique("renamer") }); - const name = unique("host"); - const { id } = await backend.join({ name, user: user.name }); - const other = await backend.join({ name: unique("other"), user: user.name }); - const givenName = unique("renamed"); + const node = await api.nodes.get(id); + const listed = (await api.nodes.list()).find((n) => n.id === id)!; + expect(node.tags).toEqual(["tag:db"]); + expect(listed.tags).toEqual(["tag:db"]); + expectTaggedOwner(version, listed, node, user); + }); - await api.nodes.rename(id, givenName); - expect(await api.nodes.get(id)).toMatchObject({ name, givenName }); + test("rename sets the given name and keeps the hostname", async () => { + const user = await api.users.create({ name: unique("renamer") }); + const name = unique("host"); + const { id } = await backend.join({ name, user: user.name }); + const other = await backend.join({ name: unique("other"), user: user.name }); + const givenName = unique("renamed"); - expect(await rejection(api.nodes.rename(other.id, givenName))).toMatch(/is not unique/); - expect(await rejection(api.nodes.rename(other.id, "Bad_Name!"))).toContain("Bad_Name!"); - }); + await api.nodes.rename(id, givenName); + expect(await api.nodes.get(id)).toMatchObject({ name, givenName }); - test("tags are validated and replace the node's tags", async () => { - await api.policy.set(JSON.stringify({ tagOwners: { "tag:web": [] }, acls: [] })); - const user = await api.users.create({ name: unique("tags") }); - const { id } = await backend.join({ name: unique("tagme"), user: user.name }); + expect(await rejection(api.nodes.rename(other.id, givenName))).toMatch(/is not unique/); + expect(await rejection(api.nodes.rename(other.id, "Bad_Name!"))).toContain("Bad_Name!"); + }); - expect(await rejection(api.nodes.setTags(id, ["web"]))).toBe( - "Tag must start with the string 'tag:'", - ); + test("tags are validated and replace the node's tags", async () => { + await api.policy.set(JSON.stringify({ tagOwners: { "tag:web": [] }, acls: [] })); + const user = await api.users.create({ name: unique("tags") }); + const { id } = await backend.join({ name: unique("tagme"), user: user.name }); - await api.nodes.setTags(id, ["tag:web"]); - const node = await api.nodes.get(id); - const listed = (await api.nodes.list()).find((n) => n.id === id)!; - expect(node.tags).toEqual(["tag:web"]); - expectTaggedOwner(version, listed, node, user); - - if (hasStableKeyIds) { - expect(await rejection(api.nodes.setTags(id, ["tag:undeclared"]))).toBe( - "Requested tags [tag:undeclared] are invalid or not permitted", + expect(await rejection(api.nodes.setTags(id, ["web"]))).toBe( + "Tag must start with the string 'tag:'", ); - } else { - await api.nodes.setTags(id, ["tag:undeclared"]); - expect((await api.nodes.get(id)).tags).toEqual(["tag:undeclared"]); - } - }); - test("expiring a node sets its expiry to now", async () => { - const user = await api.users.create({ name: unique("expirer") }); - const { id } = await backend.join({ name: unique("expiring"), user: user.name }); + await api.nodes.setTags(id, ["tag:web"]); + const node = await api.nodes.get(id); + const listed = (await api.nodes.list()).find((n) => n.id === id)!; + expect(node.tags).toEqual(["tag:web"]); + expectTaggedOwner(version, listed, node, user); - await api.nodes.expire(id); - const node = await api.nodes.get(id); - expect(new Date(node.expiry!).getTime()).toBeLessThanOrEqual(Date.now()); - }); - - test.runIf(canDisableExpiry)("key expiry can be disabled and enabled again", async () => { - const user = await api.users.create({ name: unique("noexpiry") }); - const { id } = await backend.join({ name: unique("forever"), user: user.name }); - - await api.nodes.toggleExpiry(id, true); - expect((await api.nodes.get(id)).expiry).toBeNull(); - - // Turning it back on must not sign the machine out. - await api.nodes.toggleExpiry(id, false); - const node = await api.nodes.get(id); - expect(new Date(node.expiry!).getTime()).toBeGreaterThan(Date.now() + 170 * 86_400_000); - expect(node.online).toBe(true); - }); - - test.runIf(!canDisableExpiry)("disabling expiry expires the node instead", async () => { - const user = await api.users.create({ name: unique("noexpiry") }); - const { id } = await backend.join({ name: unique("forever"), user: user.name }); - - await api.nodes.toggleExpiry(id, true); - const node = await api.nodes.get(id); - expect(new Date(node.expiry!).getTime()).toBeLessThanOrEqual(Date.now()); - }); - - test.runIf(!hasStableKeyIds)("nodes can move to another user", async () => { - const from = await api.users.create({ name: unique("from") }); - const to = await api.users.create({ name: unique("to") }); - const { id } = await backend.join({ name: unique("mover"), user: from.name }); - - await api.nodes.reassignUser!(id, to.id); - expect((await api.nodes.get(id)).user?.id).toBe(to.id); - }); - - test.runIf(hasStableKeyIds)("nodes can't move to another user", () => { - expect(api.nodes.reassignUser).toBeUndefined(); - }); - - test("deleted nodes are gone", async () => { - const user = await api.users.create({ name: unique("deleter") }); - const { id } = await backend.join({ name: unique("doomed"), user: user.name }); - - await api.nodes.delete(id); - expect(await rejection(api.nodes.get(id))).toBe("Node not found"); - expect(await rejection(api.nodes.delete(id))).toBe("Node not found"); - }); - - test("a waiting node registers to a user with the key from its URL", async () => { - const user = await api.users.create({ name: unique("registrar") }); - const name = unique("pending"); - const key = await backend.pendingNode(name); - expect(key.startsWith("hskey-authreq-")).toBe(canDisableExpiry); - - const node = await api.nodes.register(user.name, key); - expect(node).toMatchObject({ - name, - user: { id: user.id }, - registerMethod: "REGISTER_METHOD_CLI", + if (hasStableKeyIds) { + expect(await rejection(api.nodes.setTags(id, ["tag:undeclared"]))).toBe( + "Requested tags [tag:undeclared] are invalid or not permitted", + ); + } else { + await api.nodes.setTags(id, ["tag:undeclared"]); + expect((await api.nodes.get(id)).tags).toEqual(["tag:undeclared"]); + } }); - }); - test("a malformed registration key is rejected", async () => { - const user = await api.users.create({ name: unique("badreg") }); - const message = await rejection(api.nodes.register(user.name, "nope")); + test("expiring a node sets its expiry to now", async () => { + const user = await api.users.create({ name: unique("expirer") }); + const { id } = await backend.join({ name: unique("expiring"), user: user.name }); - expect(message).toBe( - canDisableExpiry - ? 'Auth ID has invalid prefix: expected prefix "hskey-authreq-"' - : "Registration ID must be 24 characters long", - ); - }); + await api.nodes.expire(id); + const node = await api.nodes.get(id); + expect(new Date(node.expiry!).getTime()).toBeLessThanOrEqual(Date.now()); + }); + + test.runIf(canDisableExpiry)("key expiry can be disabled and enabled again", async () => { + const user = await api.users.create({ name: unique("noexpiry") }); + const { id } = await backend.join({ name: unique("forever"), user: user.name }); + + await api.nodes.toggleExpiry(id, true); + expect((await api.nodes.get(id)).expiry).toBeNull(); + + // Turning it back on must not sign the machine out. + await api.nodes.toggleExpiry(id, false); + const node = await api.nodes.get(id); + expect(new Date(node.expiry!).getTime()).toBeGreaterThan(Date.now() + 170 * 86_400_000); + expect(node.online).toBe(true); + }); + + test.runIf(!canDisableExpiry)("disabling expiry expires the node instead", async () => { + const user = await api.users.create({ name: unique("noexpiry") }); + const { id } = await backend.join({ name: unique("forever"), user: user.name }); + + await api.nodes.toggleExpiry(id, true); + const node = await api.nodes.get(id); + expect(new Date(node.expiry!).getTime()).toBeLessThanOrEqual(Date.now()); + }); + + test.runIf(!hasStableKeyIds)("nodes can move to another user", async () => { + const from = await api.users.create({ name: unique("from") }); + const to = await api.users.create({ name: unique("to") }); + const { id } = await backend.join({ name: unique("mover"), user: from.name }); + + await api.nodes.reassignUser!(id, to.id); + expect((await api.nodes.get(id)).user?.id).toBe(to.id); + }); + + test.runIf(hasStableKeyIds)("nodes can't move to another user", () => { + expect(api.nodes.reassignUser).toBeUndefined(); + }); + + test("deleted nodes are gone", async () => { + const user = await api.users.create({ name: unique("deleter") }); + const { id } = await backend.join({ name: unique("doomed"), user: user.name }); + + await api.nodes.delete(id); + expect(await rejection(api.nodes.get(id))).toBe("Node not found"); + expect(await rejection(api.nodes.delete(id))).toBe("Node not found"); + }); + + test("a waiting node registers to a user with the key from its URL", async () => { + const user = await api.users.create({ name: unique("registrar") }); + const name = unique("pending"); + const key = await backend.pendingNode!(name); + expect(key.startsWith("hskey-authreq-")).toBe(canDisableExpiry); + + const node = await api.nodes.register(user.name, key); + expect(node).toMatchObject({ + name, + user: { id: user.id }, + registerMethod: "REGISTER_METHOD_CLI", + }); + }); + + test("a malformed registration key is rejected", async () => { + const user = await api.users.create({ name: unique("badreg") }); + const message = await rejection(api.nodes.register(user.name, "nope")); + + expect(message).toBe( + canDisableExpiry + ? 'Auth ID has invalid prefix: expected prefix "hskey-authreq-"' + : "Registration ID must be 24 characters long", + ); + }); + } }); }); + if (!isReal) return; + // Deleting a user on 0.28.0 deletes every user's auth keys, which would // break the tests running alongside, so these get a tailnet of their own. describe(`Headscale ${version} user deletion contract`, () => { diff --git a/tests/integration/headscale/contract.test.ts b/tests/integration/headscale/contract.test.ts index 95ac3e18..9c3205aa 100644 --- a/tests/integration/headscale/contract.test.ts +++ b/tests/integration/headscale/contract.test.ts @@ -3,18 +3,22 @@ import { createTailnet } from "../../harness/tailnet"; import { testedVersions } from "../../harness/versions"; for (const version of testedVersions()) { - headscaleContract(version, async () => { - const tailnet = await createTailnet({ version }); - return { - headscale: tailnet.hs, - api: tailnet.api, - apiKey: tailnet.headscale.apiKey, - join: async (options) => tailnet.node(options), - pendingNode: async (name) => { - const node = await tailnet.pendingNode({ name }); - return node.registerUrl.split("/register/")[1]; - }, - dispose: () => tailnet.dispose(), - }; - }); + headscaleContract( + version, + async () => { + const tailnet = await createTailnet({ version }); + return { + headscale: tailnet.hs, + api: tailnet.api, + apiKey: tailnet.headscale.apiKey, + join: async (options) => tailnet.node(options), + pendingNode: async (name) => { + const node = await tailnet.pendingNode({ name }); + return node.registerUrl.split("/register/")[1]; + }, + dispose: () => tailnet.dispose(), + }; + }, + { isReal: true }, + ); } diff --git a/tests/support/fake-headscale.ts b/tests/support/fake-headscale.ts index 55d0597f..207b1862 100644 --- a/tests/support/fake-headscale.ts +++ b/tests/support/fake-headscale.ts @@ -9,21 +9,10 @@ import type { Transport, TransportRequest } from "~/server/headscale/api/transpo import type { HeadscaleVersion } from "../harness/versions"; import { LATEST_HEADSCALE } from "../harness/versions"; -// Wire-level, so Headplane's real client runs on it; tests/contract keeps it honest. +// Wire-level, so Headplane's real client runs on it. It only models what unit +// tests use; the fake half of tests/contract keeps that honest. const ZERO_TIME = "0001-01-01T00:00:00Z"; -const TAGGED_DEVICES = { - id: "2147455555", - name: "tagged-devices", - createdAt: ZERO_TIME, - displayName: "Tagged Devices", - email: "", - providerId: "", - provider: "", - profilePicUrl: "", -}; - -const EXIT_ROUTES = new Set(["0.0.0.0/0", "::/0"]); interface UserRecord { id: string; @@ -55,26 +44,17 @@ interface NodeRecord { discoKey: string; ipAddresses: string[]; name: string; - givenName: string; - userId: string | null; - tags: string[]; - lastSeen: string; - expiry: string | null; - preAuthKeyId: string | null; + userId: string; + preAuthKeyId: string; createdAt: string; - registerMethod: "REGISTER_METHOD_AUTH_KEY" | "REGISTER_METHOD_CLI" | "REGISTER_METHOD_OIDC"; - online: boolean; - approvedRoutes: string[]; availableRoutes: string[]; } export interface JoinOptions { name: string; - user?: string; - tags?: string[]; + user: string; routes?: string[]; exitNode?: boolean; - online?: boolean; } class HeadscaleError { @@ -103,41 +83,10 @@ function token(length: number) { return randomBytes(length).toString("base64url").slice(0, length); } -function standardizeHuJson(input: string) { - let output = ""; - let inString = false; - for (let i = 0; i < input.length; i++) { - const char = input[i]; - if (inString) { - output += char; - if (char === "\\") output += input[++i] ?? ""; - else if (char === '"') inString = false; - continue; - } - - if (char === '"') { - inString = true; - output += char; - } else if (char === "/" && input[i + 1] === "/") { - while (i < input.length && input[i] !== "\n") i++; - output += "\n"; - } else if (char === "/" && input[i + 1] === "*") { - i = input.indexOf("*/", i + 2); - if (i === -1) throw new Error("unterminated block comment"); - i++; - } else { - output += char; - } - } - - return output.replace(/,(\s*[}\]])/g, "$1"); -} - export type FakeHeadscale = ReturnType; export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEADSCALE) { const hasStableKeyIds = gte(version, "0.28.0"); - const hasFlatTags = gte(version, "0.28.0"); const hasNullExpiry = gte(version, "0.29.0"); const users = new Map(); @@ -147,10 +96,7 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA string, { id: string; prefix: string; expiration: string; createdAt: string } >(); - const pending = new Map(); - let policy: { policy: string; updatedAt: string } | undefined; let isReachable = true; - let nextIp = 1; const counters = { user: 0, key: 0, node: 0, apiKey: 0 }; function nextId(kind: keyof typeof counters) { @@ -162,6 +108,10 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA return new Date().toISOString(); } + function authKey() { + return hasStableKeyIds ? `hskey-auth-${token(12)}-${token(64)}` : hex(24); + } + function createApiKey() { const id = nextId("apiKey"); const secret = hasStableKeyIds @@ -186,22 +136,6 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA return [...users.values()].find((u) => u.name === name) ?? fail(500, 2, "user not found"); } - function findNode(id: string) { - return nodes.get(id) ?? fail(404, 5, "node not found"); - } - - function policyTagOwners(): Set { - if (!policy) return new Set(); - const parsed = JSON.parse(standardizeHuJson(policy.policy)) as { tagOwners?: object }; - return new Set(Object.keys(parsed.tagOwners ?? {})); - } - - function assertTags(tags: string[], status: number, code: number) { - if (tags.some((tag) => !tag.startsWith("tag:"))) { - fail(status, code, "tag must start with the string 'tag:'"); - } - } - function serializeKey(record: KeyRecord, masked: boolean) { const key = masked && hasStableKeyIds ? `${record.key.slice(0, 23)}-***` : record.key; return { @@ -217,18 +151,14 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA }; } - // Real Headscale names a tagged node's owner differently in a list and in - // a single-node read; see expectTaggedOwner in the contract. - function nodeUser(record: NodeRecord, view: "list" | "read") { - const owner = record.userId ? users.get(record.userId) : undefined; - if (!hasFlatTags || record.tags.length === 0) return owner; - if (view === "list") return TAGGED_DEVICES; - return gte(version, "0.29.0") ? null : owner; - } + function serializeNode(record: NodeRecord) { + const routes = { + givenName: record.name, + online: true, + approvedRoutes: [], + availableRoutes: record.availableRoutes, + }; - function serializeNode(record: NodeRecord, view: "list" | "read" = "read") { - const preAuthKey = record.preAuthKeyId ? keys.get(record.preAuthKeyId) : undefined; - const approved = new Set(record.approvedRoutes); const base = { id: record.id, machineKey: record.machineKey, @@ -236,92 +166,22 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA discoKey: record.discoKey, ipAddresses: record.ipAddresses, name: record.name, - user: nodeUser(record, view), - lastSeen: record.lastSeen, - expiry: record.expiry ?? (hasNullExpiry ? null : ZERO_TIME), - preAuthKey: preAuthKey ? serializeKey(preAuthKey, true) : null, + user: users.get(record.userId), + lastSeen: record.createdAt, + expiry: hasNullExpiry ? null : ZERO_TIME, + preAuthKey: serializeKey(keys.get(record.preAuthKeyId)!, true), createdAt: record.createdAt, - registerMethod: record.registerMethod, + registerMethod: "REGISTER_METHOD_AUTH_KEY", }; - const routes = { - givenName: record.givenName, - online: record.online, - approvedRoutes: record.approvedRoutes, - availableRoutes: record.availableRoutes, - subnetRoutes: record.availableRoutes.filter( - (route) => approved.has(route) && !EXIT_ROUTES.has(route), - ), - }; - - if (hasFlatTags) { - return { ...base, ...routes, tags: record.tags }; + if (hasStableKeyIds) { + return { ...base, ...routes, tags: [] }; } - return { ...base, forcedTags: record.tags, invalidTags: [], validTags: [], ...routes }; + return { ...base, forcedTags: [], invalidTags: [], validTags: [], ...routes }; } - function addNode( - options: JoinOptions, - method: NodeRecord["registerMethod"], - keyId: string | null, - ) { - const userId = options.user ? findUserByName(options.user).id : null; - if ([...nodes.values()].some((n) => n.givenName === options.name)) { - fail(500, 2, `node name is not unique: ${options.name}`); - } - - const id = nextId("node"); - const ip = nextIp++; - const routes = [...(options.routes ?? [])]; - if (options.exitNode) routes.push("0.0.0.0/0", "::/0"); - - const record: NodeRecord = { - id, - machineKey: `mkey:${hex(32)}`, - nodeKey: `nodekey:${hex(32)}`, - discoKey: `discokey:${hex(32)}`, - ipAddresses: [`100.64.0.${ip}`, `fd7a:115c:a1e0::${ip.toString(16)}`], - name: options.name, - givenName: options.name, - userId, - tags: options.tags ?? [], - lastSeen: now(), - expiry: null, - preAuthKeyId: keyId, - createdAt: now(), - registerMethod: method, - online: options.online ?? true, - approvedRoutes: [], - availableRoutes: routes.sort(), - }; - - nodes.set(id, record); - return record; - } - - function validNodeName(name: string) { - if (gte(version, "0.29.0")) { - if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i.test(name)) { - fail( - 500, - 2, - `renaming node: "${name}" is not a valid DNS label: "${name}" is not a valid DNS label: must end with a letter or number`, - ); - } - return; - } - - if (name !== name.toLowerCase()) { - fail( - 500, - 2, - `renaming node: hostname "${name}" must be lowercase (try "${name.toLowerCase()}")`, - ); - } - } - - type Handler = (match: string[], request: TransportRequest, query: URLSearchParams) => unknown; + type Handler = (match: string[], request: TransportRequest) => unknown; const routes: Array<[TransportRequest["method"], RegExp, Handler]> = [ [ "GET", @@ -347,14 +207,6 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA displayName?: string; pictureUrl?: string; }; - if ([...users.values()].some((u) => u.name === body.name)) { - const prefix = gte(version, "0.29.0") ? "creating user" : "failed to create user"; - fail( - 500, - 13, - `${prefix}: creating user: constraint failed: UNIQUE constraint failed: users.name (2067)`, - ); - } const user: UserRecord = { id: nextId("user"), @@ -371,41 +223,6 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA return { user }; }, ], - [ - "POST", - /^v1\/user\/([^/]+)\/rename\/([^/]+)$/, - ([id, encoded]) => { - const user = findUser(id); - const name = decodeURIComponent(encoded); - if ([...users.values()].some((u) => u.name === name && u.id !== id)) { - fail( - 500, - 2, - "updating user: constraint failed: UNIQUE constraint failed: users.name (2067)", - ); - } - - user.name = name; - return { user }; - }, - ], - [ - "DELETE", - /^v1\/user\/([^/]+)$/, - ([id]) => { - findUser(id); - const owned = [...nodes.values()].filter((n) => n.userId === id); - const blocking = gte(version, "0.29.0") - ? owned.filter((n) => n.tags.length === 0 || !hasFlatTags) - : owned; - if (blocking.length > 0) fail(500, 2, "user not empty: node(s) found"); - - users.delete(id); - // An upstream 0.28.0 bug: deleting any user deletes every auth key. - if (version === "0.28.0") keys.clear(); - return {}; - }, - ], [ "GET", /^v1\/apikey$/, @@ -440,180 +257,30 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA aclTags?: string[]; }; - const aclTags = body.aclTags ?? []; if (!body.user && !hasStableKeyIds) fail(500, 2, "user not found"); - assertTags(aclTags, 400, 3); if (body.user) findUser(body.user); const record: KeyRecord = { id: nextId("key"), - key: hasStableKeyIds ? `hskey-auth-${token(12)}-${token(64)}` : hex(24), + key: authKey(), userId: body.user ?? null, reusable: body.reusable, ephemeral: body.ephemeral, used: false, expiration: body.expiration ?? ZERO_TIME, createdAt: now(), - aclTags, + aclTags: body.aclTags ?? [], }; keys.set(record.id, record); return { preAuthKey: serializeKey(record, false) }; }, ], - [ - "POST", - /^v1\/preauthkey\/expire$/, - (_, request) => { - const body = request.body as { id?: string; user?: string; key?: string }; - const record = - hasStableKeyIds && body.id - ? keys.get(body.id) - : [...keys.values()].find((k) => k.userId === body.user && k.key === body.key); - - if (!record) fail(500, 2, "AuthKey not found"); - record!.expiration = now(); - return {}; - }, - ], + ["GET", /^v1\/node$/, () => ({ nodes: [...nodes.values()].map(serializeNode) })], [ "GET", - /^v1\/policy$/, - () => { - return policy ?? fail(500, 2, "loading ACL from database: acl policy not found"); - }, - ], - [ - "PUT", - /^v1\/policy$/, - (_, request) => { - const text = (request.body as { policy: string }).policy; - try { - JSON.parse(standardizeHuJson(text)); - } catch (error) { - fail( - 500, - 2, - `setting policy: parsing policy: parsing HuJSON: ${(error as Error).message}`, - ); - } - - policy = { policy: text, updatedAt: now() }; - return policy; - }, - ], - [ - "GET", - /^v1\/node$/, - () => ({ - nodes: [...nodes.values()].map((node) => serializeNode(node, "list")), - }), - ], - ["GET", /^v1\/node\/(\d+)$/, ([id]) => ({ node: serializeNode(findNode(id)) })], - [ - "DELETE", /^v1\/node\/(\d+)$/, - ([id]) => { - findNode(id); - nodes.delete(id); - return {}; - }, - ], - [ - "POST", - /^v1\/node\/(\d+)\/rename\/([^/]+)$/, - ([id, encoded]) => { - const record = findNode(id); - const name = decodeURIComponent(encoded); - validNodeName(name); - if ([...nodes.values()].some((n) => n.givenName === name && n.id !== id)) { - fail(500, 2, `${hasFlatTags ? "node name" : "name"} is not unique: ${name}`); - } - - record.givenName = name; - return { node: serializeNode(record) }; - }, - ], - [ - "POST", - /^v1\/node\/(\d+)\/tags$/, - ([id], request) => { - const record = findNode(id); - const tags = (request.body as { tags: string[] }).tags; - assertTags(tags, 500, 2); - if (hasFlatTags) { - const owners = policyTagOwners(); - const unknown = tags.filter((tag) => !owners.has(tag)); - if (unknown.length > 0) { - fail(400, 3, `requested tags [${unknown.join(" ")}] are invalid or not permitted`); - } - } - - record.tags = tags; - return { node: serializeNode(record) }; - }, - ], - [ - "POST", - /^v1\/node\/(\d+)\/approve_routes$/, - ([id], request) => { - const record = findNode(id); - record.approvedRoutes = [...(request.body as { routes: string[] }).routes].sort(); - return { node: serializeNode(record) }; - }, - ], - [ - "POST", - /^v1\/node\/(\d+)\/expire$/, - ([id], _, query) => { - const record = findNode(id); - if (gte(version, "0.29.0") && query.get("disableExpiry") === "true") { - record.expiry = null; - return { node: serializeNode(record) }; - } - - const expiry = query.get("expiry"); - if (expiry && new Date(expiry).getTime() > Date.now()) { - record.expiry = expiry; - return { node: serializeNode(record) }; - } - - record.expiry = now(); - if (gte(version, "0.29.0")) record.online = false; - return { node: serializeNode(record) }; - }, - ], - [ - "POST", - /^v1\/node\/(\d+)\/user$/, - ([id], request) => { - if (hasFlatTags) fail(404, 5, "Not Found"); - const record = findNode(id); - record.userId = findUser((request.body as { user: string }).user).id; - return { node: serializeNode(record) }; - }, - ], - [ - "POST", - /^v1\/node\/register$/, - (_, request) => { - const body = request.body as { user: string; key: string }; - if (gte(version, "0.29.0")) { - if (!body.key.startsWith("hskey-authreq-")) { - fail(500, 2, 'auth ID has invalid prefix: expected prefix "hskey-authreq-"'); - } - } else if (body.key.length !== 24) { - fail(500, 2, "registration ID must be 24 characters long"); - } - - const name = pending.get(body.key) ?? fail(500, 2, "node not found in registration cache"); - pending.delete(body.key); - return { - node: serializeNode( - addNode({ name, user: body.user, online: false }, "REGISTER_METHOD_CLI", null), - ), - }; - }, + ([id]) => ({ node: serializeNode(nodes.get(id) ?? fail(404, 5, "node not found")) }), ], ]; @@ -623,42 +290,25 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA async request(request: TransportRequest): Promise { const label = `${request.method} ${request.path}` as const; if (!isReachable) { - throw data( - { - requestUrl: label, - errorCode: "ECONNREFUSED", - errorMessage: "connect ECONNREFUSED", - extraData: null, - }, - { status: 502, statusText: "Bad Gateway" }, - ); + throw transportError(label, "ECONNREFUSED", "connect ECONNREFUSED"); } // Undici refuses to send a header with these, as the real transport does. if (!/^[\x20-\x7e]*$/.test(request.apiKey)) { - throw data( - { - requestUrl: label, - errorCode: "UND_ERR_INVALID_ARG", - errorMessage: "invalid Authorization header", - extraData: null, - }, - { status: 502, statusText: "Bad Gateway" }, - ); + throw transportError(label, "UND_ERR_INVALID_ARG", "invalid Authorization header"); } if (!apiKeys.has(request.apiKey)) { throw apiError(label, 401, "Unauthorized", null); } - const [path, search = ""] = request.path.split("?"); - const query = new URLSearchParams(search); + const [path] = request.path.split("?"); for (const [method, pattern, handler] of routes) { const match = path.match(pattern); if (method !== request.method || !match) continue; try { - return structuredClone(handler(match.slice(1), request, query)) as T; + return structuredClone(handler(match.slice(1), request)) as T; } catch (error) { if (!(error instanceof HeadscaleError)) throw error; const body = { code: error.code, message: error.message, details: [] }; @@ -671,15 +321,7 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA async getPublic(path: `/${string}`): Promise { if (!isReachable) { - throw data( - { - requestUrl: `GET ${path}`, - errorCode: "ECONNREFUSED", - errorMessage: "connect ECONNREFUSED", - extraData: null, - }, - { status: 502, statusText: "Bad Gateway" }, - ); + throw transportError(`GET ${path}`, "ECONNREFUSED", "connect ECONNREFUSED"); } if (path !== "/version") throw new Error(`Fake Headscale doesn't implement GET ${path}`); @@ -701,37 +343,38 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA // Joins a node the way a Tailscale client with a pre-auth key would. join(options: JoinOptions) { - const user = options.user ? findUserByName(options.user) : undefined; - if (!user && !hasStableKeyIds) { - throw new Error(`Headscale ${version} can't join tag-only nodes`); - } - + const user = findUserByName(options.user); const key: KeyRecord = { id: nextId("key"), - key: hasStableKeyIds ? `hskey-auth-${token(12)}-${token(64)}` : hex(24), - userId: user?.id ?? null, + key: authKey(), + userId: user.id, reusable: false, ephemeral: false, used: true, expiration: new Date(Date.now() + 3_600_000).toISOString(), createdAt: now(), - aclTags: options.tags ?? [], + aclTags: [], }; - keys.set(key.id, key); - return { id: addNode(options, "REGISTER_METHOD_AUTH_KEY", key.id).id }; - }, - // Returns the key a waiting Tailscale client prints in its register URL. - pendingNode(name: string) { - const id = token(24); - const key = gte(version, "0.29.0") ? `hskey-authreq-${id}` : id; - pending.set(key, name); - return { registerKey: key, registerUrl: `http://headscale:8080/register/${key}` }; - }, + const id = nextId("node"); + const routes = [...(options.routes ?? [])]; + if (options.exitNode) routes.push("0.0.0.0/0", "::/0"); - setOnline(id: string, online: boolean) { - findNode(id).online = online; + nodes.set(id, { + id, + machineKey: `mkey:${hex(32)}`, + nodeKey: `nodekey:${hex(32)}`, + discoKey: `discokey:${hex(32)}`, + ipAddresses: [`100.64.0.${id}`, `fd7a:115c:a1e0::${Number(id).toString(16)}`], + name: options.name, + userId: user.id, + preAuthKeyId: key.id, + createdAt: now(), + availableRoutes: routes.sort(), + }); + + return { id }; }, setReachable(reachable: boolean) { @@ -740,6 +383,13 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA }; } +function transportError(requestUrl: `${string} ${string}`, errorCode: string, message: string) { + return data( + { requestUrl, errorCode, errorMessage: message, extraData: null }, + { status: 502, statusText: "Bad Gateway" }, + ); +} + function apiError( requestUrl: `${string} ${string}`, statusCode: number, diff --git a/tests/unit/headscale/fake-contract.test.ts b/tests/unit/headscale/fake-contract.test.ts index daa6d1c8..60889d02 100644 --- a/tests/unit/headscale/fake-contract.test.ts +++ b/tests/unit/headscale/fake-contract.test.ts @@ -3,15 +3,18 @@ import { HEADSCALE_VERSIONS } from "../../harness/versions"; import { createFakeHeadscale } from "../../support/fake-headscale"; for (const version of HEADSCALE_VERSIONS) { - headscaleContract(version, async () => { - const { server, headscale, api } = await createFakeHeadscale(version); - return { - headscale, - api, - apiKey: server.apiKey, - join: async (options) => server.join(options), - pendingNode: async (name) => server.pendingNode(name).registerKey, - dispose: () => headscale.dispose(), - }; - }); + headscaleContract( + version, + async () => { + const { server, headscale, api } = await createFakeHeadscale(version); + return { + headscale, + api, + apiKey: server.apiKey, + join: async (options) => server.join(options), + dispose: () => headscale.dispose(), + }; + }, + { isReal: false }, + ); }