mirror of
https://github.com/tale/headplane.git
synced 2026-10-25 22:20:30 +00:00
test: hold an in-memory Headscale to the same contract as the real one
This commit is contained in:
@@ -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: /,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user