mirror of
https://github.com/tale/headplane.git
synced 2026-08-27 15:37:04 +00:00
fix: handle registration keys on headscale 0.29+
Closes HP-555.
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
- Added automatic role assignment for new OIDC users via `oidc.default_role` and IdP-provided role claims via `oidc.role_claim` (closes [#352](https://github.com/tale/headplane/issues/352)).
|
- Added automatic role assignment for new OIDC users via `oidc.default_role` and IdP-provided role claims via `oidc.role_claim` (closes [#352](https://github.com/tale/headplane/issues/352)).
|
||||||
- Fixed the DNS page crashing when Headscale has no Split DNS nameservers configured (closes [#570](https://github.com/tale/headplane/issues/570)).
|
- Fixed the DNS page crashing when Headscale has no Split DNS nameservers configured (closes [#570](https://github.com/tale/headplane/issues/570)).
|
||||||
- User lists now show Headscale display names while preserving usernames as secondary text (closes [#571](https://github.com/tale/headplane/issues/571)).
|
- User lists now show Headscale display names while preserving usernames as secondary text (closes [#571](https://github.com/tale/headplane/issues/571)).
|
||||||
|
- Fixed the Register Machine Key dialog so it accepts registration URLs and full `hskey-authreq-...` registration keys (closes [#579](https://github.com/tale/headplane/issues/579)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import Text from "~/components/text";
|
|||||||
import Title from "~/components/title";
|
import Title from "~/components/title";
|
||||||
import { useForm } from "~/hooks/use-form";
|
import { useForm } from "~/hooks/use-form";
|
||||||
import type { User } from "~/types";
|
import type { User } from "~/types";
|
||||||
|
import { normalizeRegistrationKey } from "~/utils/register-key";
|
||||||
import { getUserDisplayName } from "~/utils/user";
|
import { getUserDisplayName } from "~/utils/user";
|
||||||
|
|
||||||
const registerSchema = type({
|
const registerSchema = type({
|
||||||
register_key: "string == 24",
|
register_key: "string > 0",
|
||||||
user: "string > 0",
|
user: "string > 0",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,7 +29,16 @@ export interface NewMachineProps {
|
|||||||
|
|
||||||
export default function NewMachine(data: NewMachineProps) {
|
export default function NewMachine(data: NewMachineProps) {
|
||||||
const [pushDialog, setPushDialog] = useState(false);
|
const [pushDialog, setPushDialog] = useState(false);
|
||||||
const form = useForm({ schema: registerSchema });
|
const form = useForm({
|
||||||
|
schema: registerSchema,
|
||||||
|
validate: (values) =>
|
||||||
|
normalizeRegistrationKey(String(values.register_key ?? ""))
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
register_key:
|
||||||
|
"Paste the registration URL or full hskey-authreq-... key from tailscale up.",
|
||||||
|
},
|
||||||
|
});
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +53,8 @@ export default function NewMachine(data: NewMachineProps) {
|
|||||||
{...form.field("register_key")}
|
{...form.field("register_key")}
|
||||||
required
|
required
|
||||||
label="Machine Key"
|
label="Machine Key"
|
||||||
placeholder="AbCd..."
|
placeholder="hskey-authreq-XXXXXXXXXXXXXXXXXXXXXXXX"
|
||||||
|
description="Paste the registration URL or full key shown by tailscale up."
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/ser
|
|||||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||||
import { nodesResource } from "~/server/headscale/live-store";
|
import { nodesResource } from "~/server/headscale/live-store";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
|
import { normalizeRegistrationKey } from "~/utils/register-key";
|
||||||
|
|
||||||
import type { Route } from "./+types/machine";
|
import type { Route } from "./+types/machine";
|
||||||
|
|
||||||
@@ -31,13 +32,20 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const registrationKey = formData.get("register_key")?.toString();
|
const registrationKeyInput = formData.get("register_key")?.toString();
|
||||||
if (!registrationKey) {
|
if (!registrationKeyInput) {
|
||||||
throw data("Missing `register_key` in the form data.", {
|
throw data("Missing `register_key` in the form data.", {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const registrationKey = normalizeRegistrationKey(registrationKeyInput);
|
||||||
|
if (!registrationKey) {
|
||||||
|
throw data("Invalid `register_key` in the form data.", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const user = formData.get("user")?.toString();
|
const user = formData.get("user")?.toString();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw data("Missing `user` in the form data.", {
|
throw data("Missing `user` in the form data.", {
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ export interface Capabilities {
|
|||||||
* 0.28.0+.
|
* 0.28.0+.
|
||||||
*/
|
*/
|
||||||
readonly nodeOwnerIsImmutable: boolean;
|
readonly nodeOwnerIsImmutable: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `POST /api/v1/node/register` endpoint expects the full
|
||||||
|
* `hskey-authreq-<id>` AuthID as the `key` parameter. Pre-0.29
|
||||||
|
* Headscale expected the raw 24-character registration ID without
|
||||||
|
* the `hskey-authreq-` prefix. Introduced in 0.29.0.
|
||||||
|
*/
|
||||||
|
readonly registerKeyIncludesAuthReqPrefix: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function capabilitiesFor(version: ServerVersion): Capabilities {
|
export function capabilitiesFor(version: ServerVersion): Capabilities {
|
||||||
@@ -43,5 +51,6 @@ export function capabilitiesFor(version: ServerVersion): Capabilities {
|
|||||||
preAuthKeysHaveStableIds: gte(version, "0.28.0"),
|
preAuthKeysHaveStableIds: gte(version, "0.28.0"),
|
||||||
nodeTagsAreFlat: gte(version, "0.28.0"),
|
nodeTagsAreFlat: gte(version, "0.28.0"),
|
||||||
nodeOwnerIsImmutable: gte(version, "0.28.0"),
|
nodeOwnerIsImmutable: gte(version, "0.28.0"),
|
||||||
|
registerKeyIncludesAuthReqPrefix: gte(version, "0.29.0"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,14 +62,19 @@ export function makeNodeApi(
|
|||||||
register: async (user, key) => {
|
register: async (user, key) => {
|
||||||
// Headscale's node-register endpoint expects the registration
|
// Headscale's node-register endpoint expects the registration
|
||||||
// params as both query string and body — preserved as-is.
|
// params as both query string and body — preserved as-is.
|
||||||
|
// Pre-0.29 expects the raw 24-char registration ID; 0.29+ expects
|
||||||
|
// the full `hskey-authreq-<id>` AuthID.
|
||||||
|
const registerKey = capabilities.registerKeyIncludesAuthReqPrefix
|
||||||
|
? key
|
||||||
|
: key.replace(/^hskey-authreq-/, "");
|
||||||
const qp = new URLSearchParams();
|
const qp = new URLSearchParams();
|
||||||
qp.append("user", user);
|
qp.append("user", user);
|
||||||
qp.append("key", key);
|
qp.append("key", registerKey);
|
||||||
const { node } = await transport.request<{ node: RawMachine }>({
|
const { node } = await transport.request<{ node: RawMachine }>({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `v1/node/register?${qp.toString()}`,
|
path: `v1/node/register?${qp.toString()}`,
|
||||||
apiKey,
|
apiKey,
|
||||||
body: { user, key },
|
body: { user, key: registerKey },
|
||||||
});
|
});
|
||||||
return normalize(node);
|
return normalize(node);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const authRequestKeyPattern = /^hskey-authreq-[A-Za-z0-9_-]{16,}$/;
|
||||||
|
const authRequestKeySearchPattern = /hskey-authreq-[A-Za-z0-9_-]{16,}/;
|
||||||
|
const legacyMachineKeyPattern = /^mkey:[A-Za-z0-9_-]{16,}$/;
|
||||||
|
const registerUrlPattern = /\/register\/([^\s?#]+)/;
|
||||||
|
const registrationKeySuffixPattern = /^[A-Za-z0-9_-]{24,}$/;
|
||||||
|
|
||||||
|
export function normalizeRegistrationKey(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerUrlMatch = trimmed.match(registerUrlPattern);
|
||||||
|
if (registerUrlMatch) {
|
||||||
|
const token = normalizeRegistrationToken(registerUrlMatch[1]);
|
||||||
|
if (token) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authRequestKeyMatch = trimmed.match(authRequestKeySearchPattern);
|
||||||
|
if (authRequestKeyMatch) {
|
||||||
|
return authRequestKeyMatch[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeRegistrationToken(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRegistrationToken(value: string): string | null {
|
||||||
|
let token: string;
|
||||||
|
try {
|
||||||
|
token = decodeURIComponent(value.trim());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authRequestKeyPattern.test(token) || legacyMachineKeyPattern.test(token)) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registrationKeySuffixPattern.test(token)) {
|
||||||
|
return `hskey-authreq-${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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";
|
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) => {
|
describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
|
||||||
let workingNodeId: string;
|
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 client = await getRuntimeClient(version);
|
||||||
const tailnetNode = await getNode(version);
|
const tailnetNode = await getNode(version);
|
||||||
|
const { auth, context, liveStore } = actionContext(client);
|
||||||
|
|
||||||
const user = await client.users.create({ name: "node-reg@" });
|
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).toBeDefined();
|
||||||
expect(node.registerMethod).toBe("REGISTER_METHOD_CLI");
|
expect(node?.registerMethod).toBe("REGISTER_METHOD_CLI");
|
||||||
expect(node.name).toBe(tailnetNode.nodeName);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("nodes can be retrieved", async () => {
|
test("nodes can be retrieved", async () => {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export async function getNode(version: Version) {
|
|||||||
const { tailscaleNode } = await ensureVersion(version);
|
const { tailscaleNode } = await ensureVersion(version);
|
||||||
return {
|
return {
|
||||||
authCode: tailscaleNode.authCode,
|
authCode: tailscaleNode.authCode,
|
||||||
|
registerUrl: tailscaleNode.registerUrl,
|
||||||
nodeName: tailscaleNode.nodeName,
|
nodeName: tailscaleNode.nodeName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type Version = string;
|
|||||||
export interface TailscaleNodeEnv {
|
export interface TailscaleNodeEnv {
|
||||||
container: tc.StartedTestContainer;
|
container: tc.StartedTestContainer;
|
||||||
authCode: string;
|
authCode: string;
|
||||||
|
registerUrl: string;
|
||||||
nodeName: string;
|
nodeName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export async function startTailscaleNode(
|
|||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
authCodeResolved = true;
|
authCodeResolved = true;
|
||||||
resolveAuthCode(token);
|
resolveAuthCode(`${prefix}${token}`);
|
||||||
rl.close();
|
rl.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ export async function startTailscaleNode(
|
|||||||
.withWaitStrategy(tc.Wait.forLogMessage(prefix).withStartupTimeout(30_000))
|
.withWaitStrategy(tc.Wait.forLogMessage(prefix).withStartupTimeout(30_000))
|
||||||
.start();
|
.start();
|
||||||
|
|
||||||
const authCode = await Promise.race<string>([
|
const registerUrl = await Promise.race<string>([
|
||||||
authCodePromise,
|
authCodePromise,
|
||||||
new Promise((_, reject) =>
|
new Promise((_, reject) =>
|
||||||
setTimeout(
|
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,
|
preAuthKeysHaveStableIds: true,
|
||||||
nodeTagsAreFlat: true,
|
nodeTagsAreFlat: true,
|
||||||
nodeOwnerIsImmutable: 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", () => {
|
test("0.27.1 lacks every 0.28-gated capability", () => {
|
||||||
const caps = capabilitiesFor(parseServerVersion("0.27.1"));
|
const caps = capabilitiesFor(parseServerVersion("0.27.1"));
|
||||||
expect(caps.preAuthKeysHaveStableIds).toBe(false);
|
expect(caps.preAuthKeysHaveStableIds).toBe(false);
|
||||||
expect(caps.nodeTagsAreFlat).toBe(false);
|
expect(caps.nodeTagsAreFlat).toBe(false);
|
||||||
expect(caps.nodeOwnerIsImmutable).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