From 0512565f8eb974981621d8b55e3fa0327d94c643 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 23 May 2026 19:59:03 -0400 Subject: [PATCH] feat: replace openapi hashing system with /version Apparently I didn't use my brain cells and rely on the /version endpoint that Headscale has exposed since 0.26 (our lowest supported version). Switching to that significantly simplifies the API surface. --- app/layout/app.tsx | 2 +- app/openapi-canonical-families.json | 6 - app/openapi-operation-hashes.json | 135 ------ app/routes/acls/acl-action.ts | 4 +- app/routes/acls/acl-loader.ts | 2 +- app/routes/auth/login/action.ts | 4 +- app/routes/auth/oidc-callback.ts | 4 +- app/routes/dns/dns-actions.ts | 2 +- app/routes/machines/machine-actions.ts | 21 +- app/routes/machines/machine.tsx | 2 +- app/routes/machines/overview.tsx | 2 +- app/routes/settings/auth-keys/actions.ts | 20 +- app/routes/settings/auth-keys/overview.tsx | 12 +- app/routes/settings/restrictions/actions.ts | 2 +- app/routes/ssh/page.tsx | 18 +- app/routes/users/user-actions.ts | 14 +- app/routes/util/healthz.ts | 2 +- app/routes/util/info.ts | 4 +- app/server/README.md | 2 +- app/server/config/integration/abstract.ts | 4 +- app/server/config/integration/docker.ts | 4 +- app/server/config/integration/kubernetes.ts | 4 +- app/server/config/integration/proc-helper.ts | 6 +- app/server/config/integration/proc.ts | 4 +- app/server/context.ts | 29 +- app/server/headscale/api/capabilities.ts | 55 +++ .../headscale/api/endpoints/api-keys.ts | 20 - app/server/headscale/api/endpoints/index.ts | 75 ---- app/server/headscale/api/endpoints/nodes.ts | 171 -------- app/server/headscale/api/endpoints/policy.ts | 41 -- .../headscale/api/endpoints/pre-auth-keys.ts | 93 ----- app/server/headscale/api/endpoints/users.ts | 87 ---- app/server/headscale/api/factory.ts | 38 -- app/server/headscale/api/hasher.ts | 51 --- app/server/headscale/api/index.ts | 386 ++++-------------- .../headscale/api/resources/api-keys.ts | 25 ++ app/server/headscale/api/resources/nodes.ts | 116 ++++++ app/server/headscale/api/resources/policy.ts | 33 ++ .../headscale/api/resources/pre-auth-keys.ts | 88 ++++ app/server/headscale/api/resources/users.ts | 66 +++ app/server/headscale/api/server-version.ts | 98 +++++ app/server/headscale/api/transport.ts | 191 +++++++++ app/server/headscale/api/version.ts | 86 ---- app/server/headscale/live-store.ts | 20 +- app/server/hp-agent.ts | 20 +- docs/development/architecture.md | 4 +- package.json | 3 - pnpm-lock.yaml | 127 +----- tests/generate-openapi-hashes.ts | 101 ----- tests/integration/api/api-keys.test.ts | 2 +- tests/integration/api/nodes.test.ts | 32 +- tests/integration/api/pre-auth-keys.test.ts | 62 ++- tests/integration/api/users.test.ts | 32 +- tests/integration/api/versions.test.ts | 37 +- tests/integration/setup/env.ts | 25 +- tests/integration/setup/start-tailscale.ts | 6 +- tests/unit/auth/login-action.test.ts | 20 +- tests/unit/headscale/api-paths.test.ts | 66 ++- tests/unit/headscale/server-version.test.ts | 110 +++++ 59 files changed, 1123 insertions(+), 1573 deletions(-) delete mode 100644 app/openapi-canonical-families.json delete mode 100644 app/openapi-operation-hashes.json create mode 100644 app/server/headscale/api/capabilities.ts delete mode 100644 app/server/headscale/api/endpoints/api-keys.ts delete mode 100644 app/server/headscale/api/endpoints/index.ts delete mode 100644 app/server/headscale/api/endpoints/nodes.ts delete mode 100644 app/server/headscale/api/endpoints/policy.ts delete mode 100644 app/server/headscale/api/endpoints/pre-auth-keys.ts delete mode 100644 app/server/headscale/api/endpoints/users.ts delete mode 100644 app/server/headscale/api/factory.ts delete mode 100644 app/server/headscale/api/hasher.ts create mode 100644 app/server/headscale/api/resources/api-keys.ts create mode 100644 app/server/headscale/api/resources/nodes.ts create mode 100644 app/server/headscale/api/resources/policy.ts create mode 100644 app/server/headscale/api/resources/pre-auth-keys.ts create mode 100644 app/server/headscale/api/resources/users.ts create mode 100644 app/server/headscale/api/server-version.ts create mode 100644 app/server/headscale/api/transport.ts delete mode 100644 app/server/headscale/api/version.ts delete mode 100644 tests/generate-openapi-hashes.ts create mode 100644 tests/unit/headscale/server-version.test.ts diff --git a/app/layout/app.tsx b/app/layout/app.tsx index 632f41b..56fef0f 100644 --- a/app/layout/app.tsx +++ b/app/layout/app.tsx @@ -48,7 +48,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { const isHealthy = await api.isHealthy(); if (isHealthy) { try { - await api.getApiKeys(); + await api.apiKeys.list(); } catch (error) { if (isDataUnauthorizedError(error)) { const displayName = diff --git a/app/openapi-canonical-families.json b/app/openapi-canonical-families.json deleted file mode 100644 index 6fbad5a..0000000 --- a/app/openapi-canonical-families.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "0.26.1": ["0.26.0", "0.26.1"], - "0.27.0": ["0.27.0"], - "0.27.1": ["0.27.1"], - "0.28.0": ["0.28.0"] -} diff --git a/app/openapi-operation-hashes.json b/app/openapi-operation-hashes.json deleted file mode 100644 index b8eba8b..0000000 --- a/app/openapi-operation-hashes.json +++ /dev/null @@ -1,135 +0,0 @@ -{ - "0.26.0": { - "GET /api/v1/apikey": "efe31b6dc980e158", - "POST /api/v1/apikey": "39953a96c1da5312", - "POST /api/v1/apikey/expire": "ca56add866802f17", - "DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1", - "POST /api/v1/debug/node": "204f9ae3f9f738c6", - "GET /api/v1/node": "8bb18b8c7cfb4f20", - "POST /api/v1/node/backfillips": "6da4d1d3922a8001", - "POST /api/v1/node/register": "539f7cb3a84d43d4", - "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", - "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", - "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", - "POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784", - "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", - "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", - "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922", - "GET /api/v1/policy": "d6c639be304cd3c0", - "PUT /api/v1/policy": "6cbe80bde771a388", - "GET /api/v1/preauthkey": "14db6a04f90d7a7e", - "POST /api/v1/preauthkey": "0b4308e049d4eb58", - "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", - "GET /api/v1/user": "228831b58ccc5a17", - "POST /api/v1/user": "a4e1d889d7962da5", - "DELETE /api/v1/user/{id}": "3d553e4b74296884", - "POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7" - }, - "0.26.1": { - "GET /api/v1/apikey": "efe31b6dc980e158", - "POST /api/v1/apikey": "39953a96c1da5312", - "POST /api/v1/apikey/expire": "ca56add866802f17", - "DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1", - "POST /api/v1/debug/node": "204f9ae3f9f738c6", - "GET /api/v1/node": "8bb18b8c7cfb4f20", - "POST /api/v1/node/backfillips": "6da4d1d3922a8001", - "POST /api/v1/node/register": "539f7cb3a84d43d4", - "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", - "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", - "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", - "POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784", - "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", - "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", - "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922", - "GET /api/v1/policy": "d6c639be304cd3c0", - "PUT /api/v1/policy": "6cbe80bde771a388", - "GET /api/v1/preauthkey": "14db6a04f90d7a7e", - "POST /api/v1/preauthkey": "0b4308e049d4eb58", - "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", - "GET /api/v1/user": "228831b58ccc5a17", - "POST /api/v1/user": "a4e1d889d7962da5", - "DELETE /api/v1/user/{id}": "3d553e4b74296884", - "POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7" - }, - "0.27.0": { - "GET /api/v1/apikey": "efe31b6dc980e158", - "POST /api/v1/apikey": "39953a96c1da5312", - "POST /api/v1/apikey/expire": "ca56add866802f17", - "DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1", - "POST /api/v1/debug/node": "204f9ae3f9f738c6", - "GET /api/v1/health": "5e447272e72b2e5f", - "GET /api/v1/node": "8bb18b8c7cfb4f20", - "POST /api/v1/node/backfillips": "6da4d1d3922a8001", - "POST /api/v1/node/register": "539f7cb3a84d43d4", - "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", - "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", - "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", - "POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784", - "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", - "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", - "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922", - "GET /api/v1/policy": "d6c639be304cd3c0", - "PUT /api/v1/policy": "6cbe80bde771a388", - "GET /api/v1/preauthkey": "14db6a04f90d7a7e", - "POST /api/v1/preauthkey": "0b4308e049d4eb58", - "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", - "GET /api/v1/user": "228831b58ccc5a17", - "POST /api/v1/user": "a4e1d889d7962da5", - "DELETE /api/v1/user/{id}": "3d553e4b74296884", - "POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7" - }, - "0.27.1": { - "GET /api/v1/apikey": "efe31b6dc980e158", - "POST /api/v1/apikey": "39953a96c1da5312", - "POST /api/v1/apikey/expire": "ca56add866802f17", - "DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1", - "POST /api/v1/debug/node": "204f9ae3f9f738c6", - "GET /api/v1/health": "5e447272e72b2e5f", - "GET /api/v1/node": "8bb18b8c7cfb4f20", - "POST /api/v1/node/backfillips": "6da4d1d3922a8001", - "POST /api/v1/node/register": "539f7cb3a84d43d4", - "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", - "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", - "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", - "POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae", - "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", - "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", - "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922", - "GET /api/v1/policy": "d6c639be304cd3c0", - "PUT /api/v1/policy": "6cbe80bde771a388", - "GET /api/v1/preauthkey": "14db6a04f90d7a7e", - "POST /api/v1/preauthkey": "0b4308e049d4eb58", - "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", - "GET /api/v1/user": "228831b58ccc5a17", - "POST /api/v1/user": "a4e1d889d7962da5", - "DELETE /api/v1/user/{id}": "3d553e4b74296884", - "POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7" - }, - "0.28.0": { - "GET /api/v1/apikey": "efe31b6dc980e158", - "POST /api/v1/apikey": "39953a96c1da5312", - "POST /api/v1/apikey/expire": "ca56add866802f17", - "DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2", - "POST /api/v1/debug/node": "204f9ae3f9f738c6", - "GET /api/v1/health": "5e447272e72b2e5f", - "GET /api/v1/node": "8bb18b8c7cfb4f20", - "POST /api/v1/node/backfillips": "6da4d1d3922a8001", - "POST /api/v1/node/register": "539f7cb3a84d43d4", - "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", - "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", - "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", - "POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae", - "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", - "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", - "GET /api/v1/policy": "d6c639be304cd3c0", - "PUT /api/v1/policy": "6cbe80bde771a388", - "GET /api/v1/preauthkey": "8428b44e3a821e9e", - "DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09", - "POST /api/v1/preauthkey": "0b4308e049d4eb58", - "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", - "GET /api/v1/user": "228831b58ccc5a17", - "POST /api/v1/user": "a4e1d889d7962da5", - "DELETE /api/v1/user/{id}": "3d553e4b74296884", - "POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7" - } -} diff --git a/app/routes/acls/acl-action.ts b/app/routes/acls/acl-action.ts index a7e7288..f113f50 100644 --- a/app/routes/acls/acl-action.ts +++ b/app/routes/acls/acl-action.ts @@ -28,7 +28,7 @@ export async function aclAction({ request, context }: Route.ActionArgs) { const { api } = await context.apiForRequest(request); try { - const { policy, updatedAt } = await api.setPolicy(policyData); + const { policy, updatedAt } = await api.policy.set(policyData); return data({ success: true, error: undefined, @@ -56,7 +56,7 @@ export async function aclAction({ request, context }: Route.ActionArgs) { // Starting in Headscale 0.27.0 the ACLs parsing was changed meaning // we need to reference other error messages based on API version. - if (context.hsApi.clientHelpers.isAtleast("0.27.0")) { + if (context.headscale.capabilities.policyErrorsUseModernFormat) { if (message.includes("parsing HuJSON:")) { const cutIndex = message.indexOf("parsing HuJSON:"); const trimmed = diff --git a/app/routes/acls/acl-loader.ts b/app/routes/acls/acl-loader.ts index 6b4382c..12eb9d7 100644 --- a/app/routes/acls/acl-loader.ts +++ b/app/routes/acls/acl-loader.ts @@ -31,7 +31,7 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) { // Try to load the ACL policy from the API. const { api } = await context.apiForRequest(request); try { - const { policy, updatedAt } = await api.getPolicy(); + const { policy, updatedAt } = await api.policy.get(); flags.writable = updatedAt !== null; flags.policy = policy; return flags; diff --git a/app/routes/auth/login/action.ts b/app/routes/auth/login/action.ts index 9455f7b..af5a008 100644 --- a/app/routes/auth/login/action.ts +++ b/app/routes/auth/login/action.ts @@ -33,9 +33,9 @@ export async function loginAction({ request, context }: Route.LoaderArgs) { }; } - const api = context.hsApi.getRuntimeClient(apiKey); + const api = context.headscale.client(apiKey); try { - const apiKeys = await api.getApiKeys(); + const apiKeys = await api.apiKeys.list(); // We don't need to check for 0 API keys because this request cannot // be authenticated correctly without an API key diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index c38d8b2..1c2e986 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -56,8 +56,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { }); try { - const hsApi = context.hsApi.getRuntimeClient(context.headscaleApiKey!); - const hsUsers = await hsApi.getUsers(); + const hsApi = context.headscale.client(context.headscaleApiKey!); + const hsUsers = await hsApi.users.list(); const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email); if (hsUser) { await context.auth.linkHeadscaleUser(userId, hsUser.id); diff --git a/app/routes/dns/dns-actions.ts b/app/routes/dns/dns-actions.ts index a65571c..089959e 100644 --- a/app/routes/dns/dns-actions.ts +++ b/app/routes/dns/dns-actions.ts @@ -17,7 +17,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { } // We only need it for health checks which don't require auth - const api = context.hsApi.getRuntimeClient("fake-api-key"); + const api = context.headscale.client("fake-api-key"); const formData = await request.formData(); const action = formData.get("action_id")?.toString(); diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts index c32fc49..f90deb2 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -40,7 +40,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { }); } - const node = await api.registerNode(user, registrationKey); + const node = await api.nodes.register(user, registrationKey); await context.hsLive.refresh(nodesResource, api); return redirect(`/machines/${node.id}`); } @@ -53,7 +53,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { }); } - const node = await api.getNode(nodeId); + const node = await api.nodes.get(nodeId); if (!node) { throw data(`Machine with ID ${nodeId} not found`, { status: 404, @@ -76,19 +76,19 @@ export async function machineAction({ request, context }: Route.ActionArgs) { } const name = String(formData.get("name")); - await api.renameNode(nodeId, name); + await api.nodes.rename(nodeId, name); await context.hsLive.refresh(nodesResource, api); return { message: "Machine renamed" }; } case "delete": { - await api.deleteNode(nodeId); + await api.nodes.delete(nodeId); await context.hsLive.refresh(nodesResource, api); return redirect("/machines"); } case "expire": { - await api.expireNode(nodeId); + await api.nodes.expire(nodeId); await context.hsLive.refresh(nodesResource, api); return { message: "Machine expired" }; } @@ -102,7 +102,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { } try { - await api.setNodeTags( + await api.nodes.setTags( nodeId, tags.map((tag) => tag.trim()).filter((tag) => tag !== ""), ); @@ -171,7 +171,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { } } - await api.approveNodeRoutes(nodeId, newApproved); + await api.nodes.approveRoutes(nodeId, newApproved); await context.hsLive.refresh(nodesResource, api); return { message: "Routes updated" }; } @@ -184,7 +184,12 @@ export async function machineAction({ request, context }: Route.ActionArgs) { }); } - await api.setNodeUser(nodeId, user); + if (!api.nodes.reassignUser) { + throw data("Reassigning a node owner is no longer supported on this Headscale version.", { + status: 400, + }); + } + await api.nodes.reassignUser(nodeId, user); await context.hsLive.refresh(nodesResource, api); return { message: "Machine reassigned" }; } diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 6c80ce8..fb169a0 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -53,7 +53,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { const lookup = await agents?.lookup([node.nodeKey]); const [enhancedNode] = mapNodes([node], lookup); const tags = [...node.tags].toSorted(); - const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0"); + const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable; const agentSync = agents?.lastSync(); return { diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index e2797a0..72f5b96 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -48,7 +48,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { const agents = context.agents.state === "enabled" ? context.agents.value : undefined; const stats = await agents?.lookup(nodes.map((node) => node.nodeKey)); const populatedNodes = mapNodes(nodes, stats); - const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0"); + const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable; const agentSync = agents?.lastSync(); return { diff --git a/app/routes/settings/auth-keys/actions.ts b/app/routes/settings/auth-keys/actions.ts index 918386f..85f5d8a 100644 --- a/app/routes/settings/auth-keys/actions.ts +++ b/app/routes/settings/auth-keys/actions.ts @@ -20,7 +20,7 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) { async function checkSelfServiceOwnership(userId: string) { if (canGenerateAny || !canGenerateOwn) return; - const [targetUser] = await api.getUsers(userId); + const [targetUser] = await api.users.list({ id: userId }); if (!targetUser) { throw data("User not found.", { status: 404 }); } @@ -84,13 +84,13 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) { const date = new Date(); date.setDate(date.getDate() + day); - const key = await api.createPreAuthKey( + const key = await api.preAuthKeys.create({ user, - ephemeral === "on", - reusable === "on", - date, - aclTags.length > 0 ? aclTags : null, - ); + ephemeral: ephemeral === "on", + reusable: reusable === "on", + expiration: date, + aclTags: aclTags.length > 0 ? aclTags : null, + }); return data({ success: true as const, key: key.key }); } @@ -112,7 +112,11 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) { } await checkSelfServiceOwnership(user); - await api.expirePreAuthKey(user, { id: keyId, key } as unknown as PreAuthKey); + await api.preAuthKeys.expire({ + id: keyId, + key, + user: { name: user }, + } as unknown as PreAuthKey); return data("Pre-auth key expired"); } diff --git a/app/routes/settings/auth-keys/overview.tsx b/app/routes/settings/auth-keys/overview.tsx index bc2ac23..5186618 100644 --- a/app/routes/settings/auth-keys/overview.tsx +++ b/app/routes/settings/auth-keys/overview.tsx @@ -29,10 +29,12 @@ export async function loader({ request, context }: Route.LoaderArgs) { // Try fetching all keys at once (Headscale 0.28+), fall back to per-user let allKeys: PreAuthKey[] | null = null; - try { - allKeys = await api.getAllPreAuthKeys(); - } catch { - // Older versions don't support this endpoint + if (api.preAuthKeys.listAll) { + try { + allKeys = await api.preAuthKeys.listAll(); + } catch { + // Treat any failure as "no global list available" and fall through. + } } if (allKeys !== null) { @@ -65,7 +67,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { .filter((u) => u.id?.length > 0) .map(async (user) => { try { - const preAuthKeys = await api.getPreAuthKeys(user.id); + const preAuthKeys = await api.preAuthKeys.listForUser(user.id); return { preAuthKeys, success: true as const, user }; } catch (error) { log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error); diff --git a/app/routes/settings/restrictions/actions.ts b/app/routes/settings/restrictions/actions.ts index c953d80..9ab4db6 100644 --- a/app/routes/settings/restrictions/actions.ts +++ b/app/routes/settings/restrictions/actions.ts @@ -29,7 +29,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs) } // We only need healthchecks which don't rely on an API key - const api = context.hsApi.getRuntimeClient("fake-api-key"); + const api = context.headscale.client("fake-api-key"); switch (action) { case "add_domain": { const domain = formData.get("domain")?.toString()?.trim(); diff --git a/app/routes/ssh/page.tsx b/app/routes/ssh/page.tsx index c0e051e..f6aac78 100644 --- a/app/routes/ssh/page.tsx +++ b/app/routes/ssh/page.tsx @@ -49,7 +49,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { const hostname = params.id; const username = new URL(request.url).searchParams.get("user") || undefined; - const nodes = await api.getNodes(); + const nodes = await api.nodes.list(); const node = nodes.find((n) => n.givenName === hostname); if (!node) { throw data(sshErrors.node_not_found(hostname), 404); @@ -64,20 +64,20 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { } // The user must exist within Headscale to generate a pre-auth key - const users = await api.getUsers(); + const users = await api.users.list(); const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email); if (!hsUser) { throw data(sshErrors.user_not_linked, 404); } - const preAuthKey = await api.createPreAuthKey( - hsUser.id, - true, - false, - new Date(Date.now() + 60 * 1000), // 1 minute expiry - null, - ); + const preAuthKey = await api.preAuthKeys.create({ + user: hsUser.id, + ephemeral: true, + reusable: false, + expiration: new Date(Date.now() + 60 * 1000), // 1 minute expiry + aclTags: null, + }); const controlURL = context.config.headscale.public_url ?? context.config.headscale.url; return { diff --git a/app/routes/users/user-actions.ts b/app/routes/users/user-actions.ts index 23a3aaa..5e3153b 100644 --- a/app/routes/users/user-actions.ts +++ b/app/routes/users/user-actions.ts @@ -37,7 +37,7 @@ export async function userAction({ request, context }: Route.ActionArgs) { }); } - await api.createUser(name, email, displayName); + await api.users.create({ name, email, displayName }); await context.hsLive.refresh(usersResource, api); return { message: "User created successfully" }; } @@ -49,7 +49,7 @@ export async function userAction({ request, context }: Route.ActionArgs) { }); } - await api.deleteUser(userId); + await api.users.delete(userId); await context.hsLive.refresh(usersResource, api); return { message: "User deleted successfully" }; } @@ -60,7 +60,7 @@ export async function userAction({ request, context }: Route.ActionArgs) { return data({ success: false }, 400); } - const users = await api.getUsers(userId); + const users = await api.users.list({ id: userId }); const user = users.find((user) => user.id === userId); if (!user) { throw data(`No user found with id: ${userId}`, { status: 400 }); @@ -73,7 +73,7 @@ export async function userAction({ request, context }: Route.ActionArgs) { }); } - await api.renameUser(userId, newName); + await api.users.rename(userId, newName); await context.hsLive.refresh(usersResource, api); return { message: "User renamed successfully" }; } @@ -86,7 +86,7 @@ export async function userAction({ request, context }: Route.ActionArgs) { }); } - const users = await api.getUsers(userId); + const users = await api.users.list({ id: userId }); const user = users.find((user) => user.id === userId); if (!user) { throw data("Specified user not found", { @@ -117,7 +117,7 @@ export async function userAction({ request, context }: Route.ActionArgs) { throw data("Missing `user_id` in the form data.", { status: 400 }); } - const users = await api.getUsers(userId); + const users = await api.users.list({ id: userId }); const user = users.find((user) => user.id === userId); if (!user) { throw data("Specified user not found", { status: 400 }); @@ -144,7 +144,7 @@ export async function userAction({ request, context }: Route.ActionArgs) { }); } - const users = await api.getUsers(userId); + const users = await api.users.list({ id: userId }); const user = users.find((user) => user.id === userId); if (!user) { throw data("Specified user not found", { status: 400 }); diff --git a/app/routes/util/healthz.ts b/app/routes/util/healthz.ts index 6cc6596..017820e 100644 --- a/app/routes/util/healthz.ts +++ b/app/routes/util/healthz.ts @@ -2,7 +2,7 @@ import type { Route } from "./+types/healthz"; export async function loader({ context }: Route.LoaderArgs) { // Use a fake API key for healthcheck - const api = context.hsApi.getRuntimeClient("fake-api-key"); + const api = context.headscale.client("fake-api-key"); const healthy = await api.isHealthy(); return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), { diff --git a/app/routes/util/info.ts b/app/routes/util/info.ts index 19e2f17..5af2038 100644 --- a/app/routes/util/info.ts +++ b/app/routes/util/info.ts @@ -35,13 +35,13 @@ export async function loader({ request, context }: Route.LoaderArgs) { } // Use a fake API key for healthcheck - const api = context.hsApi.getRuntimeClient("fake-api-key"); + const api = context.headscale.client("fake-api-key"); const healthy = await api.isHealthy(); const body = { status: healthy ? "healthy" : "unhealthy", headplane_version: __VERSION__, - headscale_canonical_version: healthy ? context.hsApi.apiVersion : "unknown", + headscale_canonical_version: healthy ? context.headscale.version.raw : "unknown", internal_versions: { node: versions.node, v8: versions.v8, diff --git a/app/server/README.md b/app/server/README.md index acafce1..ecc1f83 100644 --- a/app/server/README.md +++ b/app/server/README.md @@ -63,7 +63,7 @@ constructs everything that needs to live for the lifetime of the process: - the SQLite client (`db`) -- the Headscale REST interface (`hsApi`) +- the Headscale REST interface (`headscale`) - the optional Headplane agent manager (`agents`) - the auth service (`auth`) - the optional OIDC service (`oidc`) diff --git a/app/server/config/integration/abstract.ts b/app/server/config/integration/abstract.ts index 7079cfe..be6cc26 100644 --- a/app/server/config/integration/abstract.ts +++ b/app/server/config/integration/abstract.ts @@ -1,4 +1,4 @@ -import type { RuntimeApiClient } from "~/server/headscale/api/endpoints"; +import type { HeadscaleClient } from "~/server/headscale/api"; export abstract class Integration { protected context: NonNullable; @@ -11,6 +11,6 @@ export abstract class Integration { } abstract isAvailable(): Promise | boolean; - abstract onConfigChange(client: RuntimeApiClient): Promise | void; + abstract onConfigChange(client: HeadscaleClient): Promise | void; abstract get name(): string; } diff --git a/app/server/config/integration/docker.ts b/app/server/config/integration/docker.ts index 245c6a5..d176e39 100644 --- a/app/server/config/integration/docker.ts +++ b/app/server/config/integration/docker.ts @@ -4,7 +4,7 @@ import { setTimeout } from "node:timers/promises"; import { type } from "arktype"; import { Client } from "undici"; -import type { RuntimeApiClient } from "~/server/headscale/api/endpoints"; +import type { HeadscaleClient } from "~/server/headscale/api"; import log from "~/utils/log"; import { Integration } from "./abstract"; @@ -255,7 +255,7 @@ export default class DockerIntegration extends Integration { const { pid, signal = "SIGHUP", maxAttempts = 10, retryDelayMs = 1000 } = options; diff --git a/app/server/config/integration/proc.ts b/app/server/config/integration/proc.ts index e6a7466..506fd03 100644 --- a/app/server/config/integration/proc.ts +++ b/app/server/config/integration/proc.ts @@ -2,7 +2,7 @@ import { platform } from "node:os"; import { type } from "arktype"; -import type { RuntimeApiClient } from "~/server/headscale/api/endpoints"; +import type { HeadscaleClient } from "~/server/headscale/api"; import log from "~/utils/log"; import { Integration } from "./abstract"; @@ -51,7 +51,7 @@ export default class ProcIntegration extends Integration Promise | void> = [() => auth.stop(), () => hsLive.dispose()]; + const disposers: Array<() => Promise | void> = [ + () => auth.stop(), + () => hsLive.dispose(), + () => headscale.dispose(), + ]; if (agents.state === "enabled") { disposers.push(() => agents.value.dispose()); } async function apiForRequest( request: Request, - ): Promise<{ principal: Principal; api: RuntimeApiClient }> { + ): Promise<{ principal: Principal; api: HeadscaleClient }> { const principal = await auth.require(request); const apiKey = auth.getHeadscaleApiKey(principal); - return { principal, api: hsApi.getRuntimeClient(apiKey) }; + return { principal, api: headscale.client(apiKey) }; } function startServices() { @@ -91,7 +94,7 @@ export async function createAppContext(config: HeadplaneConfig) { return { config, db, - hsApi, + headscale, headscaleApiKey, agents, auth, @@ -147,7 +150,7 @@ function buildOidc( async function buildAgents( config: HeadplaneConfig, supportsTagOnlyKeys: boolean, - apiClient: RuntimeApiClient | undefined, + apiClient: HeadscaleClient | undefined, db: Awaited>, ): Promise> { const agentConfig = config.integration?.agent; diff --git a/app/server/headscale/api/capabilities.ts b/app/server/headscale/api/capabilities.ts new file mode 100644 index 0000000..df0de26 --- /dev/null +++ b/app/server/headscale/api/capabilities.ts @@ -0,0 +1,55 @@ +// MARK: Headscale Capabilities +// +// Behavioural facts about the connected Headscale server, derived +// once from `ServerVersion` at boot. Capabilities are named for *what +// changed* — not for the version number it changed in — so a reader +// who has never seen Headscale can tell what each flag controls +// without consulting a release note. +// +// Add a capability here when you find yourself reaching for a raw +// version comparison in endpoint or route code. Adding a capability +// is also the right answer when a new Headscale release changes wire +// format or removes an endpoint. + +import { gte, type ServerVersion } from "./server-version"; + +export interface Capabilities { + /** + * Pre-auth keys have stable IDs. `GET /api/v1/preauthkey` (no + * user filter) returns every key in the system, and + * `POST /api/v1/preauthkey/expire` takes `{ id }` instead of + * `{ user, key }`. Tag-only pre-auth keys (no owning user) are + * supported. Introduced in 0.28.0. + */ + readonly preAuthKeysHaveStableIds: boolean; + + /** + * Node tags are a flat `tags: string[]` field on the wire. + * Pre-0.28 returned `forcedTags` / `validTags` / `invalidTags` + * that the client had to union itself. Introduced in 0.28.0. + */ + readonly nodeTagsAreFlat: boolean; + + /** + * A node's owning user is immutable after creation; + * `POST /api/v1/node/{id}/user` no longer reassigns. Effective in + * 0.28.0+. + */ + readonly nodeOwnerIsImmutable: boolean; + + /** + * Policy parse errors use the modern `parsing HuJSON:` / + * `parsing policy from bytes:` prefixes. Pre-0.27 emitted + * `parsing hujson` / `unmarshalling policy`. Introduced in 0.27.0. + */ + readonly policyErrorsUseModernFormat: boolean; +} + +export function capabilitiesFor(version: ServerVersion): Capabilities { + return { + preAuthKeysHaveStableIds: gte(version, "0.28.0"), + nodeTagsAreFlat: gte(version, "0.28.0"), + nodeOwnerIsImmutable: gte(version, "0.28.0"), + policyErrorsUseModernFormat: gte(version, "0.27.0"), + }; +} diff --git a/app/server/headscale/api/endpoints/api-keys.ts b/app/server/headscale/api/endpoints/api-keys.ts deleted file mode 100644 index bad1d91..0000000 --- a/app/server/headscale/api/endpoints/api-keys.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Key } from "~/types"; - -import { defineApiEndpoints } from "../factory"; - -export interface ApiKeyEndpoints { - /** - * Retrieves all API keys from the Headscale instance. - * - * @returns An array of `Key` objects representing the API keys. - */ - getApiKeys(): Promise; -} - -export default defineApiEndpoints((client, apiKey) => ({ - getApiKeys: async () => { - const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>("GET", "v1/apikey", apiKey); - - return apiKeys; - }, -})); diff --git a/app/server/headscale/api/endpoints/index.ts b/app/server/headscale/api/endpoints/index.ts deleted file mode 100644 index 72b18f5..0000000 --- a/app/server/headscale/api/endpoints/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - composeEndpoints, - defineApiEndpoints, - type ExtractApiEndpoints, - type UnionToIntersection, -} from "../factory"; -import type { HeadscaleApiInterface } from "../index"; -import apiKeyEndpoints from "./api-keys"; -import nodeEndpoints from "./nodes"; -import policyEndpoints from "./policy"; -import preAuthKeyEndpoints from "./pre-auth-keys"; -import userEndpoints from "./users"; - -interface HealthcheckEndpoint { - /** - * Checks if the Headscale instance is healthy. - * - * @returns A boolean indicating if the instance is healthy. - */ - isHealthy(): Promise; -} - -const healthcheckEndpoint = defineApiEndpoints((client, apiKey) => ({ - isHealthy: async () => { - try { - const res = await client.rawFetch("/health", { - method: "GET", - headers: { - // This doesn't really matter - Authorization: `Bearer ${apiKey}`, - }, - }); - - return res.statusCode === 200; - } catch { - return false; - } - }, -})); - -/** - * A constant list of all endpoint groups. - * Add new endpoint groups here. - */ -export const endpointSets = [ - apiKeyEndpoints, - healthcheckEndpoint, - nodeEndpoints, - policyEndpoints, - preAuthKeyEndpoints, - userEndpoints, -] as const; - -/** - * All of the available API methods when interacting with Headscale's API. - * We have wrapped each operation with nice methods and parameters to make it - * easier to do integration testing by spinning up an actual Headscale instance - * and calling these methods against it. - * - * We also have the benefit of supporting multiple Headscale versions by - * passing in different internal implementations based on the OpenAPI spec. - */ -export type RuntimeApiClient = UnionToIntersection< - ExtractApiEndpoints<(typeof endpointSets)[number]> ->; - -/** - * Composes all endpoint groups into a single runtime API client. - * - * @param client - The client helpers for making API requests. - * @param apiKey - The API key for authentication. - * @returns A fully composed runtime API client. - */ -export default (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) => - composeEndpoints(endpointSets, client, apiKey); diff --git a/app/server/headscale/api/endpoints/nodes.ts b/app/server/headscale/api/endpoints/nodes.ts deleted file mode 100644 index e26d6ac..0000000 --- a/app/server/headscale/api/endpoints/nodes.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { Machine } from "~/types"; - -import type { HeadscaleApiInterface } from ".."; -import { defineApiEndpoints } from "../factory"; - -interface RawMachine extends Omit { - tags?: string[]; - forcedTags?: string[]; - validTags?: string[]; - invalidTags?: string[]; -} - -/** - * Normalizes the tags of a RawMachine based on the Headscale version. - * - * @param client The Headscale API client helper. - * @param node The RawMachine object to normalize. - * @returns A Machine object with normalized tags. - */ -function normalizeTags(client: HeadscaleApiInterface["clientHelpers"], node: RawMachine): Machine { - if (client.isAtleast("0.28.0")) { - return { ...node, tags: node.tags ?? [] } as Machine; - } - - const tags = Array.from(new Set([...(node.forcedTags ?? []), ...(node.validTags ?? [])])); - - return { ...node, tags } as Machine; -} - -export interface NodeEndpoints { - /** - * Retrieves all nodes (machines) from the Headscale instance. - * - * @returns An array of `Machine` objects representing the nodes. - */ - getNodes(): Promise; - - /** - * Retrieves a specific node (machine) by its ID. - * - * @param id The ID of the node to retrieve. - * @returns A `Machine` object representing the node. - */ - getNode(id: string): Promise; - - /** - * Deletes a specific node (machine) by its ID. - * - * @param id The ID of the node to delete. - */ - deleteNode(id: string): Promise; - - /** - * Registers a new node (machine) with the given user and key. - * - * @param user The user to associate with the node. - * @param key The registration key for the node. - * @returns A `Machine` object representing the newly registered node. - */ - registerNode(user: string, key: string): Promise; - - /** - * Approves routes for a specific node (machine) by its ID. - * - * @param id The ID of the node. - * @param routes An array of routes to approve for the node. - */ - approveNodeRoutes(id: string, routes: string[]): Promise; - - /** - * Expires a specific node (machine) by its ID. - * - * @param id The ID of the node to expire. - */ - expireNode(id: string): Promise; - - /** - * Renames a specific node (machine) by its ID. - * - * @param id The ID of the node to rename. - * @param newName The new name for the node. - */ - renameNode(id: string, newName: string): Promise; - - /** - * Sets tags for a specific node (machine) by its ID. - * - * @param id The ID of the node. - * @param tags An array of tags to set for the node. - */ - setNodeTags(id: string, tags: string[]): Promise; - - /** - * Sets the user for a specific node (machine) by its ID. - * - * @param id The ID of the node. - * @param user The user to set for the node. - */ - setNodeUser(id: string, user: string): Promise; -} - -export default defineApiEndpoints((client, apiKey) => ({ - getNodes: async () => { - const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey); - return nodes.map((node) => normalizeTags(client, node)); - }, - - getNode: async (nodeId) => { - const { node } = await client.apiFetch<{ node: RawMachine }>( - "GET", - `v1/node/${nodeId}`, - apiKey, - ); - - return normalizeTags(client, node); - }, - - deleteNode: async (nodeId) => { - await client.apiFetch("DELETE", `v1/node/${nodeId}`, apiKey); - }, - - registerNode: async (user, key) => { - const qp = new URLSearchParams(); - qp.append("user", user); - qp.append("key", key); - const { node } = await client.apiFetch<{ node: RawMachine }>( - "POST", - `v1/node/register?${qp.toString()}`, - apiKey, - { - user, - key, - }, - ); - - return normalizeTags(client, node); - }, - - approveNodeRoutes: async (nodeId, routes) => { - await client.apiFetch("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes }); - }, - - expireNode: async (nodeId) => { - await client.apiFetch("POST", `v1/node/${nodeId}/expire`, apiKey); - }, - - renameNode: async (nodeId, newName) => { - await client.apiFetch( - "POST", - `v1/node/${nodeId}/rename/${encodeURIComponent(newName)}`, - apiKey, - ); - }, - - setNodeTags: async (nodeId, tags) => { - await client.apiFetch("POST", `v1/node/${nodeId}/tags`, apiKey, { - tags, - }); - }, - - setNodeUser: async (nodeId, user) => { - // Headscale 0.28.0 got rid of node reassignment to users - if (client.isAtleast("0.28.0")) { - return; - } - - await client.apiFetch("POST", `v1/node/${nodeId}/user`, apiKey, { - user, - }); - }, -})); diff --git a/app/server/headscale/api/endpoints/policy.ts b/app/server/headscale/api/endpoints/policy.ts deleted file mode 100644 index 15f267d..0000000 --- a/app/server/headscale/api/endpoints/policy.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { defineApiEndpoints } from "../factory"; - -export interface PolicyEndpoints { - /** - * Retrieves the current ACL policy from the Headscale instance. - * - * @returns The ACL policy as a string and the date it was last updated. - */ - getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>; - - /** - * Sets the ACL policy for the Headscale instance. - * - * @param policy The ACL policy as a string. - * @returns The updated ACL policy as a string and the date it was last updated. - */ - setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>; -} - -export default defineApiEndpoints((client, apiKey) => ({ - getPolicy: async () => { - const { policy, updatedAt } = await client.apiFetch<{ - policy: string; - updatedAt: string; - }>("GET", "v1/policy", apiKey); - - return { - policy, - updatedAt: updatedAt !== null ? new Date(updatedAt) : null, - }; - }, - - setPolicy: async (policy) => { - const { policy: newPolicy, updatedAt } = await client.apiFetch<{ - policy: string; - updatedAt: string; - }>("PUT", "v1/policy", apiKey, { policy }); - - return { policy: newPolicy, updatedAt: new Date(updatedAt) }; - }, -})); diff --git a/app/server/headscale/api/endpoints/pre-auth-keys.ts b/app/server/headscale/api/endpoints/pre-auth-keys.ts deleted file mode 100644 index 4c6c220..0000000 --- a/app/server/headscale/api/endpoints/pre-auth-keys.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { PreAuthKey } from "~/types"; - -import { defineApiEndpoints } from "../factory"; - -export interface PreAuthKeyEndpoints { - /** - * List all pre-auth keys. Requires Headscale 0.28+. - */ - getAllPreAuthKeys(): Promise; - - /** - * Retrieves all pre-authentication keys for a specific user. - * - * @param user The user to retrieve pre-authentication keys for. - * @returns An array of `PreAuthKey` objects representing the pre-authentication keys. - */ - getPreAuthKeys(user: string): Promise; - - /** - * Creates a new pre-authentication key. - * User can be null for tag-only keys (requires Headscale 0.28+). - */ - createPreAuthKey( - user: string | null, - ephemeral: boolean, - reusable: boolean, - expiration: Date | null, - aclTags: string[] | null, - ): Promise; - - /** - * Expires a specific pre-authentication key for a user. - * - * @param user The user associated with the pre-authentication key. - * @param key The pre-authentication key to expire. - */ - expirePreAuthKey(user: string, key: PreAuthKey): Promise; -} - -export default defineApiEndpoints((client, apiKey) => ({ - getAllPreAuthKeys: async () => { - const { preAuthKeys } = await client.apiFetch<{ - preAuthKeys: PreAuthKey[]; - }>("GET", "v1/preauthkey", apiKey, {}); - - return preAuthKeys; - }, - - getPreAuthKeys: async (user) => { - const { preAuthKeys } = await client.apiFetch<{ - preAuthKeys: PreAuthKey[]; - }>("GET", "v1/preauthkey", apiKey, { user }); - - return preAuthKeys; - }, - - createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => { - const body: Record = { - ephemeral, - reusable, - expiration: expiration ? expiration.toISOString() : null, - }; - - if (user) { - body.user = user; - } - - if (aclTags && aclTags.length > 0) { - body.aclTags = aclTags; - } - - const { preAuthKey } = await client.apiFetch<{ - preAuthKey: PreAuthKey; - }>("POST", "v1/preauthkey", apiKey, body); - - return preAuthKey; - }, - - expirePreAuthKey: async (user, key) => { - if (client.isAtleast("0.28.0")) { - await client.apiFetch("POST", "v1/preauthkey/expire", apiKey, { - id: key.id, - }); - - return; - } - - await client.apiFetch("POST", "v1/preauthkey/expire", apiKey, { - user, - key: key.key, - }); - }, -})); diff --git a/app/server/headscale/api/endpoints/users.ts b/app/server/headscale/api/endpoints/users.ts deleted file mode 100644 index c82f7e6..0000000 --- a/app/server/headscale/api/endpoints/users.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { User } from "~/types"; - -import { defineApiEndpoints } from "../factory"; - -export interface UserEndpoints { - /** - * Retrieves users from the Headscale instance, optionally filtering by ID, name, or email. - * - * @param id Optional ID of the user to retrieve. - * @param name Optional name of the user to retrieve. - * @param email Optional email of the user to retrieve. - * @returns An array of `User` objects representing the users. - */ - getUsers(id?: string, name?: string, email?: string): Promise; - - /** - * Creates a new user in the Headscale instance. - * - * @param username The username of the new user. - * @param email Optional email of the new user. - * @param displayName Optional display name of the new user. - * @param pictureUrl Optional picture URL of the new user. - * @returns A `User` object representing the newly created user. - */ - createUser( - username: string, - email?: string, - displayName?: string, - pictureUrl?: string, - ): Promise; - - /** - * Deletes a specific user by its ID. - * - * @param id The ID of the user to delete. - */ - deleteUser(id: string): Promise; - - /** - * Renames a specific user by its ID. - * - * @param id The ID of the user to rename. - * @param newName The new name for the user. - */ - renameUser(id: string, newName: string): Promise; -} - -export default defineApiEndpoints((client, apiKey) => ({ - getUsers: async (id, name, email) => { - const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1; - - if (moreThanOneFilter) { - throw new Error("Only one of id, name, or email filters can be provided"); - } - - const { users } = await client.apiFetch<{ users: User[] }>("GET", "v1/user", apiKey, { - id, - name, - email, - }); - - return users; - }, - - createUser: async (username, email, displayName, pictureUrl) => { - const { user } = await client.apiFetch<{ user: User }>("POST", "v1/user", apiKey, { - name: username, - email, - displayName, - pictureUrl, - }); - - return user; - }, - - deleteUser: async (id) => { - await client.apiFetch("DELETE", `v1/user/${id}`, apiKey); - }, - - renameUser: async (oldId, newName) => { - await client.apiFetch( - "POST", - `v1/user/${oldId}/rename/${encodeURIComponent(newName)}`, - apiKey, - ); - }, -})); diff --git a/app/server/headscale/api/factory.ts b/app/server/headscale/api/factory.ts deleted file mode 100644 index ba83b46..0000000 --- a/app/server/headscale/api/factory.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { HeadscaleApiInterface } from "../api"; - -/** - * Creates a strongly-typed group factory for a given endpoint interface. - * - * Example: - * export const apiKeyGroup = defineGroup({...}) - */ - -export interface EndpointFactory { - __type?: T; - (client: HeadscaleApiInterface["clientHelpers"], apiKey: string): T; -} - -export function defineApiEndpoints( - factories: (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) => T, -): EndpointFactory { - return factories; -} - -export type ExtractApiEndpoints = F extends EndpointFactory ? T : never; -export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( - k: infer I, -) => void - ? I - : never; - -/** - * Compose multiple endpoint sets into a single typed runtime client - */ -export function composeEndpoints[]>( - factories: T, - clientHelpers: any, - apiKey: string, -): UnionToIntersection> { - const instances = factories.map((f) => f(clientHelpers, apiKey)); - return Object.assign({}, ...instances) as any; -} diff --git a/app/server/headscale/api/hasher.ts b/app/server/headscale/api/hasher.ts deleted file mode 100644 index 953f602..0000000 --- a/app/server/headscale/api/hasher.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createHash } from "node:crypto"; - -import { dereference } from "@readme/openapi-parser"; -import { OpenAPIV2 } from "openapi-types"; - -/** - * A map of operation IDs to their hashes. - */ -export interface DocumentHash { - [operationId: string]: string; -} - -/** - * Given an OpenAPI v2 document, generate a map of operations with hashes. - * This gives us deterministic identifers to determine the version of Headscale - * that is being used at runtime. - * - * @param doc The OpenAPI v2 document to hash. - * @returns A map of operation IDs to their hashes. - */ -export async function hashOpenApiDocument(doc: OpenAPIV2.Document): Promise { - const spec = await dereference(doc); - const hashes: DocumentHash = {}; - const seen = new Set(); - - for (const [path, item] of Object.entries(spec.paths)) { - for (const [method, operation] of Object.entries(item)) { - if (typeof operation !== "object") { - continue; - } - - const { parameters, responses } = operation as OpenAPIV2.OperationObject; - const raw = JSON.stringify( - { - path, - method: method.toUpperCase(), - parameters, - responses, - }, - Object.keys({ path, method, parameters, responses }).sort(), - ); - - const hash = createHash("md5").update(raw).digest("hex").slice(0, 16); - const final = seen.has(hash) ? `${hash}_${seen.size}` : hash; - seen.add(final); - hashes[`${method.toUpperCase()} ${path}`] = final; - } - } - - return hashes; -} diff --git a/app/server/headscale/api/index.ts b/app/server/headscale/api/index.ts index fd72432..976cf50 100644 --- a/app/server/headscale/api/index.ts +++ b/app/server/headscale/api/index.ts @@ -1,328 +1,92 @@ -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; - -import { dereference } from "@readme/openapi-parser"; -import type { OpenAPIV2 } from "openapi-types"; -import { data } from "react-router"; -import { Agent, type Dispatcher, request } from "undici"; +// MARK: Headscale API +// +// The public entry point for talking to a Headscale server. Boot +// detects the server version via `GET /version` (unauthenticated, +// available since Headscale 0.26.0), derives a typed `Capabilities` +// object, and returns a `Headscale` value that constructs +// authenticated `HeadscaleClient`s on demand. import log from "~/utils/log"; -import endpointSets, { RuntimeApiClient } from "./endpoints"; -import { undiciToFriendlyError } from "./error"; -import { HeadscaleAPIError, isApiError } from "./error-client"; -import { detectApiVersion, isAtLeast, type Version } from "./version"; +import { type Capabilities, capabilitiesFor } from "./capabilities"; +import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys"; +import { makeNodeApi, type NodeApi } from "./resources/nodes"; +import { makePolicyApi, type PolicyApi } from "./resources/policy"; +import { makePreAuthKeyApi, type PreAuthKeyApi } from "./resources/pre-auth-keys"; +import { makeUserApi, type UserApi } from "./resources/users"; +import { formatServerVersion, parseServerVersion, type ServerVersion } from "./server-version"; +import { createTransport, type Transport } from "./transport"; -/** - * A low-level composed interface for interacting with the Headscale API. - * This interface provides direct access to the underlying Undici agent - * and methods for making API requests. - * - * It is also responsible for handling OpenAPI spec polling and hashing to - * determine the implementations of API methods when requested for use. - */ -export interface HeadscaleApiInterface { - /** - * The underlying Undici agent used for making requests. - */ - undiciAgent: Agent; - - /** - * The base URL of the Headscale API. - */ - baseUrl: string; - - /** - * The OpenAPI hashes retrieved from the Headscale instance at runtime. - * This is used to determine which implementations of API methods to use. - */ - openapiHashes: Record | null; - - /** - * The detected API version of the connected Headscale instance. - */ - apiVersion: Version; - - /** - * Retrieves a runtime API client for the given API key. - * - * @param apiKey The API key to use for authentication. - * @returns A `RuntimeApiClient` instance for interacting with the API. - */ - getRuntimeClient(apiKey: string): RuntimeApiClient; - - /** - * A set of helper methods made available to API method implementations. - * The idea is to make interacting with the API easier by providing - * common functionality that can be reused across multiple methods. - */ - clientHelpers: { - /** - * Checks if the connected Headscale instance's API version - * is at least the specified version. - * - * @param version The version to check against. - * @returns `true` if the API version is at least the specified version, `false` otherwise. - */ - isAtleast(version: Version): boolean; - - /** - * Makes a raw fetch request to the Headscale API via the Undici agent. - * This method is used internally by API method implementations - * to make requests to the Headscale API. - * - * @param path The API path to request. - * @param options Optional request options. - * @returns A promise that resolves to the response data. - */ - rawFetch( - path: string, - options?: Partial, - ): Promise; - - /** - * Makes a typed API fetch request to the Headscale API. - * This method is used internally by API method implementations - * to make requests to the Headscale API and parse the response. - * - * @param method The HTTP method to use. - * @param apiPath The API path to request. - * @param apiKey The API key to use for authentication. - * @param bodyOrQuery Optional body or query parameters. - * @returns A promise that resolves to the typed response data. - */ - apiFetch( - method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", - apiPath: `v1/${string}`, - apiKey: string, - bodyOrQuery?: Record, - ): Promise; - }; +export interface Headscale { + readonly version: ServerVersion; + readonly capabilities: Capabilities; + /** True if the Headscale server's `/health` endpoint returns 200. */ + health(): Promise; + /** Build an API client bound to a specific Headscale API key. */ + client(apiKey: string): HeadscaleClient; + /** Stop background work and close the underlying HTTP agent. */ + dispose(): Promise; } -/** - * Creates a new Headscale API client interface. - * - * @param baseUrl The base URL of the Headscale API. - * @param certPath Optional path to a custom TLS certificate for secure connections. - * @returns A promise that resolves to a `HeadscaleApiClient` instance. - */ -export async function createHeadscaleInterface( - baseUrl: string, - certPath?: string, -): Promise { - const undiciAgent = await createUndiciAgent(certPath); - let openapiHashes: Record | null = null; - let apiVersion: Version; - - const rawFetch = async ( - url: string, - options?: Partial, - ): Promise => { - const method = options?.method ?? "GET"; - log.debug("api", "%s %s", method, url); - - try { - const res = await request(new URL(url, baseUrl), { - dispatcher: undiciAgent, - headers: { - ...options?.headers, - Accept: "application/json", - "User-Agent": `Headplane/${__VERSION__}`, - }, - - body: options?.body, - method, - }); - - return res; - } catch (error) { - const errorBody = undiciToFriendlyError(error, `${method} ${url}`); - throw data(errorBody, { - status: 502, - statusText: "Bad Gateway", - }); - } - }; - - const apiFetch = async ( - method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", - apiPath: `v1/${string}`, - apiKey: string, - bodyOrQuery?: Record, - ): Promise => { - let url = `/api/${apiPath}`; - const options: Partial = { - method: method, - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }; - - if (bodyOrQuery) { - if (method === "GET" || method === "DELETE") { - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(bodyOrQuery)) { - if (value !== undefined) { - params.append(key, String(value)); - } - } - - if ([...params.keys()].length > 0) { - url += `?${params.toString()}`; - } - } else { - options.body = JSON.stringify(bodyOrQuery); - options.headers = { - ...options.headers, - "Content-Type": "application/json", - }; - } - } - - const res = await rawFetch(url, options); - if (res.statusCode >= 400) { - log.debug("api", "%s %s failed with status %d", method, apiPath, res.statusCode); - const rawData = await res.body.text(); - const jsonData = (() => { - try { - return JSON.parse(rawData) as Record; - } catch { - return null; - } - })(); - - throw data( - { - requestUrl: `${method} ${apiPath}`, - statusCode: res.statusCode, - rawData, - data: jsonData, - } satisfies HeadscaleAPIError, - { status: 502, statusText: "Bad Gateway" }, - ); - } - - return res.body.json() as Promise; - }; - +export interface HeadscaleClient { + nodes: NodeApi; + users: UserApi; + policy: PolicyApi; + preAuthKeys: PreAuthKeyApi; + apiKeys: ApiKeyApi; /** - * Polls the OpenAPI spec endpoint and generates operation hashes. - * This is used to determine which implementations of API methods to use. - * - * @returns A promise that resolves to the OpenAPI operation hashes. + * Convenience passthrough to `Headscale.health()`. Headscale's + * `/health` endpoint is unauthenticated so callers that already + * have a client (loaders/actions) don't need to also reach for + * the top-level `Headscale` value. */ - async function fetchAndHashOpenapi(): Promise | null> { - try { - const res = await rawFetch("/swagger/v1/openapiv2.json"); - if (res.statusCode !== 200) { - log.error("api", "Failed to fetch OpenAPI spec: %d", res.statusCode); - return null; - } + isHealthy(): Promise; +} - const body = await res.body.json(); - const spec = await dereference(body as OpenAPIV2.Document); - const hashes = generateSpecHashes(spec); - log.debug("api", "OpenAPI hashes updated (%d endpoints)", Object.keys(hashes).length); - return hashes; - } catch (error) { - if (isApiError(error)) { - log.debug("api", "Failed to fetch OpenAPI spec: %d", error.statusCode); - } +export interface CreateHeadscaleOptions { + url: string; + certPath?: string; +} - return null; - } +export async function createHeadscale(opts: CreateHeadscaleOptions): Promise { + const transport = await createTransport({ url: opts.url, certPath: opts.certPath }); + + const versionInfo = await transport.getPublic<{ version: string }>("/version"); + const version = parseServerVersion(versionInfo.version); + const capabilities = capabilitiesFor(version); + + if (version.unknown) { + log.warn( + "api", + "Could not parse Headscale version %s, assuming newest known capabilities", + versionInfo.version, + ); + } else { + log.info("api", "Connected to Headscale %s", formatServerVersion(version)); } - const isAtleast = (version: Version): boolean => { - return isAtLeast(apiVersion, version); - }; - - openapiHashes = await fetchAndHashOpenapi(); - apiVersion = detectApiVersion(openapiHashes); - - setInterval(async () => { - const hashes = await fetchAndHashOpenapi(); - if (hashes) { - openapiHashes = hashes; - apiVersion = detectApiVersion(openapiHashes); - } - }, 60_000); // every 60 seconds + return makeHeadscale(transport, version, capabilities); +} +function makeHeadscale( + transport: Transport, + version: ServerVersion, + capabilities: Capabilities, +): Headscale { return { - undiciAgent, - baseUrl, - openapiHashes, - apiVersion, - getRuntimeClient: (apiKey: string) => { - return endpointSets( - { - rawFetch, - apiFetch, - isAtleast, - }, - apiKey, - ); - }, - clientHelpers: { - rawFetch, - apiFetch, - isAtleast, + version, + capabilities, + health: () => transport.health(), + client(apiKey) { + return { + nodes: makeNodeApi(transport, capabilities, apiKey), + users: makeUserApi(transport, capabilities, apiKey), + policy: makePolicyApi(transport, capabilities, apiKey), + preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey), + apiKeys: makeApiKeyApi(transport, capabilities, apiKey), + isHealthy: () => transport.health(), + }; }, + dispose: () => transport.dispose(), }; } - -/** - * Creates a new Undici agent for making HTTP requests. - * - * @param certPath Optional path to a custom TLS certificate for secure connections. - * @returns A promise that resolves to an `Agent` instance. - */ -async function createUndiciAgent(certPath?: string): Promise { - if (!certPath) { - return new Agent(); - } - - try { - log.debug("config", "Loading certificate from %s", certPath); - const data = await readFile(certPath, "utf8"); - - log.info("config", "Using certificate from %s", certPath); - return new Agent({ connect: { ca: data.trim() } }); - } catch (error) { - log.error("config", "Failed to load Headscale TLS cert: %s", error); - log.debug("config", "Error Details: %o", error); - return new Agent(); - } -} - -function generateSpecHashes(spec: OpenAPIV2.Document) { - const hashes: Record = {}; - const seen = new Set(); - - for (const [path, item] of Object.entries(spec.paths)) { - for (const [method, operation] of Object.entries(item)) { - if (typeof operation !== "object") { - continue; - } - - const { parameters, responses } = operation as OpenAPIV2.OperationObject; - const raw = JSON.stringify( - { - path, - method: method.toUpperCase(), - parameters, - responses, - }, - Object.keys({ path, method, parameters, responses }).sort(), - ); - - const hash = createHash("md5").update(raw).digest("hex").slice(0, 16); - const final = seen.has(hash) ? `${hash}_${seen.size}` : hash; - seen.add(final); - hashes[`${method.toUpperCase()} ${path}`] = final; - } - } - - return hashes; -} diff --git a/app/server/headscale/api/resources/api-keys.ts b/app/server/headscale/api/resources/api-keys.ts new file mode 100644 index 0000000..d220caf --- /dev/null +++ b/app/server/headscale/api/resources/api-keys.ts @@ -0,0 +1,25 @@ +import type { Key } from "~/types"; + +import type { Capabilities } from "../capabilities"; +import type { Transport } from "../transport"; + +export interface ApiKeyApi { + list(): Promise; +} + +export function makeApiKeyApi( + transport: Transport, + _capabilities: Capabilities, + apiKey: string, +): ApiKeyApi { + return { + list: async () => { + const { apiKeys } = await transport.request<{ apiKeys: Key[] }>({ + method: "GET", + path: "v1/apikey", + apiKey, + }); + return apiKeys; + }, + }; +} diff --git a/app/server/headscale/api/resources/nodes.ts b/app/server/headscale/api/resources/nodes.ts new file mode 100644 index 0000000..fb0d487 --- /dev/null +++ b/app/server/headscale/api/resources/nodes.ts @@ -0,0 +1,116 @@ +import type { Machine } from "~/types"; + +import type { Capabilities } from "../capabilities"; +import type { Transport } from "../transport"; + +interface RawMachine extends Omit { + tags?: string[]; + forcedTags?: string[]; + validTags?: string[]; + invalidTags?: string[]; +} + +export interface NodeApi { + list(): Promise; + get(id: string): Promise; + delete(id: string): Promise; + register(user: string, key: string): Promise; + approveRoutes(id: string, routes: string[]): Promise; + expire(id: string): Promise; + rename(id: string, newName: string): Promise; + setTags(id: string, tags: string[]): Promise; + /** + * Reassign a node to a different user. Only present when + * `capabilities.nodeOwnerIsImmutable` is false (Headscale < 0.28). + */ + reassignUser?: (id: string, user: string) => Promise; +} + +export function makeNodeApi( + transport: Transport, + capabilities: Capabilities, + apiKey: string, +): NodeApi { + function normalize(raw: RawMachine): Machine { + if (capabilities.nodeTagsAreFlat) { + return { ...raw, tags: raw.tags ?? [] } as Machine; + } + const tags = Array.from(new Set([...(raw.forcedTags ?? []), ...(raw.validTags ?? [])])); + return { ...raw, tags } as Machine; + } + + const api: NodeApi = { + list: async () => { + const { nodes } = await transport.request<{ nodes: RawMachine[] }>({ + method: "GET", + path: "v1/node", + apiKey, + }); + return nodes.map(normalize); + }, + get: async (id) => { + const { node } = await transport.request<{ node: RawMachine }>({ + method: "GET", + path: `v1/node/${id}`, + apiKey, + }); + return normalize(node); + }, + delete: async (id) => { + await transport.request({ method: "DELETE", path: `v1/node/${id}`, apiKey }); + }, + register: async (user, key) => { + // Headscale's node-register endpoint expects the registration + // params as both query string and body — preserved as-is. + const qp = new URLSearchParams(); + qp.append("user", user); + qp.append("key", key); + const { node } = await transport.request<{ node: RawMachine }>({ + method: "POST", + path: `v1/node/register?${qp.toString()}`, + apiKey, + body: { user, key }, + }); + return normalize(node); + }, + approveRoutes: async (id, routes) => { + await transport.request({ + method: "POST", + path: `v1/node/${id}/approve_routes`, + apiKey, + body: { routes }, + }); + }, + expire: async (id) => { + await transport.request({ method: "POST", path: `v1/node/${id}/expire`, apiKey }); + }, + rename: async (id, newName) => { + await transport.request({ + method: "POST", + path: `v1/node/${id}/rename/${encodeURIComponent(newName)}`, + apiKey, + }); + }, + setTags: async (id, tags) => { + await transport.request({ + method: "POST", + path: `v1/node/${id}/tags`, + apiKey, + body: { tags }, + }); + }, + }; + + if (!capabilities.nodeOwnerIsImmutable) { + api.reassignUser = async (id, user) => { + await transport.request({ + method: "POST", + path: `v1/node/${id}/user`, + apiKey, + body: { user }, + }); + }; + } + + return api; +} diff --git a/app/server/headscale/api/resources/policy.ts b/app/server/headscale/api/resources/policy.ts new file mode 100644 index 0000000..493128d --- /dev/null +++ b/app/server/headscale/api/resources/policy.ts @@ -0,0 +1,33 @@ +import type { Capabilities } from "../capabilities"; +import type { Transport } from "../transport"; + +export interface PolicyApi { + get(): Promise<{ policy: string; updatedAt: Date | null }>; + set(policy: string): Promise<{ policy: string; updatedAt: Date }>; +} + +export function makePolicyApi( + transport: Transport, + _capabilities: Capabilities, + apiKey: string, +): PolicyApi { + return { + get: async () => { + const { policy, updatedAt } = await transport.request<{ + policy: string; + updatedAt: string; + }>({ method: "GET", path: "v1/policy", apiKey }); + return { + policy, + updatedAt: updatedAt !== null ? new Date(updatedAt) : null, + }; + }, + set: async (policy) => { + const { policy: newPolicy, updatedAt } = await transport.request<{ + policy: string; + updatedAt: string; + }>({ method: "PUT", path: "v1/policy", apiKey, body: { policy } }); + return { policy: newPolicy, updatedAt: new Date(updatedAt) }; + }, + }; +} diff --git a/app/server/headscale/api/resources/pre-auth-keys.ts b/app/server/headscale/api/resources/pre-auth-keys.ts new file mode 100644 index 0000000..127961e --- /dev/null +++ b/app/server/headscale/api/resources/pre-auth-keys.ts @@ -0,0 +1,88 @@ +import type { PreAuthKey } from "~/types"; + +import type { Capabilities } from "../capabilities"; +import type { Transport } from "../transport"; + +export interface CreatePreAuthKeyOptions { + /** Owning user ID, or `null` for tag-only keys (0.28+). */ + user: string | null; + ephemeral: boolean; + reusable: boolean; + expiration: Date | null; + aclTags: string[] | null; +} + +export interface PreAuthKeyApi { + /** + * List every pre-auth key on the server. Only present when + * `capabilities.preAuthKeysHaveStableIds` is true (Headscale 0.28+). + * Pre-0.28 callers must use {@link listForUser}. + */ + listAll?: () => Promise; + + listForUser(userId: string): Promise; + + create(opts: CreatePreAuthKeyOptions): Promise; + + expire(key: PreAuthKey): Promise; +} + +export function makePreAuthKeyApi( + transport: Transport, + capabilities: Capabilities, + apiKey: string, +): PreAuthKeyApi { + const api: PreAuthKeyApi = { + listForUser: async (userId) => { + const { preAuthKeys } = await transport.request<{ + preAuthKeys: PreAuthKey[]; + }>({ method: "GET", path: "v1/preauthkey", apiKey, query: { user: userId } }); + return preAuthKeys; + }, + + create: async ({ user, ephemeral, reusable, expiration, aclTags }) => { + const body: Record = { + ephemeral, + reusable, + expiration: expiration ? expiration.toISOString() : null, + }; + if (user) body.user = user; + if (aclTags && aclTags.length > 0) body.aclTags = aclTags; + + const { preAuthKey } = await transport.request<{ + preAuthKey: PreAuthKey; + }>({ method: "POST", path: "v1/preauthkey", apiKey, body }); + return preAuthKey; + }, + + expire: async (key) => { + if (capabilities.preAuthKeysHaveStableIds) { + await transport.request({ + method: "POST", + path: "v1/preauthkey/expire", + apiKey, + body: { id: key.id }, + }); + return; + } + // Pre-0.28: expire takes user + key string. + await transport.request({ + method: "POST", + path: "v1/preauthkey/expire", + apiKey, + body: { user: key.user?.name ?? "", key: key.key }, + }); + }, + }; + + if (capabilities.preAuthKeysHaveStableIds) { + api.listAll = async () => { + const { preAuthKeys } = await transport.request<{ + preAuthKeys: PreAuthKey[]; + }>({ method: "GET", path: "v1/preauthkey", apiKey }); + return preAuthKeys; + }; + } + + return api; +} diff --git a/app/server/headscale/api/resources/users.ts b/app/server/headscale/api/resources/users.ts new file mode 100644 index 0000000..168e048 --- /dev/null +++ b/app/server/headscale/api/resources/users.ts @@ -0,0 +1,66 @@ +import type { User } from "~/types"; + +import type { Capabilities } from "../capabilities"; +import type { Transport } from "../transport"; + +export interface CreateUserOptions { + name: string; + email?: string; + displayName?: string; + pictureUrl?: string; +} + +export interface ListUsersFilter { + id?: string; + name?: string; + email?: string; +} + +export interface UserApi { + list(filter?: ListUsersFilter): Promise; + create(opts: CreateUserOptions): Promise; + delete(id: string): Promise; + rename(id: string, newName: string): Promise; +} + +export function makeUserApi( + transport: Transport, + _capabilities: Capabilities, + apiKey: string, +): UserApi { + return { + list: async (filter) => { + const { id, name, email } = filter ?? {}; + const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1; + if (moreThanOneFilter) { + throw new Error("Only one of id, name, or email filters can be provided"); + } + const { users } = await transport.request<{ users: User[] }>({ + method: "GET", + path: "v1/user", + apiKey, + query: { id, name, email }, + }); + return users; + }, + create: async ({ name, email, displayName, pictureUrl }) => { + const { user } = await transport.request<{ user: User }>({ + method: "POST", + path: "v1/user", + apiKey, + body: { name, email, displayName, pictureUrl }, + }); + return user; + }, + delete: async (id) => { + await transport.request({ method: "DELETE", path: `v1/user/${id}`, apiKey }); + }, + rename: async (id, newName) => { + await transport.request({ + method: "POST", + path: `v1/user/${id}/rename/${encodeURIComponent(newName)}`, + apiKey, + }); + }, + }; +} diff --git a/app/server/headscale/api/server-version.ts b/app/server/headscale/api/server-version.ts new file mode 100644 index 0000000..5a40805 --- /dev/null +++ b/app/server/headscale/api/server-version.ts @@ -0,0 +1,98 @@ +// MARK: ServerVersion +// +// Parses the response from Headscale's `GET /version` endpoint into a +// structured value that capability checks can reason about. The +// endpoint exists in every Headscale release we support (0.26.0+), +// returns a plain semver-like string such as `v0.28.0`, `v0.28.0-beta.1`, +// or `dev` for untagged builds. +// +// Comparisons (`gte`) are lenient about prerelease tags by design: +// `0.28.0-beta.1` is treated as `0.28.0` for capability gating. The +// behavioural changes that capabilities gate on always land in the +// first prerelease of a minor version, so a strict semver +// interpretation would lock prerelease users out of features that +// their server actually has. + +const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/; + +export interface ServerVersion { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly prerelease: string | undefined; + readonly build: string | undefined; + /** The raw string as reported by Headscale (e.g. `v0.28.0-beta.1`, `dev`). */ + readonly raw: string; + /** + * True when the server reported a version we couldn't parse — + * typically `dev` for an untagged Headscale build. Capability checks + * treat unknown versions as having every known capability so we + * exercise the modern code paths against unfamiliar servers rather + * than silently falling back to compatibility shims. + */ + readonly unknown: boolean; +} + +export function parseServerVersion(raw: string): ServerVersion { + const match = SEMVER_RE.exec(raw.trim()); + if (!match) { + return { + major: 0, + minor: 0, + patch: 0, + prerelease: undefined, + build: undefined, + raw, + unknown: true, + }; + } + const [, maj, min, pat, pre, build] = match; + return { + major: Number(maj), + minor: Number(min), + patch: Number(pat), + prerelease: pre, + build, + raw, + unknown: false, + }; +} + +/** Pretty-print a parsed version for logs (no `v` prefix, includes prerelease). */ +export function formatServerVersion(version: ServerVersion): string { + if (version.unknown) return version.raw; + const core = `${version.major}.${version.minor}.${version.patch}`; + return version.prerelease ? `${core}-${version.prerelease}` : core; +} + +/** + * Returns true when `version` is at least `target` (ignoring prerelease + * tags — see module-level note). `target` must be a plain semver + * string like `0.28.0`. Throws on a malformed target since those come + * from static capability tables, not user input. + */ +export function gte(version: ServerVersion, target: string): boolean { + if (version.unknown) return true; + const t = parseTarget(target); + if (version.major !== t.major) return version.major > t.major; + if (version.minor !== t.minor) return version.minor > t.minor; + return version.patch >= t.patch; +} + +interface TargetVersion { + major: number; + minor: number; + patch: number; +} + +function parseTarget(target: string): TargetVersion { + const match = SEMVER_RE.exec(target); + if (!match) { + throw new Error(`Invalid capability target version: ${target}`); + } + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} diff --git a/app/server/headscale/api/transport.ts b/app/server/headscale/api/transport.ts new file mode 100644 index 0000000..9dbdf98 --- /dev/null +++ b/app/server/headscale/api/transport.ts @@ -0,0 +1,191 @@ +// MARK: Headscale Transport +// +// Internal HTTP transport for talking to a Headscale server. Owns +// the Undici agent (and any custom CA), the base URL, error +// translation, and the distinction between authenticated `/api/v1` +// calls and unauthenticated public endpoints (`/version`, `/health`). +// +// This module is intentionally not exported from the package; all +// consumers should go through `Headscale` and `HeadscaleClient` in +// `./index.ts`, never the transport directly. + +import { readFile } from "node:fs/promises"; + +import { data } from "react-router"; +import { Agent, type Dispatcher, request } from "undici"; + +import log from "~/utils/log"; + +import { undiciToFriendlyError } from "./error"; +import { type HeadscaleAPIError, isApiError } from "./error-client"; + +export interface TransportRequest { + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + /** API path without the `/api/` prefix (e.g. `v1/node`). */ + path: `v1/${string}`; + apiKey: string; + /** JSON request body for non-GET/DELETE requests. */ + body?: Record; + /** Query parameters for GET/DELETE requests. */ + query?: Record; +} + +export interface Transport { + /** + * Send an authenticated JSON request against `/api/{path}`. + * Throws a React Router `data()` 502 response on transport errors + * and a typed `HeadscaleAPIError` (wrapped in `data()`) on API + * errors with statusCode >= 400. + */ + request(opts: TransportRequest): Promise; + + /** + * Send an unauthenticated GET against the server root + * (e.g. `/version`, `/health`). Returns parsed JSON. + */ + getPublic(path: `/${string}`): Promise; + + /** True if `GET /health` returns 200. Never throws. */ + health(): Promise; + + /** Shut down the underlying Undici agent. */ + dispose(): Promise; +} + +export interface TransportOptions { + url: string; + certPath?: string; +} + +export async function createTransport(opts: TransportOptions): Promise { + const agent = await createUndiciAgent(opts.certPath); + const baseUrl = opts.url; + + async function rawRequest( + url: string, + options: Partial & { method: string }, + ): Promise { + log.debug("api", "%s %s", options.method, url); + try { + return await request(new URL(url, baseUrl), { + dispatcher: agent, + headers: { + ...options.headers, + Accept: "application/json", + "User-Agent": `Headplane/${__VERSION__}`, + }, + body: options.body, + method: options.method, + }); + } catch (error) { + const errorBody = undiciToFriendlyError(error, `${options.method} ${url}`); + throw data(errorBody, { status: 502, statusText: "Bad Gateway" }); + } + } + + return { + async request({ method, path, apiKey, body, query }: TransportRequest): Promise { + let url = `/api/${path}`; + const options: Partial & { method: string } = { + method, + headers: { Authorization: `Bearer ${apiKey}` }, + }; + + if (query && (method === "GET" || method === "DELETE")) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) { + params.append(key, String(value)); + } + } + if ([...params.keys()].length > 0) { + url += `?${params.toString()}`; + } + } else if (body && method !== "GET" && method !== "DELETE") { + options.body = JSON.stringify(body); + options.headers = { ...options.headers, "Content-Type": "application/json" }; + } + + const res = await rawRequest(url, options); + if (res.statusCode >= 400) { + log.debug("api", "%s %s failed with status %d", method, path, res.statusCode); + const rawData = await res.body.text(); + const jsonData = (() => { + try { + return JSON.parse(rawData) as Record; + } catch { + return null; + } + })(); + + throw data( + { + requestUrl: `${method} ${path}`, + statusCode: res.statusCode, + rawData, + data: jsonData, + } satisfies HeadscaleAPIError, + { status: 502, statusText: "Bad Gateway" }, + ); + } + + return res.body.json() as Promise; + }, + + async getPublic(path: `/${string}`): Promise { + const res = await rawRequest(path, { method: "GET" }); + if (res.statusCode >= 400) { + const rawData = await res.body.text(); + const jsonData = (() => { + try { + return JSON.parse(rawData) as Record; + } catch { + return null; + } + })(); + throw data( + { + requestUrl: `GET ${path}`, + statusCode: res.statusCode, + rawData, + data: jsonData, + } satisfies HeadscaleAPIError, + { status: 502, statusText: "Bad Gateway" }, + ); + } + return res.body.json() as Promise; + }, + + async health() { + try { + const res = await rawRequest("/health", { method: "GET" }); + // Drain the body so the connection can be reused. + await res.body.dump(); + return res.statusCode === 200; + } catch (error) { + if (isApiError(error)) { + log.debug("api", "Health check failed: %d", error.statusCode); + } + return false; + } + }, + + async dispose() { + await agent.close(); + }, + }; +} + +async function createUndiciAgent(certPath?: string): Promise { + if (!certPath) return new Agent(); + try { + log.debug("config", "Loading certificate from %s", certPath); + const cert = await readFile(certPath, "utf8"); + log.info("config", "Using certificate from %s", certPath); + return new Agent({ connect: { ca: cert.trim() } }); + } catch (error) { + log.error("config", "Failed to load Headscale TLS cert: %s", error); + log.debug("config", "Error Details: %o", error); + return new Agent(); + } +} diff --git a/app/server/headscale/api/version.ts b/app/server/headscale/api/version.ts deleted file mode 100644 index 167cade..0000000 --- a/app/server/headscale/api/version.ts +++ /dev/null @@ -1,86 +0,0 @@ -import canonicals from "~/openapi-canonical-families.json"; -import hashes from "~/openapi-operation-hashes.json"; -import log from "~/utils/log"; - -/** - * The known API versions based on operation hashes. - */ -export type Version = keyof typeof hashes; -const VERSIONS = Object.keys(hashes) as Version[]; - -/** - * Detects the closest matching API version using operation hashes. - * Falls back to the latest known version and emits a warning if unknown. - * - * @param observed - A mapping of operation identifiers to their hashes. - * @returns The detected API version. - */ -export function detectApiVersion(observed: Record | null): Version { - if (!observed) { - const latest = VERSIONS.at(-1)!; - log.warn("api", "No operation hashes observed, defaulting to version %s", latest); - return latest; - } - - let bestVersion: Version | null = null; - let bestScore = -1; - - for (const [version, known] of Object.entries(hashes) as [Version, Record][]) { - let score = 0; - for (const [op, hash] of Object.entries(observed)) { - if (known[op] === hash) score++; - } - if (score > bestScore) { - bestVersion = version; - bestScore = score; - } - } - - if (!bestVersion || bestScore === 0) { - const latest = VERSIONS.at(-1)!; - log.warn("api", "Could not determine API version, defaulting to %s", latest); - return latest; - } - - if (bestScore < Object.keys(observed).length) { - log.warn( - "api", - "Partial version match: %d/%d endpoints for version %s", - bestScore, - Object.keys(observed).length, - bestVersion, - ); - } - - const canonical = Object.entries(canonicals).find(([_, family]) => - family.includes(bestVersion), - )?.[0] as Version | undefined; - - if (!canonical) { - log.warn("api", "Could not canonicalize detected version %s, using as-is", bestVersion); - - return bestVersion; - } - - if (canonical !== bestVersion) { - log.info( - "api", - "Canonicalizing detected version %s → %s (same schema)", - bestVersion, - canonical, - ); - } - - return canonical; -} - -/** - * Checks if the current version is at least the baseline version. - * - * @param current - The current API version. - * @param baseline - The baseline API version to compare against. - * @returns True if current is at least baseline, false otherwise. - */ -export function isAtLeast(current: Version, baseline: Version) { - return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline); -} diff --git a/app/server/headscale/live-store.ts b/app/server/headscale/live-store.ts index 92b83af..3883d27 100644 --- a/app/server/headscale/live-store.ts +++ b/app/server/headscale/live-store.ts @@ -1,6 +1,6 @@ import log from "~/utils/log"; -import type { RuntimeApiClient } from "./api/endpoints"; +import type { HeadscaleClient } from "./api"; /** * Defines a resource that can be fetched and polled by the live store. @@ -19,7 +19,7 @@ export interface ResourceDefinition { /** * A callback to fire to get the latest data for this resource */ - readonly fetch: (client: RuntimeApiClient) => Promise; + readonly fetch: (client: HeadscaleClient) => Promise; } /** @@ -37,12 +37,12 @@ export function defineResource( export const nodesResource = defineResource("nodes", { pollInterval: 5_000, - fetch: (api) => api.getNodes(), + fetch: (api) => api.nodes.list(), }); export const usersResource = defineResource("users", { pollInterval: 15_000, - fetch: (api) => api.getUsers(), + fetch: (api) => api.users.list(), }); interface Snapshot { @@ -54,8 +54,8 @@ interface Snapshot { type ChangeListener = (resourceKey: string, version: string) => void; export interface LiveStore { - get(resource: ResourceDefinition, apiClient: RuntimeApiClient): Promise>; - refresh(resource: ResourceDefinition, apiClient: RuntimeApiClient): Promise; + get(resource: ResourceDefinition, apiClient: HeadscaleClient): Promise>; + refresh(resource: ResourceDefinition, apiClient: HeadscaleClient): Promise; getVersions(): Record; subscribe(listener: ChangeListener): () => void; dispose(): void; @@ -66,7 +66,7 @@ export function createLiveStore(resources: ResourceDefinition[]): LiveS const serializedCache = new Map(); const listeners = new Set(); const intervals = new Map>(); - let storedApiClient: RuntimeApiClient | undefined; + let storedApiClient: HeadscaleClient | undefined; let versionCounter = 0; function notifyListeners(resourceKey: string, version: string) { @@ -77,7 +77,7 @@ export function createLiveStore(resources: ResourceDefinition[]): LiveS async function fetchResource( resource: ResourceDefinition, - apiClient: RuntimeApiClient, + apiClient: HeadscaleClient, ): Promise { const data = await resource.fetch(apiClient); const json = JSON.stringify(data); @@ -138,7 +138,7 @@ export function createLiveStore(resources: ResourceDefinition[]): LiveS return { async get( resource: ResourceDefinition, - apiClient: RuntimeApiClient, + apiClient: HeadscaleClient, ): Promise> { storedApiClient = apiClient; const def = findResource(resource.key); @@ -154,7 +154,7 @@ export function createLiveStore(resources: ResourceDefinition[]): LiveS return snapshots.get(resource.key) as Snapshot; }, - async refresh(resource: ResourceDefinition, apiClient: RuntimeApiClient): Promise { + async refresh(resource: ResourceDefinition, apiClient: HeadscaleClient): Promise { storedApiClient = apiClient; const def = findResource(resource.key); if (!def) { diff --git a/app/server/hp-agent.ts b/app/server/hp-agent.ts index fdeedc7..627b875 100644 --- a/app/server/hp-agent.ts +++ b/app/server/hp-agent.ts @@ -11,7 +11,7 @@ import log from "~/utils/log"; import { HeadplaneConfig } from "./config/config-schema"; import { hostInfo } from "./db/schema"; -import { RuntimeApiClient } from "./headscale/api/endpoints"; +import type { HeadscaleClient } from "./headscale/api"; export interface AgentManager { lookup(nodeKeys: string[]): Promise>; @@ -46,7 +46,7 @@ async function hasExistingState(workDir: string): Promise { export async function createAgentManager( agentConfig: NonNullable["agent"]> | undefined, headscaleUrl: string, - apiClient: RuntimeApiClient, + apiClient: HeadscaleClient, supportsTagOnlyKeys: boolean, db: NodeSQLiteDatabase, ): Promise { @@ -101,9 +101,13 @@ export async function createAgentManager( async function generateAuthKey(): Promise { const expiration = new Date(Date.now() + 5 * 60_000); - const pak = await apiClient.createPreAuthKey(null, false, false, expiration, [ - `tag:${hostName}`, - ]); + const pak = await apiClient.preAuthKeys.create({ + user: null, + ephemeral: false, + reusable: false, + expiration, + aclTags: [`tag:${hostName}`], + }); return pak.key; } @@ -272,7 +276,7 @@ export async function createAgentManager( */ async function pruneStaleHostInfo() { try { - const nodes = await apiClient.getNodes(); + const nodes = await apiClient.nodes.list(); const activeKeys = nodes.map((n) => n.nodeKey); if (activeKeys.length === 0) { @@ -298,11 +302,11 @@ export async function createAgentManager( async function pruneEphemeralNodes() { try { - const nodes = await apiClient.getNodes(); + const nodes = await apiClient.nodes.list(); const toPrune = nodes.filter((n) => n.preAuthKey?.ephemeral && !n.online); for (const node of toPrune) { - await apiClient.deleteNode(node.id); + await apiClient.nodes.delete(node.id); log.info("agent", "Pruned offline ephemeral node %s", node.givenName); } } catch (error) { diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 4166313..8166728 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -171,7 +171,7 @@ export interface AppRuntime { db: DbClient; auth: AuthService; oidc?: OidcService; - hsApi: HeadscaleInterface; + headscale: Headscale; agents?: AgentManager; stop(): Promise; } @@ -300,7 +300,7 @@ function createTestRuntime(overrides: Partial = {}): AppRuntime { config: testConfig, db: createTestDb(), auth: createTestAuth(), - hsApi: createTestHsApi(), + headscale: createTestHeadscale(), stop: async () => {}, ...overrides, }; diff --git a/package.json b/package.json index c3088d9..149a59b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs", - "gen:hashes": "tsx tests/generate-openapi-hashes.ts", "lint": "oxlint", "format": "oxfmt" }, @@ -32,7 +31,6 @@ "@kubernetes/client-node": "^1.4.0", "@lezer/highlight": "^1.2.3", "@react-router/node": "^7.14.0", - "@readme/openapi-parser": "^6.0.1", "@uiw/react-codemirror": "4.25.9", "arktype": "^2.2.0", "clsx": "^2.1.1", @@ -42,7 +40,6 @@ "js-yaml": "^4.1.1", "lucide-react": "^1.8.0", "mime": "^4.1.0", - "openapi-types": "^12.1.3", "react": "19.2.5", "react-codemirror-merge": "4.25.9", "react-dom": "19.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a6a48a..5c17e28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,9 +47,6 @@ importers: '@react-router/node': specifier: ^7.14.0 version: 7.14.0(react-router@7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2) - '@readme/openapi-parser': - specifier: ^6.0.1 - version: 6.0.1(openapi-types@12.1.3) '@uiw/react-codemirror': specifier: 4.25.9 version: 4.25.9(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.18.2(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.0)(@lezer/common@1.5.2))(@codemirror/language@6.12.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.41.0)(codemirror@6.0.1(@lezer/common@1.5.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -77,9 +74,6 @@ importers: mime: specifier: ^4.1.0 version: 4.1.0 - openapi-types: - specifier: ^12.1.3 - version: 12.1.3 react: specifier: 19.2.5 version: 19.2.5 @@ -180,12 +174,6 @@ importers: packages: - '@apidevtools/json-schema-ref-parser@14.2.1': - resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==} - engines: {node: '>= 20'} - peerDependencies: - '@types/json-schema': ^7.0.15 - '@ark/schema@0.56.0': resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} @@ -998,10 +986,6 @@ packages: engines: {node: '>=6'} hasBin: true - '@humanwhocodes/momoa@2.0.4': - resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} - engines: {node: '>=10.10.0'} - '@iconify-json/simple-icons@1.2.48': resolution: {integrity: sha512-EACOtZMoPJtERiAbX1De0asrrCtlwI27+03c9OJlYWsly9w1O5vcD8rTzh+kDPjo+K8FOVnq2Qy+h/CzljSKDA==} @@ -1480,22 +1464,6 @@ packages: typescript: optional: true - '@readme/better-ajv-errors@2.4.0': - resolution: {integrity: sha512-9WODaOAKSl/mU+MYNZ2aHCrkoRSvmQ+1YkLj589OEqqjOAhbn8j7Z+ilYoiTu/he6X63/clsxxAB4qny9/dDzg==} - engines: {node: '>=18'} - peerDependencies: - ajv: 4.11.8 - 8 - - '@readme/openapi-parser@6.0.1': - resolution: {integrity: sha512-uMtwMPVv86Xr5Y6A+QFrxLwWW1irJp3TIz7R3Zs2WaX383MPYX5kaQemPBsaHCu58ZULd+bosNIGUoFW8KM5Ew==} - engines: {node: '>=20'} - peerDependencies: - openapi-types: '>=7' - - '@readme/openapi-schemas@3.1.0': - resolution: {integrity: sha512-9FC/6ho8uFa8fV50+FPy/ngWN53jaUu4GRXlAjcxIRrzhltJnpKkBG2Tp0IDraFJeWrOpk84RJ9EMEEYzaI1Bw==} - engines: {node: '>=18'} - '@remix-run/node-fetch-server@0.13.0': resolution: {integrity: sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==} @@ -1844,9 +1812,6 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -2116,17 +2081,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ajv-draft-04@1.0.0: - resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} - peerDependencies: - ajv: ^8.5.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2738,15 +2692,9 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2963,9 +2911,6 @@ packages: engines: {node: '>=6'} hasBin: true - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2976,10 +2921,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - jsonpointer@5.0.1: - resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} - engines: {node: '>=0.10.0'} - jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -3051,10 +2992,6 @@ packages: resolution: {integrity: sha512-yB9IFWurFllusbPZqvG0EavTmpNXPya2MuO7Li7YT78xAj3uCQ3AgmW9TVUbTTsSMhsegbiAMRpwfEk2TP1P0A==} hasBin: true - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - libsql@0.5.29: resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} cpu: [x64, arm64, wasm32, arm] @@ -3322,9 +3259,6 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} - openapi-types@12.1.3: - resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} - openid-client@6.8.2: resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} @@ -3507,10 +3441,6 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -3873,6 +3803,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true valibot@1.3.1: @@ -4132,11 +4063,6 @@ packages: snapshots: - '@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)': - dependencies: - '@types/json-schema': 7.0.15 - js-yaml: 4.1.1 - '@ark/schema@0.56.0': dependencies: '@ark/util': 0.56.0 @@ -4888,8 +4814,6 @@ snapshots: protobufjs: 7.5.4 yargs: 17.7.2 - '@humanwhocodes/momoa@2.0.4': {} - '@iconify-json/simple-icons@1.2.48': dependencies: '@iconify/types': 2.0.0 @@ -5296,28 +5220,6 @@ snapshots: optionalDependencies: typescript: 6.0.2 - '@readme/better-ajv-errors@2.4.0(ajv@8.18.0)': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 - '@humanwhocodes/momoa': 2.0.4 - ajv: 8.18.0 - jsonpointer: 5.0.1 - leven: 3.1.0 - picocolors: 1.1.1 - - '@readme/openapi-parser@6.0.1(openapi-types@12.1.3)': - dependencies: - '@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15) - '@readme/better-ajv-errors': 2.4.0(ajv@8.18.0) - '@readme/openapi-schemas': 3.1.0 - '@types/json-schema': 7.0.15 - ajv: 8.18.0 - ajv-draft-04: 1.0.0(ajv@8.18.0) - openapi-types: 12.1.3 - - '@readme/openapi-schemas@3.1.0': {} - '@remix-run/node-fetch-server@0.13.0': {} '@rolldown/binding-android-arm64@1.0.0-rc.15': @@ -5584,8 +5486,6 @@ snapshots: '@types/js-yaml@4.0.9': {} - '@types/json-schema@7.0.15': {} - '@types/linkify-it@5.0.0': {} '@types/markdown-it@14.1.2': @@ -5880,17 +5780,6 @@ snapshots: agent-base@7.1.4: {} - ajv-draft-04@1.0.0(ajv@8.18.0): - optionalDependencies: - ajv: 8.18.0 - - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -6459,12 +6348,8 @@ snapshots: exsolve@1.0.8: {} - fast-deep-equal@3.1.3: {} - fast-fifo@1.3.2: {} - fast-uri@3.1.0: {} - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -6673,8 +6558,6 @@ snapshots: jsesc@3.0.2: {} - json-schema-traverse@1.0.0: {} - json5@2.2.3: {} jsonpath-plus@10.3.0: @@ -6683,8 +6566,6 @@ snapshots: '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) jsep: 1.4.0 - jsonpointer@5.0.1: {} - jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -6758,8 +6639,6 @@ snapshots: lefthook-windows-arm64: 2.1.5 lefthook-windows-x64: 2.1.5 - leven@3.1.0: {} - libsql@0.5.29: dependencies: '@neon-rs/load': 0.0.4 @@ -7001,8 +6880,6 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 - openapi-types@12.1.3: {} - openid-client@6.8.2: dependencies: jose: 6.2.2 @@ -7256,8 +7133,6 @@ snapshots: require-directory@2.1.1: {} - require-from-string@2.0.2: {} - reselect@5.1.1: {} resolve-pkg-maps@1.0.0: {} diff --git a/tests/generate-openapi-hashes.ts b/tests/generate-openapi-hashes.ts deleted file mode 100644 index 1924cfb..0000000 --- a/tests/generate-openapi-hashes.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { cwd } from "node:process"; - -import type { OpenAPIV2 } from "openapi-types"; -import { request } from "undici"; - -import { hashOpenApiDocument } from "~/server/headscale/api/hasher"; - -const HASH_FILE_LOCATION = "app/openapi-operation-hashes.json"; -const CANONICAL_LOCATION = "app/openapi-canonical-families.json"; - -const SPEC_MAP = { - // '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json', - // '0.25.1': '/v0.25.1/gen/openapiv2/headscale/v1/headscale.swagger.json', - "0.26.0": "/v0.26.0/gen/openapiv2/headscale/v1/headscale.swagger.json", - "0.26.1": "/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json", - "0.27.0": "/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json", - "0.27.1": "/v0.27.1/gen/openapiv2/headscale/v1/headscale.swagger.json", - "0.28.0": "/v0.28.0/gen/openapiv2/headscale/v1/headscale.swagger.json", -} as const; - -async function hashOpenApiOperations(specUrl: string) { - const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`; - const res = await request(url); - if (res.statusCode !== 200) { - console.error("Failed to fetch OpenAPI spec:", res.statusCode); - process.exit(1); - } - - const body = (await res.body.json()) as OpenAPIV2.Document; - return hashOpenApiDocument(body); -} - -async function collectCanonicalizedFamilies( - newHashes: readonly (readonly [string, Record])[], -) { - const canonicalizedFamilies: Record = {}; - for (const [version, hashes] of newHashes) { - const signature = JSON.stringify(hashes); - let canonical: string | null = null; - - for (const existingCanonical of Object.keys(canonicalizedFamilies)) { - const existingSignature = JSON.stringify( - newHashes.find(([v]) => v === existingCanonical)![1], - ); - - if (existingSignature === signature) { - canonical = existingCanonical; - break; - } - } - - if (!canonical) { - canonicalizedFamilies[version] = [version]; - continue; - } - - canonicalizedFamilies[canonical].push(version); - if ( - version.localeCompare(canonical, undefined, { - numeric: true, - sensitivity: "base", - }) > 0 - ) { - canonicalizedFamilies[version] = canonicalizedFamilies[canonical]; - delete canonicalizedFamilies[canonical]; - } - } - - for (const [canonical, family] of Object.entries(canonicalizedFamilies)) { - canonicalizedFamilies[canonical] = family.sort((a, b) => - a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }), - ); - } - - return canonicalizedFamilies; -} - -async function writeHashes(hashes: Record>) { - const path = resolve(cwd(), HASH_FILE_LOCATION); - await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, "utf-8"); -} - -async function writeCanonicalizedFamilies(families: Record) { - const path = resolve(cwd(), CANONICAL_LOCATION); - await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, "utf-8"); -} - -const newHashes = await Promise.all( - Object.entries(SPEC_MAP).map(async ([version, specUrl]) => { - const hashes = await hashOpenApiOperations(specUrl); - return [version, hashes] as const; - }), -); - -const canonicalizedFamilies = await collectCanonicalizedFamilies(newHashes); - -console.log("Writing new OpenAPI operation hashes to file"); -await writeHashes(Object.fromEntries(newHashes)); -await writeCanonicalizedFamilies(canonicalizedFamilies); diff --git a/tests/integration/api/api-keys.test.ts b/tests/integration/api/api-keys.test.ts index c3c6ce2..68698d2 100644 --- a/tests/integration/api/api-keys.test.ts +++ b/tests/integration/api/api-keys.test.ts @@ -5,7 +5,7 @@ import { getRuntimeClient, HS_VERSIONS } from "../setup/env"; describe.for(HS_VERSIONS)("Headscale %s: API Keys", (version) => { test("api keys can be fetched", async () => { const client = await getRuntimeClient(version); - const apiKeys = await client.getApiKeys(); + const apiKeys = await client.apiKeys.list(); expect(Array.isArray(apiKeys)).toBe(true); expect(apiKeys.length).toBe(1); }); diff --git a/tests/integration/api/nodes.test.ts b/tests/integration/api/nodes.test.ts index 7565dd9..91105e2 100644 --- a/tests/integration/api/nodes.test.ts +++ b/tests/integration/api/nodes.test.ts @@ -9,8 +9,8 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { const client = await getRuntimeClient(version); const tailnetNode = await getNode(version); - const user = await client.createUser("node-reg@"); - const node = await client.registerNode(user.name, tailnetNode.authCode); + const user = await client.users.create({ name: "node-reg@" }); + const node = await client.nodes.register(user.name, tailnetNode.authCode); expect(node).toBeDefined(); expect(node.registerMethod).toBe("REGISTER_METHOD_CLI"); expect(node.name).toBe(tailnetNode.nodeName); @@ -19,12 +19,12 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { test("nodes can be retrieved", async () => { const client = await getRuntimeClient(version); const { nodeName } = await getNode(version); - const nodes = await client.getNodes(); + const nodes = await client.nodes.list(); const node = nodes.find((n) => n.name === nodeName); expect(node).toBeDefined(); expect(node?.name).toBe(nodeName); - const fetchedNode = await client.getNode(node!.id); + const fetchedNode = await client.nodes.get(node!.id); expect(fetchedNode).toBeDefined(); expect(fetchedNode.id).toBe(node!.id); workingNodeId = node!.id; @@ -35,41 +35,43 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { const { nodeName } = await getNode(version); const newName = `${nodeName}-renamed`; - await client.renameNode(workingNodeId, newName); - const renamedNode = await client.getNode(workingNodeId); + await client.nodes.rename(workingNodeId, newName); + const renamedNode = await client.nodes.get(workingNodeId); expect(renamedNode).toBeDefined(); expect(renamedNode.givenName).toBe(newName); }); test("nodes can be reassigned to another user", async (context) => { const bootstrap = await getBootstrapClient(version); - if (bootstrap.clientHelpers.isAtleast("0.28.0")) { + // Reassigning a node owner was removed in 0.28. + if (bootstrap.capabilities.nodeOwnerIsImmutable) { context.skip(); } const client = await getRuntimeClient(version); - const user = await client.createUser("node-reassign@"); + const user = await client.users.create({ name: "node-reassign@" }); - await client.setNodeUser(workingNodeId, user.id); - const reassignedNode = await client.getNode(workingNodeId); + // reassignUser is only defined on pre-0.28 clients, hence the guard above. + await client.nodes.reassignUser!(workingNodeId, user.id); + const reassignedNode = await client.nodes.get(workingNodeId); expect(reassignedNode).toBeDefined(); - expect(reassignedNode.user.name).toBe(user.name); + expect(reassignedNode.user?.name).toBe(user.name); }); test("nodes can be expired", async () => { const client = await getRuntimeClient(version); - await client.expireNode(workingNodeId); + await client.nodes.expire(workingNodeId); - const expiredNode = await client.getNode(workingNodeId); + const expiredNode = await client.nodes.get(workingNodeId); expect(expiredNode).toBeDefined(); expect(expiredNode.expiry).toBeDefined(); }); test("nodes can be deleted", async () => { const client = await getRuntimeClient(version); - await client.deleteNode(workingNodeId); + await client.nodes.delete(workingNodeId); - const nodes = await client.getNodes(); + const nodes = await client.nodes.list(); const node = nodes.find((n) => n.id === workingNodeId); expect(node).toBeUndefined(); }); diff --git a/tests/integration/api/pre-auth-keys.test.ts b/tests/integration/api/pre-auth-keys.test.ts index 7ce5d0c..86ebd67 100644 --- a/tests/integration/api/pre-auth-keys.test.ts +++ b/tests/integration/api/pre-auth-keys.test.ts @@ -1,16 +1,22 @@ import { describe, expect, test } from "vitest"; -import { getBootstrapClient, getIsAtLeast, getRuntimeClient, HS_VERSIONS } from "../setup/env"; +import { getBootstrapClient, getRuntimeClient, HS_VERSIONS } from "../setup/env"; describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => { test("pre-auth keys can be created", async () => { const client = await getRuntimeClient(version); - const preAuthKeyUser = await client.createUser("preauthkeyuser@"); + const preAuthKeyUser = await client.users.create({ name: "preauthkeyuser@" }); expect(preAuthKeyUser).toBeDefined(); expect(preAuthKeyUser.name).toBe("preauthkeyuser@"); const expiry = new Date(Date.now() + 3600 * 1000); - const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, false, false, expiry, null); + const preAuthKey = await client.preAuthKeys.create({ + user: preAuthKeyUser.id, + ephemeral: false, + reusable: false, + expiration: expiry, + aclTags: null, + }); expect(preAuthKey).toBeDefined(); expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id); @@ -22,12 +28,18 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => test("pre-auth keys can be created with ACL tags", async () => { const client = await getRuntimeClient(version); - const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@"); + const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" }); expect(preAuthKeyUser).toBeDefined(); expect(preAuthKeyUser.name).toBe("preauthkeyuser@"); const aclTags = ["tag:test1", "tag:test2"]; - const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, true, true, null, aclTags); + const preAuthKey = await client.preAuthKeys.create({ + user: preAuthKeyUser.id, + ephemeral: true, + reusable: true, + expiration: null, + aclTags, + }); expect(preAuthKey).toBeDefined(); expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id); @@ -38,13 +50,19 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => test("tag-only pre-auth keys (0.28+)", async (context) => { const bootstrap = await getBootstrapClient(version); - if (!bootstrap.clientHelpers.isAtleast("0.28.0")) { + if (!bootstrap.capabilities.preAuthKeysHaveStableIds) { context.skip(); } const client = await getRuntimeClient(version); const aclTags = ["tag:server", "tag:prod"]; - const preAuthKey = await client.createPreAuthKey(null, false, true, null, aclTags); + const preAuthKey = await client.preAuthKeys.create({ + user: null, + ephemeral: false, + reusable: true, + expiration: null, + aclTags, + }); expect(preAuthKey).toBeDefined(); expect(preAuthKey.user).toBeNull(); @@ -55,44 +73,44 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => test("pre-auth keys can be listed", async () => { const client = await getRuntimeClient(version); - const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@"); + const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" }); expect(preAuthKeyUser).toBeDefined(); expect(preAuthKeyUser.name).toBe("preauthkeyuser@"); - const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id); + const preAuthKeys = await client.preAuthKeys.listForUser(preAuthKeyUser.id); expect(Array.isArray(preAuthKeys)).toBe(true); expect(preAuthKeys.length).toBeGreaterThanOrEqual(2); }); test("all pre-auth keys can be listed without user filter (0.28+)", async (context) => { - const isAtLeast = await getIsAtLeast(version); - if (!isAtLeast("0.28.0")) { + const bootstrap = await getBootstrapClient(version); + if (!bootstrap.capabilities.preAuthKeysHaveStableIds) { context.skip(); } const client = await getRuntimeClient(version); - const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@"); + const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" }); expect(preAuthKeyUser).toBeDefined(); - const allKeys = await client.getAllPreAuthKeys(); + const allKeys = await client.preAuthKeys.listAll!(); expect(Array.isArray(allKeys)).toBe(true); expect(allKeys.length).toBeGreaterThanOrEqual(2); - const userSpecificKeys = await client.getPreAuthKeys(preAuthKeyUser.id); + const userSpecificKeys = await client.preAuthKeys.listForUser(preAuthKeyUser.id); for (const userKey of userSpecificKeys) { const found = allKeys.find((k) => k.key === userKey.key); expect(found).toBeDefined(); } }); - test("getAllPreAuthKeys returns keys with correct structure (0.28+)", async (context) => { - const isAtLeast = await getIsAtLeast(version); - if (!isAtLeast("0.28.0")) { + test("listAll returns keys with correct structure (0.28+)", async (context) => { + const bootstrap = await getBootstrapClient(version); + if (!bootstrap.capabilities.preAuthKeysHaveStableIds) { context.skip(); } const client = await getRuntimeClient(version); - const allKeys = await client.getAllPreAuthKeys(); + const allKeys = await client.preAuthKeys.listAll!(); for (const key of allKeys) { expect(key.id).toBeDefined(); @@ -107,16 +125,16 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => test("pre-auth keys can be expired", async () => { const client = await getRuntimeClient(version); - const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@"); + const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" }); expect(preAuthKeyUser).toBeDefined(); expect(preAuthKeyUser.name).toBe("preauthkeyuser@"); - const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id); + const preAuthKeys = await client.preAuthKeys.listForUser(preAuthKeyUser.id); expect(preAuthKeys.length).toBeGreaterThanOrEqual(2); const preAuthKeyToExpire = preAuthKeys[0]; - await client.expirePreAuthKey(preAuthKeyUser.id, preAuthKeyToExpire); + await client.preAuthKeys.expire(preAuthKeyToExpire); - const preAuthKeysAfterExpire = await client.getPreAuthKeys(preAuthKeyUser.id); + const preAuthKeysAfterExpire = await client.preAuthKeys.listForUser(preAuthKeyUser.id); const expiredKey = preAuthKeysAfterExpire.find((key) => key.key === preAuthKeyToExpire.key); expect(expiredKey).toBeDefined(); }); diff --git a/tests/integration/api/users.test.ts b/tests/integration/api/users.test.ts index 8b1e2b8..18b33b6 100644 --- a/tests/integration/api/users.test.ts +++ b/tests/integration/api/users.test.ts @@ -5,19 +5,19 @@ import { getRuntimeClient, HS_VERSIONS } from "../setup/env"; describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { test("users can be created", async () => { const client = await getRuntimeClient(version); - const user = await client.createUser("tale@"); + const user = await client.users.create({ name: "tale@" }); expect(user).toBeDefined(); expect(user.name).toBe("tale@"); }); test("users can be created with attributes", async () => { const client = await getRuntimeClient(version); - const user = await client.createUser( - "test-user@", - "test-user@example.com", - "Test User", - "https://github.com/tale.png", - ); + const user = await client.users.create({ + name: "test-user@", + email: "test-user@example.com", + displayName: "Test User", + pictureUrl: "https://github.com/tale.png", + }); expect(user).toBeDefined(); expect(user.name).toBe("test-user@"); @@ -28,14 +28,14 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { test("users can be listed", async () => { const client = await getRuntimeClient(version); - const users = await client.getUsers(); + const users = await client.users.list(); expect(Array.isArray(users)).toBe(true); expect(users.length).toBeGreaterThanOrEqual(2); }); test("users can be listed by name", async () => { const client = await getRuntimeClient(version); - const users = await client.getUsers(undefined, "tale@"); + const users = await client.users.list({ name: "tale@" }); expect(Array.isArray(users)).toBe(true); expect(users.length).toBe(1); expect(users[0].name).toBe("tale@"); @@ -43,7 +43,7 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { test("users can be listed by email", async () => { const client = await getRuntimeClient(version); - const users = await client.getUsers(undefined, undefined, "test-user@example.com"); + const users = await client.users.list({ email: "test-user@example.com" }); expect(Array.isArray(users)).toBe(true); expect(users.length).toBe(1); expect(users[0].email).toBe("test-user@example.com"); @@ -51,24 +51,24 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { test("users can be renamed", async () => { const client = await getRuntimeClient(version); - const usersBefore = await client.getUsers(undefined, "tale@"); + const usersBefore = await client.users.list({ name: "tale@" }); expect(usersBefore.length).toBe(1); const user = usersBefore[0]; - await client.renameUser(user.id, "renamed-user@"); - const usersAfter = await client.getUsers(undefined, "renamed-user@"); + await client.users.rename(user.id, "renamed-user@"); + const usersAfter = await client.users.list({ name: "renamed-user@" }); expect(usersAfter.length).toBe(1); expect(usersAfter[0].id).toBe(user.id); }); test("users can be deleted", async () => { const client = await getRuntimeClient(version); - const usersBefore = await client.getUsers(undefined, "test-user@"); + const usersBefore = await client.users.list({ name: "test-user@" }); expect(usersBefore.length).toBe(1); const user = usersBefore[0]; - await client.deleteUser(user.id); - const usersAfter = await client.getUsers(undefined, "test-user@"); + await client.users.delete(user.id); + const usersAfter = await client.users.list({ name: "test-user@" }); expect(usersAfter.length).toBe(0); }); }); diff --git a/tests/integration/api/versions.test.ts b/tests/integration/api/versions.test.ts index a9c53c9..20dc2de 100644 --- a/tests/integration/api/versions.test.ts +++ b/tests/integration/api/versions.test.ts @@ -1,31 +1,36 @@ import { describe, expect, test } from "vitest"; -import canonicals from "~/openapi-canonical-families.json"; +import { gte } from "~/server/headscale/api/server-version"; import { getBootstrapClient, getRuntimeClient, HS_VERSIONS, Version } from "../setup/env"; -function getCanonicalVersion(version: Version) { - const canonical = Object.entries(canonicals).find(([_, family]) => - family.includes(version), - )?.[0] as Version | undefined; - - if (!canonical) { - return version; - } - - return canonical; -} - describe.for(HS_VERSIONS)("Headscale %s: Runtime Client", (version) => { test("the runtime client is usable", async () => { const bootstrapper = await getBootstrapClient(version); - const runtimeClient = bootstrapper.getRuntimeClient("test-api-key"); + const runtimeClient = bootstrapper.client("test-api-key"); expect(runtimeClient).toBeDefined(); }); - test("the runtime client has the correct canonical API version", async () => { + test("the server version reported by /version matches the running container", async () => { const bootstrapper = await getBootstrapClient(version); - expect(bootstrapper.apiVersion).toBe(getCanonicalVersion(version)); + expect(bootstrapper.version.unknown).toBe(false); + const reported = `${bootstrapper.version.major}.${bootstrapper.version.minor}.${bootstrapper.version.patch}`; + expect(reported).toBe(version); + }); + + test("capabilities are derived correctly from the detected version", async (context) => { + const bootstrapper = await getBootstrapClient(version); + const v = bootstrapper.version; + expect(bootstrapper.capabilities.preAuthKeysHaveStableIds).toBe(gte(v, "0.28.0")); + expect(bootstrapper.capabilities.nodeTagsAreFlat).toBe(gte(v, "0.28.0")); + expect(bootstrapper.capabilities.nodeOwnerIsImmutable).toBe(gte(v, "0.28.0")); + expect(bootstrapper.capabilities.policyErrorsUseModernFormat).toBe(gte(v, "0.27.0")); + // The known version table only has 0.26+; if a future version is added + // before this test is updated, surface that explicitly rather than passing. + const known: Version[] = ["0.26.1", "0.27.0", "0.27.1", "0.28.0"]; + if (!known.includes(version)) { + context.skip(); + } }); test("the health check endpoint works", async () => { diff --git a/tests/integration/setup/env.ts b/tests/integration/setup/env.ts index d3d3693..7842983 100644 --- a/tests/integration/setup/env.ts +++ b/tests/integration/setup/env.ts @@ -1,16 +1,18 @@ -import hashes from "~/openapi-operation-hashes.json"; -import { createHeadscaleInterface, type HeadscaleApiInterface } from "~/server/headscale/api"; +import { createHeadscale, type Headscale } from "~/server/headscale/api"; import { type HeadscaleEnv, startHeadscale } from "./start-headscale"; import { startTailscaleNode, TailscaleNodeEnv } from "./start-tailscale"; -export type Version = keyof typeof hashes; -export const HS_VERSIONS = Object.keys(hashes) as Version[]; +// The set of Headscale versions integration tests run against. Listed +// explicitly (rather than derived from a generated manifest) so the +// supported version matrix lives next to the code that uses it. +export const HS_VERSIONS = ["0.26.1", "0.27.0", "0.27.1", "0.28.0"] as const; +export type Version = (typeof HS_VERSIONS)[number]; interface VersionStateEntry { env: HeadscaleEnv; tailscaleNode: TailscaleNodeEnv; - bootstrap: HeadscaleApiInterface; + bootstrap: Headscale; } const versionState = new Map(); @@ -21,7 +23,7 @@ async function ensureVersion(version: Version) { const env = await startHeadscale(version); const tailscaleNode = await startTailscaleNode(version, env.container.getMappedPort(8080)); - const bootstrap = await createHeadscaleInterface(env.apiUrl); + const bootstrap = await createHeadscale({ url: env.apiUrl }); const entry = { env, tailscaleNode, bootstrap }; versionState.set(version, entry); @@ -35,12 +37,7 @@ export async function getBootstrapClient(version: Version) { export async function getRuntimeClient(version: Version) { const { env, bootstrap } = await ensureVersion(version); - return bootstrap.getRuntimeClient(env.apiKey); -} - -export async function getIsAtLeast(version: Version) { - const { env, bootstrap } = await ensureVersion(version); - return bootstrap.clientHelpers.isAtleast; + return bootstrap.client(env.apiKey); } export async function getNode(version: Version) { @@ -52,7 +49,9 @@ export async function getNode(version: Version) { } export async function stopAllVersions() { - for (const { env, tailscaleNode } of versionState.values()) { + for (const { env, tailscaleNode, bootstrap } of versionState.values()) { + await bootstrap.dispose(); + await env.container.stop({ remove: true, removeVolumes: true, diff --git a/tests/integration/setup/start-tailscale.ts b/tests/integration/setup/start-tailscale.ts index 3370315..ca8952e 100644 --- a/tests/integration/setup/start-tailscale.ts +++ b/tests/integration/setup/start-tailscale.ts @@ -2,9 +2,9 @@ import { createInterface } from "node:readline"; import tc from "testcontainers"; -import hashes from "~/openapi-operation-hashes.json"; - -export type Version = keyof typeof hashes; +// Imported by `env.ts` which owns the canonical version list; keep this +// loose to avoid an import cycle. +export type Version = string; export interface TailscaleNodeEnv { container: tc.StartedTestContainer; diff --git a/tests/unit/auth/login-action.test.ts b/tests/unit/auth/login-action.test.ts index e38dbcb..c86b37c 100644 --- a/tests/unit/auth/login-action.test.ts +++ b/tests/unit/auth/login-action.test.ts @@ -43,7 +43,7 @@ describe("Login action validation", () => { const request = mockRequest(formData); const mockContext = { - hsApi: { getRuntimeClient: vi.fn() }, + headscale: { client: vi.fn() }, sessions: { createSession: vi.fn() }, }; @@ -63,7 +63,7 @@ describe("Login action validation", () => { const request = mockRequest(formData); const mockContext = { - hsApi: { getRuntimeClient: vi.fn() }, + headscale: { client: vi.fn() }, sessions: { createSession: vi.fn() }, }; @@ -87,8 +87,8 @@ describe("Login action validation", () => { .mockResolvedValue([{ prefix: "other-prefix", expiration: "2030-01-01T00:00:00Z" }]); const mockContext = { - hsApi: { - getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }), + headscale: { + client: () => ({ apiKeys: { list: mockGetApiKeys } }), }, sessions: { createSession: vi.fn() }, }; @@ -114,8 +114,8 @@ describe("Login action validation", () => { .mockResolvedValue([{ prefix: "expired-key-prefix", expiration: "2020-01-01T00:00:00Z" }]); const mockContext = { - hsApi: { - getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }), + headscale: { + client: () => ({ apiKeys: { list: mockGetApiKeys } }), }, sessions: { createSession: vi.fn() }, }; @@ -141,8 +141,8 @@ describe("Login action validation", () => { .mockResolvedValue([{ prefix: "malformed-key", expiration: null } as MockApiKey]); const mockContext = { - hsApi: { - getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }), + headscale: { + client: () => ({ apiKeys: { list: mockGetApiKeys } }), }, sessions: { createSession: vi.fn() }, }; @@ -173,8 +173,8 @@ describe("Login action validation", () => { const mockCreateSession = vi.fn().mockResolvedValue("session-cookie"); const mockContext = { - hsApi: { - getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }), + headscale: { + client: () => ({ apiKeys: { list: mockGetApiKeys } }), }, sessions: { createSession: mockCreateSession }, }; diff --git a/tests/unit/headscale/api-paths.test.ts b/tests/unit/headscale/api-paths.test.ts index 4fed422..8771b81 100644 --- a/tests/unit/headscale/api-paths.test.ts +++ b/tests/unit/headscale/api-paths.test.ts @@ -1,65 +1,59 @@ import { describe, expect, test } from "vitest"; -import type { HeadscaleApiInterface } from "~/server/headscale/api"; -import nodeEndpoints from "~/server/headscale/api/endpoints/nodes"; -import userEndpoints from "~/server/headscale/api/endpoints/users"; +import { capabilitiesFor } from "~/server/headscale/api/capabilities"; +import { makeNodeApi } from "~/server/headscale/api/resources/nodes"; +import { makeUserApi } from "~/server/headscale/api/resources/users"; +import { parseServerVersion } from "~/server/headscale/api/server-version"; +import type { Transport, TransportRequest } from "~/server/headscale/api/transport"; -type ApiFetchArgs = Parameters; - -function createApiClientRecorder() { - const calls: Array<{ - method: ApiFetchArgs[0]; - apiPath: ApiFetchArgs[1]; - apiKey: ApiFetchArgs[2]; - bodyOrQuery: ApiFetchArgs[3]; - }> = []; - - const client = { - isAtleast: () => false, - rawFetch: async () => { - throw new Error("rawFetch should not be called"); - }, - apiFetch: async ( - method: ApiFetchArgs[0], - apiPath: ApiFetchArgs[1], - apiKey: ApiFetchArgs[2], - bodyOrQuery?: ApiFetchArgs[3], - ) => { - calls.push({ method, apiPath, apiKey, bodyOrQuery }); +function createTransportRecorder() { + const calls: TransportRequest[] = []; + const transport: Transport = { + async request(opts: TransportRequest): Promise { + calls.push(opts); return undefined as T; }, - } as HeadscaleApiInterface["clientHelpers"]; - - return { calls, client }; + async getPublic(): Promise { + throw new Error("getPublic should not be called"); + }, + async health() { + throw new Error("health should not be called"); + }, + async dispose() {}, + }; + return { calls, transport }; } +const capabilities = capabilitiesFor(parseServerVersion("0.28.0")); + describe("Headscale API path encoding", () => { test("encodes node rename names as a single URL segment", async () => { - const { calls, client } = createApiClientRecorder(); + const { calls, transport } = createTransportRecorder(); - await nodeEndpoints(client, "api-key").renameNode("2", "../../1/expire"); + await makeNodeApi(transport, capabilities, "api-key").rename("2", "../../1/expire"); expect(calls).toEqual([ { method: "POST", - apiPath: "v1/node/2/rename/..%2F..%2F1%2Fexpire", + path: "v1/node/2/rename/..%2F..%2F1%2Fexpire", apiKey: "api-key", - bodyOrQuery: undefined, }, ]); }); test("encodes user rename names as a single URL segment", async () => { - const { calls, client } = createApiClientRecorder(); + const { calls, transport } = createTransportRecorder(); - await userEndpoints(client, "api-key").renameUser("3", "../../../user/4/rename/pwned-user@"); + await makeUserApi(transport, capabilities, "api-key").rename( + "3", + "../../../user/4/rename/pwned-user@", + ); expect(calls).toEqual([ { method: "POST", - apiPath: "v1/user/3/rename/..%2F..%2F..%2Fuser%2F4%2Frename%2Fpwned-user%40", + path: "v1/user/3/rename/..%2F..%2F..%2Fuser%2F4%2Frename%2Fpwned-user%40", apiKey: "api-key", - bodyOrQuery: undefined, }, ]); }); diff --git a/tests/unit/headscale/server-version.test.ts b/tests/unit/headscale/server-version.test.ts new file mode 100644 index 0000000..21f5973 --- /dev/null +++ b/tests/unit/headscale/server-version.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "vitest"; + +import { capabilitiesFor } from "~/server/headscale/api/capabilities"; +import { + formatServerVersion, + gte, + parseServerVersion, +} from "~/server/headscale/api/server-version"; + +describe("parseServerVersion", () => { + test("parses release versions with and without a leading v", () => { + expect(parseServerVersion("0.28.0")).toMatchObject({ + major: 0, + minor: 28, + patch: 0, + prerelease: undefined, + unknown: false, + }); + expect(parseServerVersion("v0.27.1")).toMatchObject({ + major: 0, + minor: 27, + patch: 1, + unknown: false, + }); + }); + + test("captures prerelease and build metadata", () => { + const v = parseServerVersion("v0.28.0-beta.1+abcdef"); + expect(v.major).toBe(0); + expect(v.minor).toBe(28); + expect(v.patch).toBe(0); + expect(v.prerelease).toBe("beta.1"); + expect(v.build).toBe("abcdef"); + expect(v.unknown).toBe(false); + }); + + test("flags unparseable versions (e.g. dev builds)", () => { + const v = parseServerVersion("dev"); + expect(v.unknown).toBe(true); + expect(v.raw).toBe("dev"); + }); +}); + +describe("gte", () => { + test("compares plain versions correctly", () => { + const v = parseServerVersion("0.28.0"); + expect(gte(v, "0.27.0")).toBe(true); + expect(gte(v, "0.28.0")).toBe(true); + expect(gte(v, "0.29.0")).toBe(false); + }); + + test("ignores prerelease tags so betas get modern capabilities", () => { + // 0.28.0-beta.1 ships every wire format change that 0.28.0 does, so + // strict semver (where 0.28.0-beta.1 < 0.28.0) would lock beta users + // out of features their server actually supports. + const beta = parseServerVersion("0.28.0-beta.1"); + expect(gte(beta, "0.28.0")).toBe(true); + expect(gte(beta, "0.27.0")).toBe(true); + }); + + test("unknown versions are treated as the newest known release", () => { + const dev = parseServerVersion("dev"); + expect(gte(dev, "9.9.9")).toBe(true); + }); +}); + +describe("formatServerVersion", () => { + test("renders release and prerelease versions without a leading v", () => { + expect(formatServerVersion(parseServerVersion("v0.28.0"))).toBe("0.28.0"); + expect(formatServerVersion(parseServerVersion("0.28.0-beta.1"))).toBe("0.28.0-beta.1"); + }); + + test("falls back to the raw string for unknown versions", () => { + expect(formatServerVersion(parseServerVersion("dev"))).toBe("dev"); + }); +}); + +describe("capabilitiesFor", () => { + test("0.28.0 enables every modern flag", () => { + const caps = capabilitiesFor(parseServerVersion("0.28.0")); + expect(caps).toEqual({ + preAuthKeysHaveStableIds: true, + nodeTagsAreFlat: true, + nodeOwnerIsImmutable: true, + policyErrorsUseModernFormat: true, + }); + }); + + test("0.28.0-beta.1 matches 0.28.0 capabilities (prerelease gating)", () => { + expect(capabilitiesFor(parseServerVersion("0.28.0-beta.1"))).toEqual( + capabilitiesFor(parseServerVersion("0.28.0")), + ); + }); + + test("0.27.1 only has the policy-error format flag", () => { + const caps = capabilitiesFor(parseServerVersion("0.27.1")); + expect(caps.preAuthKeysHaveStableIds).toBe(false); + expect(caps.nodeTagsAreFlat).toBe(false); + expect(caps.nodeOwnerIsImmutable).toBe(false); + expect(caps.policyErrorsUseModernFormat).toBe(true); + }); + + test("0.26.1 has none of the modern capabilities", () => { + const caps = capabilitiesFor(parseServerVersion("0.26.1")); + expect(caps.preAuthKeysHaveStableIds).toBe(false); + expect(caps.nodeTagsAreFlat).toBe(false); + expect(caps.nodeOwnerIsImmutable).toBe(false); + expect(caps.policyErrorsUseModernFormat).toBe(false); + }); +});