test: hold an in-memory Headscale to the same contract as the real one

This commit is contained in:
Aarnav Tale
2026-10-17 17:20:36 -07:00
parent 4f41fc6748
commit 74e79b175e
6 changed files with 1282 additions and 4 deletions
+495
View File
@@ -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<string>;
dispose(): Promise<void>;
}
// 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<unknown>) {
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<ContractBackend>,
) {
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: /,
);
});
});
}
+4 -3
View File
@@ -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 = {}) {
@@ -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(),
};
});
}
+745
View File
@@ -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<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>();
const keys = new Map<string, KeyRecord>();
const nodes = new Map<string, NodeRecord>();
const apiKeys = new Map<
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) {
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<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 {
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<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" },
);
}
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<T>(path: `/${string}`): Promise<T> {
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<string, unknown> | 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) };
}
@@ -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(),
};
});
}
+1 -1
View File
@@ -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,
},