mirror of
https://github.com/tale/headplane.git
synced 2026-10-25 22:20:30 +00:00
test(fake): model only what unit tests use
This commit is contained in:
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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
@@ -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`, () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user