mirror of
https://github.com/tale/headplane.git
synced 2026-08-11 14:26:56 +00:00
fix: handle registration keys on headscale 0.29+
Closes HP-555.
This commit is contained in:
@@ -1,19 +1,63 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { RouterContextProvider } from "react-router";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { machineAction } from "~/routes/machines/machine-actions";
|
||||
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import { getBootstrapClient, getNode, getRuntimeClient, HS_VERSIONS } from "../setup/env";
|
||||
|
||||
function registerRequest(registerKey: string) {
|
||||
const form = new FormData();
|
||||
form.set("action_id", "register");
|
||||
form.set("register_key", registerKey);
|
||||
form.set("user", "node-reg@");
|
||||
|
||||
return new Request("http://headplane.test/machines", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
function actionContext(api: Awaited<ReturnType<typeof getRuntimeClient>>) {
|
||||
const auth = { can: vi.fn(() => true) };
|
||||
const liveStore = { refresh: vi.fn() };
|
||||
const principal = { id: "integration-user" };
|
||||
const context = new RouterContextProvider();
|
||||
|
||||
context.set(authContext, auth as never);
|
||||
context.set(headscaleLiveStoreContext, liveStore as never);
|
||||
context.set(requestApiContext, vi.fn(async () => ({ principal, api })) as never);
|
||||
|
||||
return { auth, context, liveStore };
|
||||
}
|
||||
|
||||
describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
|
||||
let workingNodeId: string;
|
||||
|
||||
test("nodes can register via their nodekey", async () => {
|
||||
test("nodes can register from a Tailscale registration URL", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const tailnetNode = await getNode(version);
|
||||
const { auth, context, liveStore } = actionContext(client);
|
||||
|
||||
const user = await client.users.create({ name: "node-reg@" });
|
||||
const node = await client.nodes.register(user.name, tailnetNode.authCode);
|
||||
expect(user.name).toBe("node-reg@");
|
||||
|
||||
const response = await machineAction({
|
||||
request: registerRequest(tailnetNode.registerUrl),
|
||||
context,
|
||||
params: {},
|
||||
} as never);
|
||||
|
||||
expect(auth.can).toHaveBeenCalledWith(expect.anything(), Capabilities.write_machines);
|
||||
expect(liveStore.refresh).toHaveBeenCalledOnce();
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect((response as Response).status).toBe(302);
|
||||
|
||||
const nodes = await client.nodes.list();
|
||||
const node = nodes.find((n) => n.name === tailnetNode.nodeName);
|
||||
expect(node).toBeDefined();
|
||||
expect(node.registerMethod).toBe("REGISTER_METHOD_CLI");
|
||||
expect(node.name).toBe(tailnetNode.nodeName);
|
||||
expect(node?.registerMethod).toBe("REGISTER_METHOD_CLI");
|
||||
});
|
||||
|
||||
test("nodes can be retrieved", async () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ export async function getNode(version: Version) {
|
||||
const { tailscaleNode } = await ensureVersion(version);
|
||||
return {
|
||||
authCode: tailscaleNode.authCode,
|
||||
registerUrl: tailscaleNode.registerUrl,
|
||||
nodeName: tailscaleNode.nodeName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type Version = string;
|
||||
export interface TailscaleNodeEnv {
|
||||
container: tc.StartedTestContainer;
|
||||
authCode: string;
|
||||
registerUrl: string;
|
||||
nodeName: string;
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ export async function startTailscaleNode(
|
||||
if (!token) return;
|
||||
|
||||
authCodeResolved = true;
|
||||
resolveAuthCode(token);
|
||||
resolveAuthCode(`${prefix}${token}`);
|
||||
rl.close();
|
||||
});
|
||||
|
||||
@@ -68,7 +69,7 @@ export async function startTailscaleNode(
|
||||
.withWaitStrategy(tc.Wait.forLogMessage(prefix).withStartupTimeout(30_000))
|
||||
.start();
|
||||
|
||||
const authCode = await Promise.race<string>([
|
||||
const registerUrl = await Promise.race<string>([
|
||||
authCodePromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
@@ -77,6 +78,7 @@ export async function startTailscaleNode(
|
||||
),
|
||||
),
|
||||
]);
|
||||
const authCode = registerUrl.slice(prefix.length);
|
||||
|
||||
return { container, authCode, nodeName };
|
||||
return { container, authCode, registerUrl, nodeName };
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ describe("capabilitiesFor", () => {
|
||||
preAuthKeysHaveStableIds: true,
|
||||
nodeTagsAreFlat: true,
|
||||
nodeOwnerIsImmutable: true,
|
||||
registerKeyIncludesAuthReqPrefix: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,10 +92,16 @@ describe("capabilitiesFor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("0.29.0 enables the prefixed AuthID register key", () => {
|
||||
const caps = capabilitiesFor(parseServerVersion("0.29.0"));
|
||||
expect(caps.registerKeyIncludesAuthReqPrefix).toBe(true);
|
||||
});
|
||||
|
||||
test("0.27.1 lacks every 0.28-gated capability", () => {
|
||||
const caps = capabilitiesFor(parseServerVersion("0.27.1"));
|
||||
expect(caps.preAuthKeysHaveStableIds).toBe(false);
|
||||
expect(caps.nodeTagsAreFlat).toBe(false);
|
||||
expect(caps.nodeOwnerIsImmutable).toBe(false);
|
||||
expect(caps.registerKeyIncludesAuthReqPrefix).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { normalizeRegistrationKey } from "~/utils/register-key";
|
||||
|
||||
const suffix = "ABCDEFGHIJKLMNOPQRSTUVWX";
|
||||
const key = `hskey-authreq-${suffix}`;
|
||||
const legacyKey = "mkey:0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
describe("normalizeRegistrationKey", () => {
|
||||
test("keeps full registration keys", () => {
|
||||
expect(normalizeRegistrationKey(key)).toBe(key);
|
||||
});
|
||||
|
||||
test("keeps legacy machine keys", () => {
|
||||
expect(normalizeRegistrationKey(legacyKey)).toBe(legacyKey);
|
||||
});
|
||||
|
||||
test("trims surrounding whitespace", () => {
|
||||
expect(normalizeRegistrationKey(` ${key}\n`)).toBe(key);
|
||||
});
|
||||
|
||||
test("extracts keys from registration URLs", () => {
|
||||
expect(normalizeRegistrationKey(`https://headscale.example.com/register/${key}`)).toBe(key);
|
||||
});
|
||||
|
||||
test("extracts legacy machine keys from registration URLs", () => {
|
||||
expect(normalizeRegistrationKey(`https://headscale.example.com/register/${legacyKey}`)).toBe(
|
||||
legacyKey,
|
||||
);
|
||||
});
|
||||
|
||||
test("extracts keys from registration URLs with trailing URL parts", () => {
|
||||
expect(normalizeRegistrationKey(`https://headscale.example.com/register/${key}?foo=bar`)).toBe(
|
||||
key,
|
||||
);
|
||||
});
|
||||
|
||||
test("extracts auth request keys from URLs with trailing punctuation", () => {
|
||||
expect(normalizeRegistrationKey(`https://headscale.example.com/register/${key}.`)).toBe(key);
|
||||
});
|
||||
|
||||
test("accepts suffix-only input as a fallback", () => {
|
||||
expect(normalizeRegistrationKey(suffix)).toBe(key);
|
||||
});
|
||||
|
||||
test("does not require an exact suffix length for full keys", () => {
|
||||
const longerKey = `${key}YZ12`;
|
||||
expect(normalizeRegistrationKey(longerKey)).toBe(longerKey);
|
||||
});
|
||||
|
||||
test("rejects empty or unrelated input", () => {
|
||||
expect(normalizeRegistrationKey(" ")).toBeNull();
|
||||
expect(normalizeRegistrationKey("not-a-registration-key")).toBeNull();
|
||||
expect(normalizeRegistrationKey("hskey-authreq-short")).toBeNull();
|
||||
expect(normalizeRegistrationKey("https://headscale.example.com/register/not-a-key")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user