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.
This commit is contained in:
Aarnav Tale
2026-05-23 19:59:03 -04:00
parent d4eee702e9
commit 0512565f8e
59 changed files with 1123 additions and 1573 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const isHealthy = await api.isHealthy(); const isHealthy = await api.isHealthy();
if (isHealthy) { if (isHealthy) {
try { try {
await api.getApiKeys(); await api.apiKeys.list();
} catch (error) { } catch (error) {
if (isDataUnauthorizedError(error)) { if (isDataUnauthorizedError(error)) {
const displayName = const displayName =
-6
View File
@@ -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"]
}
-135
View File
@@ -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"
}
}
+2 -2
View File
@@ -28,7 +28,7 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
const { api } = await context.apiForRequest(request); const { api } = await context.apiForRequest(request);
try { try {
const { policy, updatedAt } = await api.setPolicy(policyData); const { policy, updatedAt } = await api.policy.set(policyData);
return data({ return data({
success: true, success: true,
error: undefined, 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 // Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
// we need to reference other error messages based on API version. // 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:")) { if (message.includes("parsing HuJSON:")) {
const cutIndex = message.indexOf("parsing HuJSON:"); const cutIndex = message.indexOf("parsing HuJSON:");
const trimmed = const trimmed =
+1 -1
View File
@@ -31,7 +31,7 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
// Try to load the ACL policy from the API. // Try to load the ACL policy from the API.
const { api } = await context.apiForRequest(request); const { api } = await context.apiForRequest(request);
try { try {
const { policy, updatedAt } = await api.getPolicy(); const { policy, updatedAt } = await api.policy.get();
flags.writable = updatedAt !== null; flags.writable = updatedAt !== null;
flags.policy = policy; flags.policy = policy;
return flags; return flags;
+2 -2
View File
@@ -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 { 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 // We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key // be authenticated correctly without an API key
+2 -2
View File
@@ -56,8 +56,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}); });
try { try {
const hsApi = context.hsApi.getRuntimeClient(context.headscaleApiKey!); const hsApi = context.headscale.client(context.headscaleApiKey!);
const hsUsers = await hsApi.getUsers(); const hsUsers = await hsApi.users.list();
const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email); const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
if (hsUser) { if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id); await context.auth.linkHeadscaleUser(userId, hsUser.id);
+1 -1
View File
@@ -17,7 +17,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
} }
// We only need it for health checks which don't require auth // 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 formData = await request.formData();
const action = formData.get("action_id")?.toString(); const action = formData.get("action_id")?.toString();
+13 -8
View File
@@ -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); await context.hsLive.refresh(nodesResource, api);
return redirect(`/machines/${node.id}`); 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) { if (!node) {
throw data(`Machine with ID ${nodeId} not found`, { throw data(`Machine with ID ${nodeId} not found`, {
status: 404, status: 404,
@@ -76,19 +76,19 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
} }
const name = String(formData.get("name")); const name = String(formData.get("name"));
await api.renameNode(nodeId, name); await api.nodes.rename(nodeId, name);
await context.hsLive.refresh(nodesResource, api); await context.hsLive.refresh(nodesResource, api);
return { message: "Machine renamed" }; return { message: "Machine renamed" };
} }
case "delete": { case "delete": {
await api.deleteNode(nodeId); await api.nodes.delete(nodeId);
await context.hsLive.refresh(nodesResource, api); await context.hsLive.refresh(nodesResource, api);
return redirect("/machines"); return redirect("/machines");
} }
case "expire": { case "expire": {
await api.expireNode(nodeId); await api.nodes.expire(nodeId);
await context.hsLive.refresh(nodesResource, api); await context.hsLive.refresh(nodesResource, api);
return { message: "Machine expired" }; return { message: "Machine expired" };
} }
@@ -102,7 +102,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
} }
try { try {
await api.setNodeTags( await api.nodes.setTags(
nodeId, nodeId,
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""), 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); await context.hsLive.refresh(nodesResource, api);
return { message: "Routes updated" }; 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); await context.hsLive.refresh(nodesResource, api);
return { message: "Machine reassigned" }; return { message: "Machine reassigned" };
} }
+1 -1
View File
@@ -53,7 +53,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
const lookup = await agents?.lookup([node.nodeKey]); const lookup = await agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup); const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].toSorted(); const tags = [...node.tags].toSorted();
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0"); const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync(); const agentSync = agents?.lastSync();
return { return {
+1 -1
View File
@@ -48,7 +48,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const agents = context.agents.state === "enabled" ? context.agents.value : undefined; const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const stats = await agents?.lookup(nodes.map((node) => node.nodeKey)); const stats = await agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats); const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0"); const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync(); const agentSync = agents?.lastSync();
return { return {
+12 -8
View File
@@ -20,7 +20,7 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
async function checkSelfServiceOwnership(userId: string) { async function checkSelfServiceOwnership(userId: string) {
if (canGenerateAny || !canGenerateOwn) return; if (canGenerateAny || !canGenerateOwn) return;
const [targetUser] = await api.getUsers(userId); const [targetUser] = await api.users.list({ id: userId });
if (!targetUser) { if (!targetUser) {
throw data("User not found.", { status: 404 }); throw data("User not found.", { status: 404 });
} }
@@ -84,13 +84,13 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
const date = new Date(); const date = new Date();
date.setDate(date.getDate() + day); date.setDate(date.getDate() + day);
const key = await api.createPreAuthKey( const key = await api.preAuthKeys.create({
user, user,
ephemeral === "on", ephemeral: ephemeral === "on",
reusable === "on", reusable: reusable === "on",
date, expiration: date,
aclTags.length > 0 ? aclTags : null, aclTags: aclTags.length > 0 ? aclTags : null,
); });
return data({ success: true as const, key: key.key }); 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 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"); return data("Pre-auth key expired");
} }
+7 -5
View File
@@ -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 // Try fetching all keys at once (Headscale 0.28+), fall back to per-user
let allKeys: PreAuthKey[] | null = null; let allKeys: PreAuthKey[] | null = null;
try { if (api.preAuthKeys.listAll) {
allKeys = await api.getAllPreAuthKeys(); try {
} catch { allKeys = await api.preAuthKeys.listAll();
// Older versions don't support this endpoint } catch {
// Treat any failure as "no global list available" and fall through.
}
} }
if (allKeys !== null) { if (allKeys !== null) {
@@ -65,7 +67,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.filter((u) => u.id?.length > 0) .filter((u) => u.id?.length > 0)
.map(async (user) => { .map(async (user) => {
try { try {
const preAuthKeys = await api.getPreAuthKeys(user.id); const preAuthKeys = await api.preAuthKeys.listForUser(user.id);
return { preAuthKeys, success: true as const, user }; return { preAuthKeys, success: true as const, user };
} catch (error) { } catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error); log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
+1 -1
View File
@@ -29,7 +29,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
} }
// We only need healthchecks which don't rely on an API key // 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) { switch (action) {
case "add_domain": { case "add_domain": {
const domain = formData.get("domain")?.toString()?.trim(); const domain = formData.get("domain")?.toString()?.trim();
+9 -9
View File
@@ -49,7 +49,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
const hostname = params.id; const hostname = params.id;
const username = new URL(request.url).searchParams.get("user") || undefined; 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); const node = nodes.find((n) => n.givenName === hostname);
if (!node) { if (!node) {
throw data(sshErrors.node_not_found(hostname), 404); 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 // 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); const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
if (!hsUser) { if (!hsUser) {
throw data(sshErrors.user_not_linked, 404); throw data(sshErrors.user_not_linked, 404);
} }
const preAuthKey = await api.createPreAuthKey( const preAuthKey = await api.preAuthKeys.create({
hsUser.id, user: hsUser.id,
true, ephemeral: true,
false, reusable: false,
new Date(Date.now() + 60 * 1000), // 1 minute expiry expiration: new Date(Date.now() + 60 * 1000), // 1 minute expiry
null, aclTags: null,
); });
const controlURL = context.config.headscale.public_url ?? context.config.headscale.url; const controlURL = context.config.headscale.public_url ?? context.config.headscale.url;
return { return {
+7 -7
View File
@@ -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); await context.hsLive.refresh(usersResource, api);
return { message: "User created successfully" }; 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); await context.hsLive.refresh(usersResource, api);
return { message: "User deleted successfully" }; return { message: "User deleted successfully" };
} }
@@ -60,7 +60,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400); 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); const user = users.find((user) => user.id === userId);
if (!user) { if (!user) {
throw data(`No user found with id: ${userId}`, { status: 400 }); 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); await context.hsLive.refresh(usersResource, api);
return { message: "User renamed successfully" }; 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); const user = users.find((user) => user.id === userId);
if (!user) { if (!user) {
throw data("Specified user not found", { 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 }); 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); const user = users.find((user) => user.id === userId);
if (!user) { if (!user) {
throw data("Specified user not found", { status: 400 }); 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); const user = users.find((user) => user.id === userId);
if (!user) { if (!user) {
throw data("Specified user not found", { status: 400 }); throw data("Specified user not found", { status: 400 });
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Route } from "./+types/healthz";
export async function loader({ context }: Route.LoaderArgs) { export async function loader({ context }: Route.LoaderArgs) {
// Use a fake API key for healthcheck // 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 healthy = await api.isHealthy();
return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), { return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), {
+2 -2
View File
@@ -35,13 +35,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
} }
// Use a fake API key for healthcheck // 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 healthy = await api.isHealthy();
const body = { const body = {
status: healthy ? "healthy" : "unhealthy", status: healthy ? "healthy" : "unhealthy",
headplane_version: __VERSION__, headplane_version: __VERSION__,
headscale_canonical_version: healthy ? context.hsApi.apiVersion : "unknown", headscale_canonical_version: healthy ? context.headscale.version.raw : "unknown",
internal_versions: { internal_versions: {
node: versions.node, node: versions.node,
v8: versions.v8, v8: versions.v8,
+1 -1
View File
@@ -63,7 +63,7 @@ constructs everything that needs to live for the lifetime of the
process: process:
- the SQLite client (`db`) - the SQLite client (`db`)
- the Headscale REST interface (`hsApi`) - the Headscale REST interface (`headscale`)
- the optional Headplane agent manager (`agents`) - the optional Headplane agent manager (`agents`)
- the auth service (`auth`) - the auth service (`auth`)
- the optional OIDC service (`oidc`) - the optional OIDC service (`oidc`)
+2 -2
View File
@@ -1,4 +1,4 @@
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints"; import type { HeadscaleClient } from "~/server/headscale/api";
export abstract class Integration<T> { export abstract class Integration<T> {
protected context: NonNullable<T>; protected context: NonNullable<T>;
@@ -11,6 +11,6 @@ export abstract class Integration<T> {
} }
abstract isAvailable(): Promise<boolean> | boolean; abstract isAvailable(): Promise<boolean> | boolean;
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void; abstract onConfigChange(client: HeadscaleClient): Promise<void> | void;
abstract get name(): string; abstract get name(): string;
} }
+2 -2
View File
@@ -4,7 +4,7 @@ import { setTimeout } from "node:timers/promises";
import { type } from "arktype"; import { type } from "arktype";
import { Client } from "undici"; 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 log from "~/utils/log";
import { Integration } from "./abstract"; import { Integration } from "./abstract";
@@ -255,7 +255,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
return this.client !== undefined && this.containerId !== undefined; return this.client !== undefined && this.containerId !== undefined;
} }
async onConfigChange(client: RuntimeApiClient) { async onConfigChange(client: HeadscaleClient) {
if (!this.client) { if (!this.client) {
return; return;
} }
+2 -2
View File
@@ -5,7 +5,7 @@ import { join } from "node:path";
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node"; import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
import { type } from "arktype"; 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 log from "~/utils/log";
import { Integration } from "./abstract"; import { Integration } from "./abstract";
@@ -154,7 +154,7 @@ export default class KubernetesIntegration extends Integration<typeof configSche
} }
} }
async onConfigChange(client: RuntimeApiClient) { async onConfigChange(client: HeadscaleClient) {
if (!this.pid) { if (!this.pid) {
return; return;
} }
+3 -3
View File
@@ -3,7 +3,7 @@ import { join } from "node:path";
import { kill } from "node:process"; import { kill } from "node:process";
import { setTimeout } from "node:timers/promises"; import { setTimeout } from "node:timers/promises";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints"; import type { HeadscaleClient } from "~/server/headscale/api";
import log from "~/utils/log"; import log from "~/utils/log";
/** /**
@@ -75,12 +75,12 @@ export interface SignalHeadscaleOptions {
/** /**
* Sends a signal to the headscale process and waits for it to become healthy. * Sends a signal to the headscale process and waits for it to become healthy.
* @param client The RuntimeApiClient to check health * @param client The HeadscaleClient to check health
* @param options Options for signaling and waiting * @param options Options for signaling and waiting
* @returns True if headscale became healthy, false otherwise * @returns True if headscale became healthy, false otherwise
*/ */
export async function signalAndWaitHealthy( export async function signalAndWaitHealthy(
client: RuntimeApiClient, client: HeadscaleClient,
options: SignalHeadscaleOptions, options: SignalHeadscaleOptions,
): Promise<boolean> { ): Promise<boolean> {
const { pid, signal = "SIGHUP", maxAttempts = 10, retryDelayMs = 1000 } = options; const { pid, signal = "SIGHUP", maxAttempts = 10, retryDelayMs = 1000 } = options;
+2 -2
View File
@@ -2,7 +2,7 @@ import { platform } from "node:os";
import { type } from "arktype"; 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 log from "~/utils/log";
import { Integration } from "./abstract"; import { Integration } from "./abstract";
@@ -51,7 +51,7 @@ export default class ProcIntegration extends Integration<typeof configSchema.ful
} }
} }
async onConfigChange(client: RuntimeApiClient) { async onConfigChange(client: HeadscaleClient) {
if (!this.pid) { if (!this.pid) {
return; return;
} }
+16 -13
View File
@@ -6,8 +6,7 @@ import type { HeadplaneConfig } from "./config/config-schema";
import { loadIntegration } from "./config/integration"; import { loadIntegration } from "./config/integration";
import { createDbClient } from "./db/client.server"; import { createDbClient } from "./db/client.server";
import { disabled, enabled, type Feature } from "./feature"; import { disabled, enabled, type Feature } from "./feature";
import { createHeadscaleInterface } from "./headscale/api"; import { createHeadscale, type HeadscaleClient } from "./headscale/api";
import type { RuntimeApiClient } from "./headscale/api/endpoints";
import { loadHeadscaleConfig } from "./headscale/config-loader"; import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store"; import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
import { type AgentManager, createAgentManager } from "./hp-agent"; import { type AgentManager, createAgentManager } from "./hp-agent";
@@ -22,10 +21,10 @@ declare module "react-router" {
export async function createAppContext(config: HeadplaneConfig) { export async function createAppContext(config: HeadplaneConfig) {
const db = await createDbClient(join(config.server.data_path, "hp_persist.db")); const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));
const hsApi = await createHeadscaleInterface( const headscale = await createHeadscale({
config.headscale.url, url: config.headscale.url,
config.headscale.tls_cert_path, certPath: config.headscale.tls_cert_path,
); });
// Resolve the Headscale API key: headscale.api_key takes precedence, // Resolve the Headscale API key: headscale.api_key takes precedence,
// falling back to the deprecated oidc.headscale_api_key for compatibility. // falling back to the deprecated oidc.headscale_api_key for compatibility.
@@ -33,8 +32,8 @@ export async function createAppContext(config: HeadplaneConfig) {
const agents = await buildAgents( const agents = await buildAgents(
config, config,
hsApi.clientHelpers.isAtleast("0.28.0"), headscale.capabilities.preAuthKeysHaveStableIds,
headscaleApiKey ? hsApi.getRuntimeClient(headscaleApiKey) : undefined, headscaleApiKey ? headscale.client(headscaleApiKey) : undefined,
db, db,
); );
@@ -61,17 +60,21 @@ export async function createAppContext(config: HeadplaneConfig) {
const integration = await loadIntegration(config.integration); const integration = await loadIntegration(config.integration);
// Disposers run in reverse-registration order on shutdown. // Disposers run in reverse-registration order on shutdown.
const disposers: Array<() => Promise<void> | void> = [() => auth.stop(), () => hsLive.dispose()]; const disposers: Array<() => Promise<void> | void> = [
() => auth.stop(),
() => hsLive.dispose(),
() => headscale.dispose(),
];
if (agents.state === "enabled") { if (agents.state === "enabled") {
disposers.push(() => agents.value.dispose()); disposers.push(() => agents.value.dispose());
} }
async function apiForRequest( async function apiForRequest(
request: Request, request: Request,
): Promise<{ principal: Principal; api: RuntimeApiClient }> { ): Promise<{ principal: Principal; api: HeadscaleClient }> {
const principal = await auth.require(request); const principal = await auth.require(request);
const apiKey = auth.getHeadscaleApiKey(principal); const apiKey = auth.getHeadscaleApiKey(principal);
return { principal, api: hsApi.getRuntimeClient(apiKey) }; return { principal, api: headscale.client(apiKey) };
} }
function startServices() { function startServices() {
@@ -91,7 +94,7 @@ export async function createAppContext(config: HeadplaneConfig) {
return { return {
config, config,
db, db,
hsApi, headscale,
headscaleApiKey, headscaleApiKey,
agents, agents,
auth, auth,
@@ -147,7 +150,7 @@ function buildOidc(
async function buildAgents( async function buildAgents(
config: HeadplaneConfig, config: HeadplaneConfig,
supportsTagOnlyKeys: boolean, supportsTagOnlyKeys: boolean,
apiClient: RuntimeApiClient | undefined, apiClient: HeadscaleClient | undefined,
db: Awaited<ReturnType<typeof createDbClient>>, db: Awaited<ReturnType<typeof createDbClient>>,
): Promise<Feature<AgentManager>> { ): Promise<Feature<AgentManager>> {
const agentConfig = config.integration?.agent; const agentConfig = config.integration?.agent;
+55
View File
@@ -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"),
};
}
@@ -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<Key[]>;
}
export default defineApiEndpoints<ApiKeyEndpoints>((client, apiKey) => ({
getApiKeys: async () => {
const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>("GET", "v1/apikey", apiKey);
return apiKeys;
},
}));
@@ -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<boolean>;
}
const healthcheckEndpoint = defineApiEndpoints<HealthcheckEndpoint>((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);
-171
View File
@@ -1,171 +0,0 @@
import type { Machine } from "~/types";
import type { HeadscaleApiInterface } from "..";
import { defineApiEndpoints } from "../factory";
interface RawMachine extends Omit<Machine, "tags"> {
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<Machine[]>;
/**
* 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<Machine>;
/**
* Deletes a specific node (machine) by its ID.
*
* @param id The ID of the node to delete.
*/
deleteNode(id: string): Promise<void>;
/**
* 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<Machine>;
/**
* 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<void>;
/**
* Expires a specific node (machine) by its ID.
*
* @param id The ID of the node to expire.
*/
expireNode(id: string): Promise<void>;
/**
* 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<void>;
/**
* 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<void>;
/**
* 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<void>;
}
export default defineApiEndpoints<NodeEndpoints>((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<void>("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<void>("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes });
},
expireNode: async (nodeId) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/expire`, apiKey);
},
renameNode: async (nodeId, newName) => {
await client.apiFetch<void>(
"POST",
`v1/node/${nodeId}/rename/${encodeURIComponent(newName)}`,
apiKey,
);
},
setNodeTags: async (nodeId, tags) => {
await client.apiFetch<void>("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<void>("POST", `v1/node/${nodeId}/user`, apiKey, {
user,
});
},
}));
@@ -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<PolicyEndpoints>((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) };
},
}));
@@ -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<PreAuthKey[]>;
/**
* 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<PreAuthKey[]>;
/**
* 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<PreAuthKey>;
/**
* 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<void>;
}
export default defineApiEndpoints<PreAuthKeyEndpoints>((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<string, unknown> = {
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<void>("POST", "v1/preauthkey/expire", apiKey, {
id: key.id,
});
return;
}
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
user,
key: key.key,
});
},
}));
@@ -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<User[]>;
/**
* 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<User>;
/**
* Deletes a specific user by its ID.
*
* @param id The ID of the user to delete.
*/
deleteUser(id: string): Promise<void>;
/**
* 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<void>;
}
export default defineApiEndpoints<UserEndpoints>((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<void>("DELETE", `v1/user/${id}`, apiKey);
},
renameUser: async (oldId, newName) => {
await client.apiFetch<void>(
"POST",
`v1/user/${oldId}/rename/${encodeURIComponent(newName)}`,
apiKey,
);
},
}));
-38
View File
@@ -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<ApiKeyEndpoints>({...})
*/
export interface EndpointFactory<T extends object> {
__type?: T;
(client: HeadscaleApiInterface["clientHelpers"], apiKey: string): T;
}
export function defineApiEndpoints<T extends object>(
factories: (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) => T,
): EndpointFactory<T> {
return factories;
}
export type ExtractApiEndpoints<F> = F extends EndpointFactory<infer T> ? T : never;
export type UnionToIntersection<U> = (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<T extends readonly EndpointFactory<any>[]>(
factories: T,
clientHelpers: any,
apiKey: string,
): UnionToIntersection<ExtractApiEndpoints<T[number]>> {
const instances = factories.map((f) => f(clientHelpers, apiKey));
return Object.assign({}, ...instances) as any;
}
-51
View File
@@ -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<DocumentHash> {
const spec = await dereference(doc);
const hashes: DocumentHash = {};
const seen = new Set<string>();
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;
}
+75 -311
View File
@@ -1,328 +1,92 @@
import { createHash } from "node:crypto"; // MARK: Headscale API
import { readFile } from "node:fs/promises"; //
// The public entry point for talking to a Headscale server. Boot
import { dereference } from "@readme/openapi-parser"; // detects the server version via `GET /version` (unauthenticated,
import type { OpenAPIV2 } from "openapi-types"; // available since Headscale 0.26.0), derives a typed `Capabilities`
import { data } from "react-router"; // object, and returns a `Headscale` value that constructs
import { Agent, type Dispatcher, request } from "undici"; // authenticated `HeadscaleClient`s on demand.
import log from "~/utils/log"; import log from "~/utils/log";
import endpointSets, { RuntimeApiClient } from "./endpoints"; import { type Capabilities, capabilitiesFor } from "./capabilities";
import { undiciToFriendlyError } from "./error"; import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys";
import { HeadscaleAPIError, isApiError } from "./error-client"; import { makeNodeApi, type NodeApi } from "./resources/nodes";
import { detectApiVersion, isAtLeast, type Version } from "./version"; 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";
/** export interface Headscale {
* A low-level composed interface for interacting with the Headscale API. readonly version: ServerVersion;
* This interface provides direct access to the underlying Undici agent readonly capabilities: Capabilities;
* and methods for making API requests. /** True if the Headscale server's `/health` endpoint returns 200. */
* health(): Promise<boolean>;
* It is also responsible for handling OpenAPI spec polling and hashing to /** Build an API client bound to a specific Headscale API key. */
* determine the implementations of API methods when requested for use. client(apiKey: string): HeadscaleClient;
*/ /** Stop background work and close the underlying HTTP agent. */
export interface HeadscaleApiInterface { dispose(): Promise<void>;
/**
* 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<string, string> | 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<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData>;
/**
* 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<T>(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T>;
};
} }
/** export interface HeadscaleClient {
* Creates a new Headscale API client interface. nodes: NodeApi;
* users: UserApi;
* @param baseUrl The base URL of the Headscale API. policy: PolicyApi;
* @param certPath Optional path to a custom TLS certificate for secure connections. preAuthKeys: PreAuthKeyApi;
* @returns A promise that resolves to a `HeadscaleApiClient` instance. apiKeys: ApiKeyApi;
*/
export async function createHeadscaleInterface(
baseUrl: string,
certPath?: string,
): Promise<HeadscaleApiInterface> {
const undiciAgent = await createUndiciAgent(certPath);
let openapiHashes: Record<string, string> | null = null;
let apiVersion: Version;
const rawFetch = async (
url: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData> => {
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 <T>(
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T> => {
let url = `/api/${apiPath}`;
const options: Partial<Dispatcher.RequestOptions> = {
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<string, unknown>;
} 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<T>;
};
/** /**
* Polls the OpenAPI spec endpoint and generates operation hashes. * Convenience passthrough to `Headscale.health()`. Headscale's
* This is used to determine which implementations of API methods to use. * `/health` endpoint is unauthenticated so callers that already
* * have a client (loaders/actions) don't need to also reach for
* @returns A promise that resolves to the OpenAPI operation hashes. * the top-level `Headscale` value.
*/ */
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> { isHealthy(): Promise<boolean>;
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;
}
const body = await res.body.json(); export interface CreateHeadscaleOptions {
const spec = await dereference(body as OpenAPIV2.Document); url: string;
const hashes = generateSpecHashes(spec); certPath?: string;
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);
}
return null; export async function createHeadscale(opts: CreateHeadscaleOptions): Promise<Headscale> {
} 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 makeHeadscale(transport, version, capabilities);
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
function makeHeadscale(
transport: Transport,
version: ServerVersion,
capabilities: Capabilities,
): Headscale {
return { return {
undiciAgent, version,
baseUrl, capabilities,
openapiHashes, health: () => transport.health(),
apiVersion, client(apiKey) {
getRuntimeClient: (apiKey: string) => { return {
return endpointSets( nodes: makeNodeApi(transport, capabilities, apiKey),
{ users: makeUserApi(transport, capabilities, apiKey),
rawFetch, policy: makePolicyApi(transport, capabilities, apiKey),
apiFetch, preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey),
isAtleast, apiKeys: makeApiKeyApi(transport, capabilities, apiKey),
}, isHealthy: () => transport.health(),
apiKey, };
);
},
clientHelpers: {
rawFetch,
apiFetch,
isAtleast,
}, },
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<Agent> {
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<string, string> = {};
const seen = new Set<string>();
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;
}
@@ -0,0 +1,25 @@
import type { Key } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface ApiKeyApi {
list(): Promise<Key[]>;
}
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;
},
};
}
+116
View File
@@ -0,0 +1,116 @@
import type { Machine } from "~/types";
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
interface RawMachine extends Omit<Machine, "tags"> {
tags?: string[];
forcedTags?: string[];
validTags?: string[];
invalidTags?: string[];
}
export interface NodeApi {
list(): Promise<Machine[]>;
get(id: string): Promise<Machine>;
delete(id: string): Promise<void>;
register(user: string, key: string): Promise<Machine>;
approveRoutes(id: string, routes: string[]): Promise<void>;
expire(id: string): Promise<void>;
rename(id: string, newName: string): Promise<void>;
setTags(id: string, tags: string[]): Promise<void>;
/**
* Reassign a node to a different user. Only present when
* `capabilities.nodeOwnerIsImmutable` is false (Headscale < 0.28).
*/
reassignUser?: (id: string, user: string) => Promise<void>;
}
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;
}
@@ -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) };
},
};
}
@@ -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<PreAuthKey[]>;
listForUser(userId: string): Promise<PreAuthKey[]>;
create(opts: CreatePreAuthKeyOptions): Promise<PreAuthKey>;
expire(key: PreAuthKey): Promise<void>;
}
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<string, unknown> = {
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;
}
@@ -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<User[]>;
create(opts: CreateUserOptions): Promise<User>;
delete(id: string): Promise<void>;
rename(id: string, newName: string): Promise<void>;
}
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,
});
},
};
}
@@ -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]),
};
}
+191
View File
@@ -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<string, unknown>;
/** Query parameters for GET/DELETE requests. */
query?: Record<string, unknown>;
}
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<T>(opts: TransportRequest): Promise<T>;
/**
* Send an unauthenticated GET against the server root
* (e.g. `/version`, `/health`). Returns parsed JSON.
*/
getPublic<T>(path: `/${string}`): Promise<T>;
/** True if `GET /health` returns 200. Never throws. */
health(): Promise<boolean>;
/** Shut down the underlying Undici agent. */
dispose(): Promise<void>;
}
export interface TransportOptions {
url: string;
certPath?: string;
}
export async function createTransport(opts: TransportOptions): Promise<Transport> {
const agent = await createUndiciAgent(opts.certPath);
const baseUrl = opts.url;
async function rawRequest(
url: string,
options: Partial<Dispatcher.RequestOptions> & { method: string },
): Promise<Dispatcher.ResponseData> {
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<T>({ method, path, apiKey, body, query }: TransportRequest): Promise<T> {
let url = `/api/${path}`;
const options: Partial<Dispatcher.RequestOptions> & { 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<string, unknown>;
} 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<T>;
},
async getPublic<T>(path: `/${string}`): Promise<T> {
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<string, unknown>;
} 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<T>;
},
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<Agent> {
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();
}
}
-86
View File
@@ -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<string, string> | 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<string, string>][]) {
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);
}
+10 -10
View File
@@ -1,6 +1,6 @@
import log from "~/utils/log"; 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. * Defines a resource that can be fetched and polled by the live store.
@@ -19,7 +19,7 @@ export interface ResourceDefinition<T> {
/** /**
* A callback to fire to get the latest data for this resource * A callback to fire to get the latest data for this resource
*/ */
readonly fetch: (client: RuntimeApiClient) => Promise<T>; readonly fetch: (client: HeadscaleClient) => Promise<T>;
} }
/** /**
@@ -37,12 +37,12 @@ export function defineResource<T>(
export const nodesResource = defineResource("nodes", { export const nodesResource = defineResource("nodes", {
pollInterval: 5_000, pollInterval: 5_000,
fetch: (api) => api.getNodes(), fetch: (api) => api.nodes.list(),
}); });
export const usersResource = defineResource("users", { export const usersResource = defineResource("users", {
pollInterval: 15_000, pollInterval: 15_000,
fetch: (api) => api.getUsers(), fetch: (api) => api.users.list(),
}); });
interface Snapshot<T> { interface Snapshot<T> {
@@ -54,8 +54,8 @@ interface Snapshot<T> {
type ChangeListener = (resourceKey: string, version: string) => void; type ChangeListener = (resourceKey: string, version: string) => void;
export interface LiveStore { export interface LiveStore {
get<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<Snapshot<T>>; get<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<Snapshot<T>>;
refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void>; refresh<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<void>;
getVersions(): Record<string, string>; getVersions(): Record<string, string>;
subscribe(listener: ChangeListener): () => void; subscribe(listener: ChangeListener): () => void;
dispose(): void; dispose(): void;
@@ -66,7 +66,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
const serializedCache = new Map<string, string>(); const serializedCache = new Map<string, string>();
const listeners = new Set<ChangeListener>(); const listeners = new Set<ChangeListener>();
const intervals = new Map<string, ReturnType<typeof setInterval>>(); const intervals = new Map<string, ReturnType<typeof setInterval>>();
let storedApiClient: RuntimeApiClient | undefined; let storedApiClient: HeadscaleClient | undefined;
let versionCounter = 0; let versionCounter = 0;
function notifyListeners(resourceKey: string, version: string) { function notifyListeners(resourceKey: string, version: string) {
@@ -77,7 +77,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
async function fetchResource( async function fetchResource(
resource: ResourceDefinition<unknown>, resource: ResourceDefinition<unknown>,
apiClient: RuntimeApiClient, apiClient: HeadscaleClient,
): Promise<void> { ): Promise<void> {
const data = await resource.fetch(apiClient); const data = await resource.fetch(apiClient);
const json = JSON.stringify(data); const json = JSON.stringify(data);
@@ -138,7 +138,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
return { return {
async get<T>( async get<T>(
resource: ResourceDefinition<T>, resource: ResourceDefinition<T>,
apiClient: RuntimeApiClient, apiClient: HeadscaleClient,
): Promise<Snapshot<T>> { ): Promise<Snapshot<T>> {
storedApiClient = apiClient; storedApiClient = apiClient;
const def = findResource(resource.key); const def = findResource(resource.key);
@@ -154,7 +154,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
return snapshots.get(resource.key) as Snapshot<T>; return snapshots.get(resource.key) as Snapshot<T>;
}, },
async refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void> { async refresh<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<void> {
storedApiClient = apiClient; storedApiClient = apiClient;
const def = findResource(resource.key); const def = findResource(resource.key);
if (!def) { if (!def) {
+12 -8
View File
@@ -11,7 +11,7 @@ import log from "~/utils/log";
import { HeadplaneConfig } from "./config/config-schema"; import { HeadplaneConfig } from "./config/config-schema";
import { hostInfo } from "./db/schema"; import { hostInfo } from "./db/schema";
import { RuntimeApiClient } from "./headscale/api/endpoints"; import type { HeadscaleClient } from "./headscale/api";
export interface AgentManager { export interface AgentManager {
lookup(nodeKeys: string[]): Promise<Record<string, HostInfo>>; lookup(nodeKeys: string[]): Promise<Record<string, HostInfo>>;
@@ -46,7 +46,7 @@ async function hasExistingState(workDir: string): Promise<boolean> {
export async function createAgentManager( export async function createAgentManager(
agentConfig: NonNullable<NonNullable<HeadplaneConfig["integration"]>["agent"]> | undefined, agentConfig: NonNullable<NonNullable<HeadplaneConfig["integration"]>["agent"]> | undefined,
headscaleUrl: string, headscaleUrl: string,
apiClient: RuntimeApiClient, apiClient: HeadscaleClient,
supportsTagOnlyKeys: boolean, supportsTagOnlyKeys: boolean,
db: NodeSQLiteDatabase, db: NodeSQLiteDatabase,
): Promise<AgentManager | undefined> { ): Promise<AgentManager | undefined> {
@@ -101,9 +101,13 @@ export async function createAgentManager(
async function generateAuthKey(): Promise<string> { async function generateAuthKey(): Promise<string> {
const expiration = new Date(Date.now() + 5 * 60_000); const expiration = new Date(Date.now() + 5 * 60_000);
const pak = await apiClient.createPreAuthKey(null, false, false, expiration, [ const pak = await apiClient.preAuthKeys.create({
`tag:${hostName}`, user: null,
]); ephemeral: false,
reusable: false,
expiration,
aclTags: [`tag:${hostName}`],
});
return pak.key; return pak.key;
} }
@@ -272,7 +276,7 @@ export async function createAgentManager(
*/ */
async function pruneStaleHostInfo() { async function pruneStaleHostInfo() {
try { try {
const nodes = await apiClient.getNodes(); const nodes = await apiClient.nodes.list();
const activeKeys = nodes.map((n) => n.nodeKey); const activeKeys = nodes.map((n) => n.nodeKey);
if (activeKeys.length === 0) { if (activeKeys.length === 0) {
@@ -298,11 +302,11 @@ export async function createAgentManager(
async function pruneEphemeralNodes() { async function pruneEphemeralNodes() {
try { try {
const nodes = await apiClient.getNodes(); const nodes = await apiClient.nodes.list();
const toPrune = nodes.filter((n) => n.preAuthKey?.ephemeral && !n.online); const toPrune = nodes.filter((n) => n.preAuthKey?.ephemeral && !n.online);
for (const node of toPrune) { 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); log.info("agent", "Pruned offline ephemeral node %s", node.givenName);
} }
} catch (error) { } catch (error) {
+2 -2
View File
@@ -171,7 +171,7 @@ export interface AppRuntime {
db: DbClient; db: DbClient;
auth: AuthService; auth: AuthService;
oidc?: OidcService; oidc?: OidcService;
hsApi: HeadscaleInterface; headscale: Headscale;
agents?: AgentManager; agents?: AgentManager;
stop(): Promise<void>; stop(): Promise<void>;
} }
@@ -300,7 +300,7 @@ function createTestRuntime(overrides: Partial<AppRuntime> = {}): AppRuntime {
config: testConfig, config: testConfig,
db: createTestDb(), db: createTestDb(),
auth: createTestAuth(), auth: createTestAuth(),
hsApi: createTestHsApi(), headscale: createTestHeadscale(),
stop: async () => {}, stop: async () => {},
...overrides, ...overrides,
}; };
-3
View File
@@ -15,7 +15,6 @@
"docs:dev": "vitepress dev docs", "docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs", "docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs", "docs:preview": "vitepress preview docs",
"gen:hashes": "tsx tests/generate-openapi-hashes.ts",
"lint": "oxlint", "lint": "oxlint",
"format": "oxfmt" "format": "oxfmt"
}, },
@@ -32,7 +31,6 @@
"@kubernetes/client-node": "^1.4.0", "@kubernetes/client-node": "^1.4.0",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@react-router/node": "^7.14.0", "@react-router/node": "^7.14.0",
"@readme/openapi-parser": "^6.0.1",
"@uiw/react-codemirror": "4.25.9", "@uiw/react-codemirror": "4.25.9",
"arktype": "^2.2.0", "arktype": "^2.2.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -42,7 +40,6 @@
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"mime": "^4.1.0", "mime": "^4.1.0",
"openapi-types": "^12.1.3",
"react": "19.2.5", "react": "19.2.5",
"react-codemirror-merge": "4.25.9", "react-codemirror-merge": "4.25.9",
"react-dom": "19.2.5", "react-dom": "19.2.5",
+1 -126
View File
@@ -47,9 +47,6 @@ importers:
'@react-router/node': '@react-router/node':
specifier: ^7.14.0 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) 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': '@uiw/react-codemirror':
specifier: 4.25.9 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) 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: mime:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
openapi-types:
specifier: ^12.1.3
version: 12.1.3
react: react:
specifier: 19.2.5 specifier: 19.2.5
version: 19.2.5 version: 19.2.5
@@ -180,12 +174,6 @@ importers:
packages: 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': '@ark/schema@0.56.0':
resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==}
@@ -998,10 +986,6 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true 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': '@iconify-json/simple-icons@1.2.48':
resolution: {integrity: sha512-EACOtZMoPJtERiAbX1De0asrrCtlwI27+03c9OJlYWsly9w1O5vcD8rTzh+kDPjo+K8FOVnq2Qy+h/CzljSKDA==} resolution: {integrity: sha512-EACOtZMoPJtERiAbX1De0asrrCtlwI27+03c9OJlYWsly9w1O5vcD8rTzh+kDPjo+K8FOVnq2Qy+h/CzljSKDA==}
@@ -1480,22 +1464,6 @@ packages:
typescript: typescript:
optional: true 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': '@remix-run/node-fetch-server@0.13.0':
resolution: {integrity: sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==} resolution: {integrity: sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==}
@@ -1844,9 +1812,6 @@ packages:
'@types/js-yaml@4.0.9': '@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/linkify-it@5.0.0': '@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
@@ -2116,17 +2081,6 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'} 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: ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2738,15 +2692,9 @@ packages:
exsolve@1.0.8: exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-fifo@1.3.2: fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -2963,9 +2911,6 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json5@2.2.3: json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2976,10 +2921,6 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
jsonpointer@5.0.1:
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
engines: {node: '>=0.10.0'}
jsonwebtoken@9.0.3: jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'} engines: {node: '>=12', npm: '>=6'}
@@ -3051,10 +2992,6 @@ packages:
resolution: {integrity: sha512-yB9IFWurFllusbPZqvG0EavTmpNXPya2MuO7Li7YT78xAj3uCQ3AgmW9TVUbTTsSMhsegbiAMRpwfEk2TP1P0A==} resolution: {integrity: sha512-yB9IFWurFllusbPZqvG0EavTmpNXPya2MuO7Li7YT78xAj3uCQ3AgmW9TVUbTTsSMhsegbiAMRpwfEk2TP1P0A==}
hasBin: true hasBin: true
leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
libsql@0.5.29: libsql@0.5.29:
resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==} resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
cpu: [x64, arm64, wasm32, arm] cpu: [x64, arm64, wasm32, arm]
@@ -3322,9 +3259,6 @@ packages:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
engines: {node: '>=18'} engines: {node: '>=18'}
openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
openid-client@6.8.2: openid-client@6.8.2:
resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==}
@@ -3507,10 +3441,6 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'} 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: reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
@@ -3873,6 +3803,7 @@ packages:
uuid@8.3.2: uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} 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 hasBin: true
valibot@1.3.1: valibot@1.3.1:
@@ -4132,11 +4063,6 @@ packages:
snapshots: 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': '@ark/schema@0.56.0':
dependencies: dependencies:
'@ark/util': 0.56.0 '@ark/util': 0.56.0
@@ -4888,8 +4814,6 @@ snapshots:
protobufjs: 7.5.4 protobufjs: 7.5.4
yargs: 17.7.2 yargs: 17.7.2
'@humanwhocodes/momoa@2.0.4': {}
'@iconify-json/simple-icons@1.2.48': '@iconify-json/simple-icons@1.2.48':
dependencies: dependencies:
'@iconify/types': 2.0.0 '@iconify/types': 2.0.0
@@ -5296,28 +5220,6 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 6.0.2 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': {} '@remix-run/node-fetch-server@0.13.0': {}
'@rolldown/binding-android-arm64@1.0.0-rc.15': '@rolldown/binding-android-arm64@1.0.0-rc.15':
@@ -5584,8 +5486,6 @@ snapshots:
'@types/js-yaml@4.0.9': {} '@types/js-yaml@4.0.9': {}
'@types/json-schema@7.0.15': {}
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2': '@types/markdown-it@14.1.2':
@@ -5880,17 +5780,6 @@ snapshots:
agent-base@7.1.4: {} 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@5.0.1: {}
ansi-regex@6.2.2: {} ansi-regex@6.2.2: {}
@@ -6459,12 +6348,8 @@ snapshots:
exsolve@1.0.8: {} exsolve@1.0.8: {}
fast-deep-equal@3.1.3: {}
fast-fifo@1.3.2: {} fast-fifo@1.3.2: {}
fast-uri@3.1.0: {}
fdir@6.5.0(picomatch@4.0.4): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
@@ -6673,8 +6558,6 @@ snapshots:
jsesc@3.0.2: {} jsesc@3.0.2: {}
json-schema-traverse@1.0.0: {}
json5@2.2.3: {} json5@2.2.3: {}
jsonpath-plus@10.3.0: jsonpath-plus@10.3.0:
@@ -6683,8 +6566,6 @@ snapshots:
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0) '@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
jsep: 1.4.0 jsep: 1.4.0
jsonpointer@5.0.1: {}
jsonwebtoken@9.0.3: jsonwebtoken@9.0.3:
dependencies: dependencies:
jws: 4.0.1 jws: 4.0.1
@@ -6758,8 +6639,6 @@ snapshots:
lefthook-windows-arm64: 2.1.5 lefthook-windows-arm64: 2.1.5
lefthook-windows-x64: 2.1.5 lefthook-windows-x64: 2.1.5
leven@3.1.0: {}
libsql@0.5.29: libsql@0.5.29:
dependencies: dependencies:
'@neon-rs/load': 0.0.4 '@neon-rs/load': 0.0.4
@@ -7001,8 +6880,6 @@ snapshots:
is-inside-container: 1.0.0 is-inside-container: 1.0.0
wsl-utils: 0.1.0 wsl-utils: 0.1.0
openapi-types@12.1.3: {}
openid-client@6.8.2: openid-client@6.8.2:
dependencies: dependencies:
jose: 6.2.2 jose: 6.2.2
@@ -7256,8 +7133,6 @@ snapshots:
require-directory@2.1.1: {} require-directory@2.1.1: {}
require-from-string@2.0.2: {}
reselect@5.1.1: {} reselect@5.1.1: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
-101
View File
@@ -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<string, string>])[],
) {
const canonicalizedFamilies: Record<string, string[]> = {};
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<string, Record<string, string>>) {
const path = resolve(cwd(), HASH_FILE_LOCATION);
await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, "utf-8");
}
async function writeCanonicalizedFamilies(families: Record<string, string[]>) {
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);
+1 -1
View File
@@ -5,7 +5,7 @@ import { getRuntimeClient, HS_VERSIONS } from "../setup/env";
describe.for(HS_VERSIONS)("Headscale %s: API Keys", (version) => { describe.for(HS_VERSIONS)("Headscale %s: API Keys", (version) => {
test("api keys can be fetched", async () => { test("api keys can be fetched", async () => {
const client = await getRuntimeClient(version); const client = await getRuntimeClient(version);
const apiKeys = await client.getApiKeys(); const apiKeys = await client.apiKeys.list();
expect(Array.isArray(apiKeys)).toBe(true); expect(Array.isArray(apiKeys)).toBe(true);
expect(apiKeys.length).toBe(1); expect(apiKeys.length).toBe(1);
}); });
+17 -15
View File
@@ -9,8 +9,8 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
const client = await getRuntimeClient(version); const client = await getRuntimeClient(version);
const tailnetNode = await getNode(version); const tailnetNode = await getNode(version);
const user = await client.createUser("node-reg@"); const user = await client.users.create({ name: "node-reg@" });
const node = await client.registerNode(user.name, tailnetNode.authCode); const node = await client.nodes.register(user.name, tailnetNode.authCode);
expect(node).toBeDefined(); expect(node).toBeDefined();
expect(node.registerMethod).toBe("REGISTER_METHOD_CLI"); expect(node.registerMethod).toBe("REGISTER_METHOD_CLI");
expect(node.name).toBe(tailnetNode.nodeName); 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 () => { test("nodes can be retrieved", async () => {
const client = await getRuntimeClient(version); const client = await getRuntimeClient(version);
const { nodeName } = await getNode(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); const node = nodes.find((n) => n.name === nodeName);
expect(node).toBeDefined(); expect(node).toBeDefined();
expect(node?.name).toBe(nodeName); 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).toBeDefined();
expect(fetchedNode.id).toBe(node!.id); expect(fetchedNode.id).toBe(node!.id);
workingNodeId = node!.id; workingNodeId = node!.id;
@@ -35,41 +35,43 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
const { nodeName } = await getNode(version); const { nodeName } = await getNode(version);
const newName = `${nodeName}-renamed`; const newName = `${nodeName}-renamed`;
await client.renameNode(workingNodeId, newName); await client.nodes.rename(workingNodeId, newName);
const renamedNode = await client.getNode(workingNodeId); const renamedNode = await client.nodes.get(workingNodeId);
expect(renamedNode).toBeDefined(); expect(renamedNode).toBeDefined();
expect(renamedNode.givenName).toBe(newName); expect(renamedNode.givenName).toBe(newName);
}); });
test("nodes can be reassigned to another user", async (context) => { test("nodes can be reassigned to another user", async (context) => {
const bootstrap = await getBootstrapClient(version); 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(); context.skip();
} }
const client = await getRuntimeClient(version); 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); // reassignUser is only defined on pre-0.28 clients, hence the guard above.
const reassignedNode = await client.getNode(workingNodeId); await client.nodes.reassignUser!(workingNodeId, user.id);
const reassignedNode = await client.nodes.get(workingNodeId);
expect(reassignedNode).toBeDefined(); expect(reassignedNode).toBeDefined();
expect(reassignedNode.user.name).toBe(user.name); expect(reassignedNode.user?.name).toBe(user.name);
}); });
test("nodes can be expired", async () => { test("nodes can be expired", async () => {
const client = await getRuntimeClient(version); 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).toBeDefined();
expect(expiredNode.expiry).toBeDefined(); expect(expiredNode.expiry).toBeDefined();
}); });
test("nodes can be deleted", async () => { test("nodes can be deleted", async () => {
const client = await getRuntimeClient(version); 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); const node = nodes.find((n) => n.id === workingNodeId);
expect(node).toBeUndefined(); expect(node).toBeUndefined();
}); });
+40 -22
View File
@@ -1,16 +1,22 @@
import { describe, expect, test } from "vitest"; 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) => { describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => {
test("pre-auth keys can be created", async () => { test("pre-auth keys can be created", async () => {
const client = await getRuntimeClient(version); const client = await getRuntimeClient(version);
const preAuthKeyUser = await client.createUser("preauthkeyuser@"); const preAuthKeyUser = await client.users.create({ name: "preauthkeyuser@" });
expect(preAuthKeyUser).toBeDefined(); expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@"); expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
const expiry = new Date(Date.now() + 3600 * 1000); 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).toBeDefined();
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id); 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 () => { test("pre-auth keys can be created with ACL tags", async () => {
const client = await getRuntimeClient(version); 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).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@"); expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
const aclTags = ["tag:test1", "tag:test2"]; 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).toBeDefined();
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id); 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) => { test("tag-only pre-auth keys (0.28+)", async (context) => {
const bootstrap = await getBootstrapClient(version); const bootstrap = await getBootstrapClient(version);
if (!bootstrap.clientHelpers.isAtleast("0.28.0")) { if (!bootstrap.capabilities.preAuthKeysHaveStableIds) {
context.skip(); context.skip();
} }
const client = await getRuntimeClient(version); const client = await getRuntimeClient(version);
const aclTags = ["tag:server", "tag:prod"]; 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).toBeDefined();
expect(preAuthKey.user).toBeNull(); 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 () => { test("pre-auth keys can be listed", async () => {
const client = await getRuntimeClient(version); 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).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@"); 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(Array.isArray(preAuthKeys)).toBe(true);
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2); expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
}); });
test("all pre-auth keys can be listed without user filter (0.28+)", async (context) => { test("all pre-auth keys can be listed without user filter (0.28+)", async (context) => {
const isAtLeast = await getIsAtLeast(version); const bootstrap = await getBootstrapClient(version);
if (!isAtLeast("0.28.0")) { if (!bootstrap.capabilities.preAuthKeysHaveStableIds) {
context.skip(); context.skip();
} }
const client = await getRuntimeClient(version); 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).toBeDefined();
const allKeys = await client.getAllPreAuthKeys(); const allKeys = await client.preAuthKeys.listAll!();
expect(Array.isArray(allKeys)).toBe(true); expect(Array.isArray(allKeys)).toBe(true);
expect(allKeys.length).toBeGreaterThanOrEqual(2); 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) { for (const userKey of userSpecificKeys) {
const found = allKeys.find((k) => k.key === userKey.key); const found = allKeys.find((k) => k.key === userKey.key);
expect(found).toBeDefined(); expect(found).toBeDefined();
} }
}); });
test("getAllPreAuthKeys returns keys with correct structure (0.28+)", async (context) => { test("listAll returns keys with correct structure (0.28+)", async (context) => {
const isAtLeast = await getIsAtLeast(version); const bootstrap = await getBootstrapClient(version);
if (!isAtLeast("0.28.0")) { if (!bootstrap.capabilities.preAuthKeysHaveStableIds) {
context.skip(); context.skip();
} }
const client = await getRuntimeClient(version); const client = await getRuntimeClient(version);
const allKeys = await client.getAllPreAuthKeys(); const allKeys = await client.preAuthKeys.listAll!();
for (const key of allKeys) { for (const key of allKeys) {
expect(key.id).toBeDefined(); 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 () => { test("pre-auth keys can be expired", async () => {
const client = await getRuntimeClient(version); 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).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@"); 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); expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
const preAuthKeyToExpire = preAuthKeys[0]; 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); const expiredKey = preAuthKeysAfterExpire.find((key) => key.key === preAuthKeyToExpire.key);
expect(expiredKey).toBeDefined(); expect(expiredKey).toBeDefined();
}); });
+16 -16
View File
@@ -5,19 +5,19 @@ import { getRuntimeClient, HS_VERSIONS } from "../setup/env";
describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
test("users can be created", async () => { test("users can be created", async () => {
const client = await getRuntimeClient(version); const client = await getRuntimeClient(version);
const user = await client.createUser("tale@"); const user = await client.users.create({ name: "tale@" });
expect(user).toBeDefined(); expect(user).toBeDefined();
expect(user.name).toBe("tale@"); expect(user.name).toBe("tale@");
}); });
test("users can be created with attributes", async () => { test("users can be created with attributes", async () => {
const client = await getRuntimeClient(version); const client = await getRuntimeClient(version);
const user = await client.createUser( const user = await client.users.create({
"test-user@", name: "test-user@",
"test-user@example.com", email: "test-user@example.com",
"Test User", displayName: "Test User",
"https://github.com/tale.png", pictureUrl: "https://github.com/tale.png",
); });
expect(user).toBeDefined(); expect(user).toBeDefined();
expect(user.name).toBe("test-user@"); 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 () => { test("users can be listed", async () => {
const client = await getRuntimeClient(version); const client = await getRuntimeClient(version);
const users = await client.getUsers(); const users = await client.users.list();
expect(Array.isArray(users)).toBe(true); expect(Array.isArray(users)).toBe(true);
expect(users.length).toBeGreaterThanOrEqual(2); expect(users.length).toBeGreaterThanOrEqual(2);
}); });
test("users can be listed by name", async () => { test("users can be listed by name", async () => {
const client = await getRuntimeClient(version); 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(Array.isArray(users)).toBe(true);
expect(users.length).toBe(1); expect(users.length).toBe(1);
expect(users[0].name).toBe("tale@"); 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 () => { test("users can be listed by email", async () => {
const client = await getRuntimeClient(version); 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(Array.isArray(users)).toBe(true);
expect(users.length).toBe(1); expect(users.length).toBe(1);
expect(users[0].email).toBe("test-user@example.com"); 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 () => { test("users can be renamed", async () => {
const client = await getRuntimeClient(version); 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); expect(usersBefore.length).toBe(1);
const user = usersBefore[0]; const user = usersBefore[0];
await client.renameUser(user.id, "renamed-user@"); await client.users.rename(user.id, "renamed-user@");
const usersAfter = await client.getUsers(undefined, "renamed-user@"); const usersAfter = await client.users.list({ name: "renamed-user@" });
expect(usersAfter.length).toBe(1); expect(usersAfter.length).toBe(1);
expect(usersAfter[0].id).toBe(user.id); expect(usersAfter[0].id).toBe(user.id);
}); });
test("users can be deleted", async () => { test("users can be deleted", async () => {
const client = await getRuntimeClient(version); 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); expect(usersBefore.length).toBe(1);
const user = usersBefore[0]; const user = usersBefore[0];
await client.deleteUser(user.id); await client.users.delete(user.id);
const usersAfter = await client.getUsers(undefined, "test-user@"); const usersAfter = await client.users.list({ name: "test-user@" });
expect(usersAfter.length).toBe(0); expect(usersAfter.length).toBe(0);
}); });
}); });
+21 -16
View File
@@ -1,31 +1,36 @@
import { describe, expect, test } from "vitest"; 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"; 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) => { describe.for(HS_VERSIONS)("Headscale %s: Runtime Client", (version) => {
test("the runtime client is usable", async () => { test("the runtime client is usable", async () => {
const bootstrapper = await getBootstrapClient(version); const bootstrapper = await getBootstrapClient(version);
const runtimeClient = bootstrapper.getRuntimeClient("test-api-key"); const runtimeClient = bootstrapper.client("test-api-key");
expect(runtimeClient).toBeDefined(); 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); 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 () => { test("the health check endpoint works", async () => {
+12 -13
View File
@@ -1,16 +1,18 @@
import hashes from "~/openapi-operation-hashes.json"; import { createHeadscale, type Headscale } from "~/server/headscale/api";
import { createHeadscaleInterface, type HeadscaleApiInterface } from "~/server/headscale/api";
import { type HeadscaleEnv, startHeadscale } from "./start-headscale"; import { type HeadscaleEnv, startHeadscale } from "./start-headscale";
import { startTailscaleNode, TailscaleNodeEnv } from "./start-tailscale"; import { startTailscaleNode, TailscaleNodeEnv } from "./start-tailscale";
export type Version = keyof typeof hashes; // The set of Headscale versions integration tests run against. Listed
export const HS_VERSIONS = Object.keys(hashes) as Version[]; // 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 { interface VersionStateEntry {
env: HeadscaleEnv; env: HeadscaleEnv;
tailscaleNode: TailscaleNodeEnv; tailscaleNode: TailscaleNodeEnv;
bootstrap: HeadscaleApiInterface; bootstrap: Headscale;
} }
const versionState = new Map<Version, VersionStateEntry>(); const versionState = new Map<Version, VersionStateEntry>();
@@ -21,7 +23,7 @@ async function ensureVersion(version: Version) {
const env = await startHeadscale(version); const env = await startHeadscale(version);
const tailscaleNode = await startTailscaleNode(version, env.container.getMappedPort(8080)); 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 }; const entry = { env, tailscaleNode, bootstrap };
versionState.set(version, entry); versionState.set(version, entry);
@@ -35,12 +37,7 @@ export async function getBootstrapClient(version: Version) {
export async function getRuntimeClient(version: Version) { export async function getRuntimeClient(version: Version) {
const { env, bootstrap } = await ensureVersion(version); const { env, bootstrap } = await ensureVersion(version);
return bootstrap.getRuntimeClient(env.apiKey); return bootstrap.client(env.apiKey);
}
export async function getIsAtLeast(version: Version) {
const { env, bootstrap } = await ensureVersion(version);
return bootstrap.clientHelpers.isAtleast;
} }
export async function getNode(version: Version) { export async function getNode(version: Version) {
@@ -52,7 +49,9 @@ export async function getNode(version: Version) {
} }
export async function stopAllVersions() { 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({ await env.container.stop({
remove: true, remove: true,
removeVolumes: true, removeVolumes: true,
+3 -3
View File
@@ -2,9 +2,9 @@ import { createInterface } from "node:readline";
import tc from "testcontainers"; import tc from "testcontainers";
import hashes from "~/openapi-operation-hashes.json"; // Imported by `env.ts` which owns the canonical version list; keep this
// loose to avoid an import cycle.
export type Version = keyof typeof hashes; export type Version = string;
export interface TailscaleNodeEnv { export interface TailscaleNodeEnv {
container: tc.StartedTestContainer; container: tc.StartedTestContainer;
+10 -10
View File
@@ -43,7 +43,7 @@ describe("Login action validation", () => {
const request = mockRequest(formData); const request = mockRequest(formData);
const mockContext = { const mockContext = {
hsApi: { getRuntimeClient: vi.fn() }, headscale: { client: vi.fn() },
sessions: { createSession: vi.fn() }, sessions: { createSession: vi.fn() },
}; };
@@ -63,7 +63,7 @@ describe("Login action validation", () => {
const request = mockRequest(formData); const request = mockRequest(formData);
const mockContext = { const mockContext = {
hsApi: { getRuntimeClient: vi.fn() }, headscale: { client: vi.fn() },
sessions: { createSession: vi.fn() }, sessions: { createSession: vi.fn() },
}; };
@@ -87,8 +87,8 @@ describe("Login action validation", () => {
.mockResolvedValue([{ prefix: "other-prefix", expiration: "2030-01-01T00:00:00Z" }]); .mockResolvedValue([{ prefix: "other-prefix", expiration: "2030-01-01T00:00:00Z" }]);
const mockContext = { const mockContext = {
hsApi: { headscale: {
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }), client: () => ({ apiKeys: { list: mockGetApiKeys } }),
}, },
sessions: { createSession: vi.fn() }, sessions: { createSession: vi.fn() },
}; };
@@ -114,8 +114,8 @@ describe("Login action validation", () => {
.mockResolvedValue([{ prefix: "expired-key-prefix", expiration: "2020-01-01T00:00:00Z" }]); .mockResolvedValue([{ prefix: "expired-key-prefix", expiration: "2020-01-01T00:00:00Z" }]);
const mockContext = { const mockContext = {
hsApi: { headscale: {
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }), client: () => ({ apiKeys: { list: mockGetApiKeys } }),
}, },
sessions: { createSession: vi.fn() }, sessions: { createSession: vi.fn() },
}; };
@@ -141,8 +141,8 @@ describe("Login action validation", () => {
.mockResolvedValue([{ prefix: "malformed-key", expiration: null } as MockApiKey]); .mockResolvedValue([{ prefix: "malformed-key", expiration: null } as MockApiKey]);
const mockContext = { const mockContext = {
hsApi: { headscale: {
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }), client: () => ({ apiKeys: { list: mockGetApiKeys } }),
}, },
sessions: { createSession: vi.fn() }, sessions: { createSession: vi.fn() },
}; };
@@ -173,8 +173,8 @@ describe("Login action validation", () => {
const mockCreateSession = vi.fn().mockResolvedValue("session-cookie"); const mockCreateSession = vi.fn().mockResolvedValue("session-cookie");
const mockContext = { const mockContext = {
hsApi: { headscale: {
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }), client: () => ({ apiKeys: { list: mockGetApiKeys } }),
}, },
sessions: { createSession: mockCreateSession }, sessions: { createSession: mockCreateSession },
}; };
+30 -36
View File
@@ -1,65 +1,59 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { HeadscaleApiInterface } from "~/server/headscale/api"; import { capabilitiesFor } from "~/server/headscale/api/capabilities";
import nodeEndpoints from "~/server/headscale/api/endpoints/nodes"; import { makeNodeApi } from "~/server/headscale/api/resources/nodes";
import userEndpoints from "~/server/headscale/api/endpoints/users"; 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<HeadscaleApiInterface["clientHelpers"]["apiFetch"]>; function createTransportRecorder() {
const calls: TransportRequest[] = [];
function createApiClientRecorder() { const transport: Transport = {
const calls: Array<{ async request<T>(opts: TransportRequest): Promise<T> {
method: ApiFetchArgs[0]; calls.push(opts);
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 <T>(
method: ApiFetchArgs[0],
apiPath: ApiFetchArgs[1],
apiKey: ApiFetchArgs[2],
bodyOrQuery?: ApiFetchArgs[3],
) => {
calls.push({ method, apiPath, apiKey, bodyOrQuery });
return undefined as T; return undefined as T;
}, },
} as HeadscaleApiInterface["clientHelpers"]; async getPublic<T>(): Promise<T> {
throw new Error("getPublic should not be called");
return { calls, client }; },
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", () => { describe("Headscale API path encoding", () => {
test("encodes node rename names as a single URL segment", async () => { 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([ expect(calls).toEqual([
{ {
method: "POST", method: "POST",
apiPath: "v1/node/2/rename/..%2F..%2F1%2Fexpire", path: "v1/node/2/rename/..%2F..%2F1%2Fexpire",
apiKey: "api-key", apiKey: "api-key",
bodyOrQuery: undefined,
}, },
]); ]);
}); });
test("encodes user rename names as a single URL segment", async () => { 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([ expect(calls).toEqual([
{ {
method: "POST", 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", apiKey: "api-key",
bodyOrQuery: undefined,
}, },
]); ]);
}); });
+110
View File
@@ -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);
});
});