diff --git a/tests/contract/headscale.ts b/tests/contract/headscale.ts new file mode 100644 index 00000000..8ea0975e --- /dev/null +++ b/tests/contract/headscale.ts @@ -0,0 +1,495 @@ +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import type { Headscale, HeadscaleClient } from "~/server/headscale/api"; +import { headscaleErrorMessage } from "~/server/headscale/api/error-client"; +import type { Machine, User } from "~/types"; + +import type { HeadscaleVersion } from "../harness/versions"; + +// What Headplane relies on from Headscale's API, asserted through Headplane's +// own client. It runs against real Headscale in tests/integration and against +// the fake in tests/unit, so the fake can't drift from the real thing. + +export interface JoinOptions { + name: string; + user?: string; + tags?: string[]; + routes?: string[]; + exitNode?: boolean; +} + +export interface ContractBackend { + headscale: Headscale; + api: HeadscaleClient; + apiKey: string; + join(options: JoinOptions): Promise<{ id: string }>; + // The key from the register URL a waiting Tailscale client prints. + pendingNode(name: string): Promise; + dispose(): Promise; +} + +// Tagged nodes belong to "tagged-devices" in 0.28+ lists, but a single-node +// read still names the user whose key registered it (0.28) or nobody (0.29). +function expectTaggedOwner(version: HeadscaleVersion, listed: Machine, read: Machine, user: User) { + if (!gte(version, "0.28.0")) { + expect(listed.user?.id).toBe(user.id); + expect(read.user?.id).toBe(user.id); + return; + } + + expect(listed.user?.name).toBe("tagged-devices"); + expect(read.user?.id).toBe(gte(version, "0.29.0") ? undefined : user.id); +} + +async function rejection(promise: Promise) { + const error = await promise.then( + () => undefined, + (error: unknown) => error, + ); + + expect(error, "expected the call to fail").toBeDefined(); + return headscaleErrorMessage(error) ?? String(error); +} + +function gte(version: HeadscaleVersion, target: string) { + const [a, b] = version.split(".").map(Number); + const [x, y] = target.split(".").map(Number); + return a !== x ? a > x : b >= y; +} + +export function headscaleContract( + version: HeadscaleVersion, + createBackend: () => Promise, +) { + const hasStableKeyIds = gte(version, "0.28.0"); + const canDisableExpiry = gte(version, "0.29.0"); + + // Tests use unique names so they can share one tailnet and run at once. + describe.concurrent(`Headscale ${version} contract`, () => { + let backend: ContractBackend; + let api: HeadscaleClient; + let counter = 0; + const unique = (name: string) => `${name}${++counter}`; + + beforeAll(async () => { + backend = await createBackend(); + api = backend.api; + }); + + afterAll(async () => { + await backend?.dispose(); + }); + + test("capabilities follow the version", () => { + expect(backend.headscale.version.unknown).toBe(false); + expect(backend.headscale.capabilities).toEqual({ + preAuthKeysHaveStableIds: hasStableKeyIds, + nodeTagsAreFlat: hasStableKeyIds, + nodeOwnerIsImmutable: hasStableKeyIds, + registerKeyIncludesAuthReqPrefix: canDisableExpiry, + keyExpiryCanBeDisabled: canDisableExpiry, + }); + }); + + test("a wrong API key is unauthorized", async () => { + const message = await rejection(backend.headscale.client("not-a-key").users.list()); + expect(message).toBe("Unauthorized"); + }); + + describe("users", () => { + test("created users keep their profile and can be filtered", async () => { + const name = `${unique("bob")}@`; + const email = `${name}example.com`; + const user = await api.users.create({ + name, + email, + displayName: "Bob B", + pictureUrl: "https://example.com/b.png", + }); + + expect(user).toMatchObject({ + name, + email, + displayName: "Bob B", + profilePicUrl: "https://example.com/b.png", + }); + + expect(await api.users.list({ name })).toEqual([user]); + expect(await api.users.list({ email })).toEqual([user]); + expect(await api.users.list({ id: user.id })).toEqual([user]); + 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") }); + + 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); + + 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("a user who owns a node can't be deleted", async () => { + const user = await api.users.create({ name: unique("owner") }); + await backend.join({ name: unique("owned"), user: user.name }); + + expect(await rejection(api.users.delete(user.id))).toBe("User not empty: node(s) found"); + }); + + test("deleted users are gone", async () => { + const user = await api.users.create({ name: unique("gone") }); + await api.users.delete(user.id); + + expect(await api.users.list({ id: user.id })).toEqual([]); + }); + }); + + describe("pre-auth keys", () => { + test("a created key has the options it was created with", async () => { + const user = await api.users.create({ name: unique("keys") }); + const expiration = new Date(Date.now() + 3_600_000); + const key = await api.preAuthKeys.create({ + user: user.id, + reusable: true, + ephemeral: true, + expiration, + aclTags: ["tag:ci"], + }); + + expect(key).toMatchObject({ + user: { id: user.id, name: user.name }, + reusable: true, + ephemeral: true, + used: false, + aclTags: ["tag:ci"], + }); + expect(new Date(key.expiration).getTime()).toBeCloseTo(expiration.getTime(), -3); + + await api.preAuthKeys.create({ + user: (await api.users.create({ name: unique("otherkeys") })).id, + reusable: false, + ephemeral: false, + expiration: null, + aclTags: null, + }); + + const listed = await api.preAuthKeys.listForUser(user.id); + expect(listed.map((k) => k.id)).toEqual([key.id]); + }); + + test("only the create response has the full secret", async () => { + const user = await api.users.create({ name: unique("secret") }); + const key = await api.preAuthKeys.create({ + user: user.id, + reusable: false, + ephemeral: false, + expiration: new Date(Date.now() + 3_600_000), + aclTags: null, + }); + + const listed = (await api.preAuthKeys.listForUser(user.id)).find((k) => k.id === key.id)!; + if (hasStableKeyIds) { + expect(key.key).toMatch(/^hskey-auth-/); + expect(listed.key).toBe(`${key.key.slice(0, 23)}-***`); + } else { + expect(listed.key).toBe(key.key); + } + }); + + test("a key without an expiration gets the zero time", async () => { + const user = await api.users.create({ name: unique("forever") }); + const key = await api.preAuthKeys.create({ + user: user.id, + reusable: false, + ephemeral: false, + expiration: null, + aclTags: null, + }); + + 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({ + 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: 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()); + }); + + test.runIf(hasStableKeyIds)("tag-only keys have no user and show up in listAll", async () => { + const key = await api.preAuthKeys.create({ + user: null, + reusable: false, + ephemeral: false, + expiration: null, + aclTags: ["tag:server"], + }); + + expect(key.user).toBeNull(); + const all = await api.preAuthKeys.listAll!(); + expect(all.find((k) => k.id === key.id)).toMatchObject({ + user: null, + aclTags: ["tag:server"], + }); + }); + + test.runIf(!hasStableKeyIds)("tag-only keys and listAll aren't available", async () => { + expect(api.preAuthKeys.listAll).toBeUndefined(); + const message = await rejection( + api.preAuthKeys.create({ + user: null, + reusable: false, + ephemeral: false, + expiration: null, + aclTags: ["tag:server"], + }), + ); + + expect(message).toBe("User not found"); + }); + }); + + test("API keys are listed by prefix", async () => { + const keys = await api.apiKeys.list(); + const prefix = hasStableKeyIds + ? `${backend.apiKey.slice(0, 22)}-***` + : backend.apiKey.slice(0, 7); + + expect(keys.map((k) => k.prefix)).toContain(prefix); + }); + + describe("nodes", () => { + test("a joined node reports its owner, addresses, and advertised routes", async () => { + const user = await api.users.create({ name: unique("nodeowner") }); + const name = unique("web"); + const { id } = await backend.join({ + name, + user: user.name, + routes: ["10.0.0.0/24"], + exitNode: true, + }); + + const node = await api.nodes.get(id); + expect(node).toMatchObject({ + id, + name, + givenName: name, + user: { id: user.id, name: user.name }, + online: true, + registerMethod: "REGISTER_METHOD_AUTH_KEY", + tags: [], + approvedRoutes: [], + availableRoutes: ["0.0.0.0/0", "10.0.0.0/24", "::/0"], + }); + expect(node.ipAddresses).toEqual([ + expect.stringMatching(/^100\.64\./), + expect.stringMatching(/^fd7a:115c:a1e0::/), + ]); + 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"], + }); + + 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("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"); + + await api.nodes.rename(id, givenName); + expect(await api.nodes.get(id)).toMatchObject({ name, givenName }); + + 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!"); + }); + + 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.setTags(id, ["web"]))).toBe( + "Tag must start with the string 'tag:'", + ); + + 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", + ); + } 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.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", 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(); + }); + + 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", + ); + }); + }); + }); + + describe(`Headscale ${version} policy contract`, () => { + let backend: ContractBackend; + + beforeAll(async () => { + backend = await createBackend(); + }); + + afterAll(async () => { + await backend?.dispose(); + }); + + test("a new tailnet has no policy", async () => { + expect(await rejection(backend.api.policy.get())).toBe( + "Loading ACL from database: acl policy not found", + ); + }); + + test("a policy round-trips with its comments and trailing commas", async () => { + const policy = '// keep me\n{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}],}'; + const saved = await backend.api.policy.set(policy); + const read = await backend.api.policy.get(); + + expect(saved.policy).toBe(policy); + expect(read).toEqual({ policy, updatedAt: saved.updatedAt }); + }); + + test("invalid HuJSON is rejected", async () => { + expect(await rejection(backend.api.policy.set("{"))).toMatch( + /^Setting policy: parsing policy: parsing HuJSON: /, + ); + }); + }); +} diff --git a/tests/harness/tailnet.ts b/tests/harness/tailnet.ts index 257a8071..ebd7fdfd 100644 --- a/tests/harness/tailnet.ts +++ b/tests/harness/tailnet.ts @@ -55,9 +55,10 @@ export async function createTailnet(options: TailnetOptions = {}) { return existing; } - const created = await api.users.create({ name, ...profile }); - users.set(name, created); - return created; + const [found] = await api.users.list({ name }); + const resolved = found ?? (await api.users.create({ name, ...profile })); + users.set(name, resolved); + return resolved; } async function authKey(keyOptions: AuthKeyOptions = {}) { diff --git a/tests/integration/headscale/contract.test.ts b/tests/integration/headscale/contract.test.ts new file mode 100644 index 00000000..95ac3e18 --- /dev/null +++ b/tests/integration/headscale/contract.test.ts @@ -0,0 +1,20 @@ +import { headscaleContract } from "../../contract/headscale"; +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(), + }; + }); +} diff --git a/tests/support/fake-headscale.ts b/tests/support/fake-headscale.ts new file mode 100644 index 00000000..f18218bc --- /dev/null +++ b/tests/support/fake-headscale.ts @@ -0,0 +1,745 @@ +import { randomBytes } from "node:crypto"; + +import { data } from "react-router"; + +import { createHeadscale } from "~/server/headscale/api"; +import type { HeadscaleAPIError } from "~/server/headscale/api/error-client"; +import type { Transport, TransportRequest } from "~/server/headscale/api/transport"; + +import type { HeadscaleVersion } from "../harness/versions"; +import { LATEST_HEADSCALE } from "../harness/versions"; + +// An in-memory Headscale that answers at the wire level, so Headplane's real +// API client runs against it. It copies the responses and errors recorded +// from real Headscale, and the contract suite in tests/contract keeps the two +// in step. Anything it can't do should fail loudly, not guess. + +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; + name: string; + createdAt: string; + displayName: string; + email: string; + providerId: string; + provider: string; + profilePicUrl: string; +} + +interface KeyRecord { + id: string; + key: string; + userId: string | null; + reusable: boolean; + ephemeral: boolean; + used: boolean; + expiration: string; + createdAt: string; + aclTags: string[]; +} + +interface NodeRecord { + id: string; + machineKey: string; + nodeKey: string; + discoKey: string; + ipAddresses: string[]; + name: string; + givenName: string; + userId: string | null; + tags: string[]; + lastSeen: string; + expiry: string | null; + preAuthKeyId: string | null; + 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[]; + routes?: string[]; + exitNode?: boolean; + online?: boolean; +} + +class HeadscaleError { + constructor( + readonly status: number, + readonly code: number, + readonly message: string, + ) {} +} + +const fail = (status: number, code: number, message: string): never => { + throw new HeadscaleError(status, code, message); +}; + +function gte(version: HeadscaleVersion, target: string) { + const [a, b, c] = version.split(".").map(Number); + const [x, y, z] = target.split(".").map(Number); + return a !== x ? a > x : b !== y ? b > y : c >= z; +} + +function hex(bytes: number) { + return randomBytes(bytes).toString("hex"); +} + +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(); + const keys = new Map(); + const nodes = new Map(); + const apiKeys = new Map< + 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) { + counters[kind] += 1; + return String(counters[kind]); + } + + function now() { + return new Date().toISOString(); + } + + function createApiKey() { + const id = nextId("apiKey"); + const secret = hasStableKeyIds + ? `hskey-api-${token(12)}-${token(64)}` + : `${token(7)}.${token(32)}`; + const prefix = hasStableKeyIds ? `${secret.slice(0, 22)}-***` : secret.slice(0, 7); + apiKeys.set(secret, { + id, + prefix, + expiration: new Date(Date.now() + 90 * 86_400_000).toISOString(), + createdAt: now(), + }); + + return secret; + } + + function findUser(id: string) { + return users.get(id) ?? fail(500, 2, "user not found"); + } + + function findUserByName(name: string) { + 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 { + user: record.userId ? (users.get(record.userId) ?? null) : null, + id: record.id, + key, + reusable: record.reusable, + ephemeral: record.ephemeral, + used: record.used, + expiration: record.expiration, + createdAt: record.createdAt, + aclTags: record.aclTags, + }; + } + + // 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, 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, + nodeKey: record.nodeKey, + 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, + createdAt: record.createdAt, + registerMethod: record.registerMethod, + }; + + 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 }; + } + + return { ...base, forcedTags: record.tags, 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; + const routes: Array<[TransportRequest["method"], RegExp, Handler]> = [ + [ + "GET", + /^v1\/user$/, + (_, request) => { + const filter = request.query ?? {}; + const list = [...users.values()].filter( + (u) => + (filter.id === undefined || u.id === filter.id) && + (filter.name === undefined || u.name === filter.name) && + (filter.email === undefined || u.email === filter.email), + ); + return { users: list }; + }, + ], + [ + "POST", + /^v1\/user$/, + (_, request) => { + const body = request.body as { + name: string; + email?: string; + 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"), + name: body.name, + createdAt: now(), + displayName: body.displayName ?? "", + email: body.email ?? "", + providerId: "", + provider: "", + profilePicUrl: body.pictureUrl ?? "", + }; + + users.set(user.id, user); + 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); + return {}; + }, + ], + [ + "GET", + /^v1\/apikey$/, + () => ({ + apiKeys: [...apiKeys.values()].map((key) => ({ ...key, lastSeen: null })), + }), + ], + [ + "GET", + /^v1\/preauthkey$/, + (_, request) => { + const userId = request.query?.user as string | undefined; + if (userId === undefined && !hasStableKeyIds) fail(500, 2, "user not found"); + if (userId !== undefined) findUser(userId); + + // 0.28+ ignores the user filter. + const list = [...keys.values()].filter( + (k) => hasStableKeyIds || userId === undefined || k.userId === userId, + ); + return { preAuthKeys: list.map((k) => serializeKey(k, true)) }; + }, + ], + [ + "POST", + /^v1\/preauthkey$/, + (_, request) => { + const body = request.body as { + user?: string; + reusable: boolean; + ephemeral: boolean; + expiration: string | null; + 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), + userId: body.user ?? null, + reusable: body.reusable, + ephemeral: body.ephemeral, + used: false, + expiration: body.expiration ?? ZERO_TIME, + createdAt: now(), + 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\/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) }; + } + + 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), + ), + }; + }, + ], + ]; + + const apiKey = createApiKey(); + + const transport: Transport = { + 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" }, + ); + } + + if (!apiKeys.has(request.apiKey)) { + throw apiError(label, 401, "Unauthorized", null); + } + + const [path, search = ""] = request.path.split("?"); + const query = new URLSearchParams(search); + 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; + } catch (error) { + if (!(error instanceof HeadscaleError)) throw error; + const body = { code: error.code, message: error.message, details: [] }; + throw apiError(label, error.status, JSON.stringify(body), body); + } + } + + throw new Error(`Fake Headscale doesn't implement ${label}`); + }, + + async getPublic(path: `/${string}`): Promise { + if (!isReachable) { + throw data( + { + requestUrl: `GET ${path}`, + errorCode: "ECONNREFUSED", + errorMessage: "connect ECONNREFUSED", + extraData: null, + }, + { status: 502, statusText: "Bad Gateway" }, + ); + } + + if (path !== "/version") throw new Error(`Fake Headscale doesn't implement GET ${path}`); + return { version: `v${version}` } as T; + }, + + async health() { + return isReachable; + }, + + async dispose() {}, + }; + + return { + version, + apiKey, + transport, + createApiKey, + + // 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 key: KeyRecord = { + id: nextId("key"), + key: hasStableKeyIds ? `hskey-auth-${token(12)}-${token(64)}` : hex(24), + userId: user?.id ?? null, + reusable: false, + ephemeral: false, + used: true, + expiration: new Date(Date.now() + 3_600_000).toISOString(), + createdAt: now(), + aclTags: options.tags ?? [], + }; + + 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}` }; + }, + + setOnline(id: string, online: boolean) { + findNode(id).online = online; + }, + + setReachable(reachable: boolean) { + isReachable = reachable; + }, + }; +} + +function apiError( + requestUrl: `${string} ${string}`, + statusCode: number, + rawData: string, + body: Record | null, +) { + return data({ requestUrl, statusCode, rawData, data: body } satisfies HeadscaleAPIError, { + status: 502, + statusText: "Bad Gateway", + }); +} + +// A `Headscale` backed by the fake, built through Headplane's own factory. +export async function createFakeHeadscale(version: HeadscaleVersion = LATEST_HEADSCALE) { + const server = createFakeHeadscaleServer(version); + const headscale = await createHeadscale({ + url: "http://fake-headscale", + transport: server.transport, + }); + return { server, headscale, api: headscale.client(server.apiKey) }; +} diff --git a/tests/unit/headscale/fake-contract.test.ts b/tests/unit/headscale/fake-contract.test.ts new file mode 100644 index 00000000..daa6d1c8 --- /dev/null +++ b/tests/unit/headscale/fake-contract.test.ts @@ -0,0 +1,17 @@ +import { headscaleContract } from "../../contract/headscale"; +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(), + }; + }); +} diff --git a/vitest.config.ts b/vitest.config.ts index 5ab0c940..b60d2b9d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -39,7 +39,7 @@ export default defineConfig({ extends: true, test: { name: "integration:harness", - include: ["tests/integration/harness/**/*.test.ts"], + include: ["tests/integration/{harness,headscale}/**/*.test.ts"], testTimeout: 120_000, hookTimeout: 120_000, },