test(fake): model only what unit tests use

This commit is contained in:
Aarnav Tale
2026-10-17 22:57:07 -07:00
parent f151e8521a
commit 8322de9ebc
6 changed files with 303 additions and 620 deletions
+11 -8
View File
@@ -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/`)
+10 -7
View File
@@ -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
+188 -168
View File
@@ -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<string>;
pendingNode?(name: string): Promise<string>;
dispose(): Promise<void>;
}
@@ -57,6 +58,7 @@ function gte(version: HeadscaleVersion, target: string) {
export function headscaleContract(
version: HeadscaleVersion,
createBackend: () => Promise<ContractBackend>,
{ 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`, () => {
+18 -14
View File
@@ -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 },
);
}
+62 -412
View File
@@ -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<typeof createFakeHeadscaleServer>;
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<string, UserRecord>();
@@ -147,10 +96,7 @@ export function createFakeHeadscaleServer(version: HeadscaleVersion = LATEST_HEA
string,
{ id: string; prefix: string; expiration: string; createdAt: string }
>();
const pending = new Map<string, string>();
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<string> {
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<T>(request: TransportRequest): Promise<T> {
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<T>(path: `/${string}`): Promise<T> {
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,
+14 -11
View File
@@ -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 },
);
}