From c61a9119a7b7902492187493a357f4ec64e6dfe1 Mon Sep 17 00:00:00 2001 From: Paul Kronenwetter Date: Thu, 22 Jan 2026 14:06:46 -0500 Subject: [PATCH 1/3] Address tags incompatiblity introduced in 0.28.0-beta.2 From change (#2993)[https://github.com/juanfont/headscale/pull/2993] --- app/routes/machines/components/machine-row.tsx | 2 +- app/routes/machines/dialogs/tags.tsx | 2 +- app/routes/machines/machine.tsx | 2 +- app/types/Machine.ts | 4 +--- app/utils/node-info.ts | 5 +---- 5 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index 4d2da34b..77e00870 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -71,7 +71,7 @@ export default function MachineRow({

{mapTagsToComponents(node, uiTags)} - {node.validTags.map((tag) => ( + {node.tags.map((tag) => ( ))}
diff --git a/app/routes/machines/dialogs/tags.tsx b/app/routes/machines/dialogs/tags.tsx index bbc280f5..e36156f8 100644 --- a/app/routes/machines/dialogs/tags.tsx +++ b/app/routes/machines/dialogs/tags.tsx @@ -21,7 +21,7 @@ export default function Tags({ setIsOpen, existingTags, }: TagsProps) { - const [tags, setTags] = useState(machine.forcedTags); + const [tags, setTags] = useState(machine.tags); const [tag, setTag] = useState('tag:'); const tagIsInvalid = useMemo(() => { return tag.length === 0 || !tag.startsWith('tag:') || tags.includes(tag); diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 0b751f00..cf17bbad 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -41,7 +41,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { const lookup = await context.agents?.lookup([node.nodeKey]); const [enhancedNode] = mapNodes([node], lookup); const tags = Array.from( - new Set([...node.validTags, ...node.forcedTags]), + new Set([...node.tags]), ).sort(); return { diff --git a/app/types/Machine.ts b/app/types/Machine.ts index 5d3e27d9..3281d678 100644 --- a/app/types/Machine.ts +++ b/app/types/Machine.ts @@ -22,9 +22,7 @@ export interface Machine { | 'REGISTER_METHOD_CLI' | 'REGISTER_METHOD_OIDC'; - forcedTags: string[]; - invalidTags: string[]; - validTags: string[]; + tags: string[]; givenName: string; online: boolean; diff --git a/app/utils/node-info.ts b/app/utils/node-info.ts index 3e54f3c4..e41decae 100644 --- a/app/utils/node-info.ts +++ b/app/utils/node-info.ts @@ -56,10 +56,7 @@ export function mapNodes( export function sortNodeTags(nodes: Machine[]): string[] { return Array.from( new Set( - nodes.flatMap(({ validTags, forcedTags }) => [ - ...validTags, - ...forcedTags, - ]), + nodes.flatMap((node) => node.tags), ), ).sort(); } From 5777a5f509e9984d751ec89c0c9fbc57befb62a6 Mon Sep 17 00:00:00 2001 From: Paul Kronenwetter Date: Fri, 23 Jan 2026 21:53:30 -0500 Subject: [PATCH 2/3] Support previous API interface as well --- .../machines/components/machine-row.tsx | 5 ++++- app/routes/machines/dialogs/tags.tsx | 2 +- app/routes/machines/machine.tsx | 2 +- app/types/Machine.ts | 3 +++ app/utils/node-info.ts | 21 ++++++++++++++----- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index 77e00870..f5c5a1e4 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -71,7 +71,10 @@ export default function MachineRow({

{mapTagsToComponents(node, uiTags)} - {node.tags.map((tag) => ( + {node.validTags?.map((tag) => ( + + ))} + {node.tags?.map((tag) => ( ))}
diff --git a/app/routes/machines/dialogs/tags.tsx b/app/routes/machines/dialogs/tags.tsx index e36156f8..d0f2f43b 100644 --- a/app/routes/machines/dialogs/tags.tsx +++ b/app/routes/machines/dialogs/tags.tsx @@ -21,7 +21,7 @@ export default function Tags({ setIsOpen, existingTags, }: TagsProps) { - const [tags, setTags] = useState(machine.tags); + const [tags, setTags] = useState([...machine.forcedTags, ...machine.tags]); const [tag, setTag] = useState('tag:'); const tagIsInvalid = useMemo(() => { return tag.length === 0 || !tag.startsWith('tag:') || tags.includes(tag); diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index cf17bbad..2bb43ca4 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -41,7 +41,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { const lookup = await context.agents?.lookup([node.nodeKey]); const [enhancedNode] = mapNodes([node], lookup); const tags = Array.from( - new Set([...node.tags]), + new Set([...node.tags, ...node.validTags, ...node.forcedTags]), ).sort(); return { diff --git a/app/types/Machine.ts b/app/types/Machine.ts index 3281d678..3e7629a8 100644 --- a/app/types/Machine.ts +++ b/app/types/Machine.ts @@ -22,6 +22,9 @@ export interface Machine { | 'REGISTER_METHOD_CLI' | 'REGISTER_METHOD_OIDC'; + forcedTags: string[]; + invalidTags: string[]; + validTags: string[]; tags: string[]; givenName: string; online: boolean; diff --git a/app/utils/node-info.ts b/app/utils/node-info.ts index e41decae..f34ee029 100644 --- a/app/utils/node-info.ts +++ b/app/utils/node-info.ts @@ -54,9 +54,20 @@ export function mapNodes( } export function sortNodeTags(nodes: Machine[]): string[] { - return Array.from( - new Set( - nodes.flatMap((node) => node.tags), - ), - ).sort(); + try { + return Array.from( + new Set( + nodes.flatMap((node) => node.tags), + ), + ).sort(); + } catch { + return Array.from( + new Set( + nodes.flatMap(({ validTags, forcedTags }) => [ + ...validTags, + ...forcedTags, + ]), + ), + ).sort(); + } } From 696e08dbcca59a55460658725fedf34a0e94ceba Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 24 Jan 2026 12:18:43 -0500 Subject: [PATCH 3/3] feat: normalize node tags before leaving the api --- app/openapi-canonical-families.json | 8 +- app/openapi-operation-hashes.json | 266 ++++--- .../machines/components/machine-row.tsx | 422 +++++------ app/routes/machines/dialogs/tags.tsx | 186 +++-- app/routes/machines/machine.tsx | 713 ++++++++---------- app/server/headscale/api/endpoints/nodes.ts | 302 ++++---- app/types/Machine.ts | 53 +- app/utils/node-info.ts | 101 +-- tests/generate-openapi-hashes.ts | 150 ++-- 9 files changed, 1079 insertions(+), 1122 deletions(-) diff --git a/app/openapi-canonical-families.json b/app/openapi-canonical-families.json index 4c95fe2e..ed10aa66 100644 --- a/app/openapi-canonical-families.json +++ b/app/openapi-canonical-families.json @@ -1,5 +1,7 @@ { - "0.26.1": ["0.26.0", "0.26.1"], - "0.27.0": ["0.27.0"], - "0.27.1": ["0.27.1"] + "0.26.1": ["0.26.0", "0.26.1"], + "0.27.0": ["0.27.0"], + "0.27.1": ["0.27.1"], + "0.28.0-beta.1": ["0.28.0-beta.1"], + "0.28.0-beta.2": ["0.28.0-beta.2"] } diff --git a/app/openapi-operation-hashes.json b/app/openapi-operation-hashes.json index d1312dd3..65004057 100644 --- a/app/openapi-operation-hashes.json +++ b/app/openapi-operation-hashes.json @@ -1,108 +1,162 @@ { - "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.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-beta.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", + "GET /api/v1/policy": "d6c639be304cd3c0", + "PUT /api/v1/policy": "6cbe80bde771a388", + "GET /api/v1/preauthkey": "14db6a04f90d7a7e", + "DELETE /api/v1/preauthkey": "fa2975a185782e5d", + "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-beta.2": { + "GET /api/v1/apikey": "efe31b6dc980e158", + "POST /api/v1/apikey": "39953a96c1da5312", + "POST /api/v1/apikey/expire": "ca56add866802f17", + "DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2", + "POST /api/v1/debug/node": "204f9ae3f9f738c6", + "GET /api/v1/health": "5e447272e72b2e5f", + "GET /api/v1/node": "8bb18b8c7cfb4f20", + "POST /api/v1/node/backfillips": "6da4d1d3922a8001", + "POST /api/v1/node/register": "539f7cb3a84d43d4", + "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", + "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", + "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", + "POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae", + "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", + "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", + "GET /api/v1/policy": "d6c639be304cd3c0", + "PUT /api/v1/policy": "6cbe80bde771a388", + "GET /api/v1/preauthkey": "8428b44e3a821e9e", + "DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09", + "POST /api/v1/preauthkey": "0b4308e049d4eb58", + "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", + "GET /api/v1/user": "228831b58ccc5a17", + "POST /api/v1/user": "a4e1d889d7962da5", + "DELETE /api/v1/user/{id}": "3d553e4b74296884", + "POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7" + } } diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index f5c5a1e4..9fae3d35 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -1,237 +1,211 @@ -import { ChevronDown, Copy } from 'lucide-react'; -import { useMemo } from 'react'; -import { Link } from 'react-router'; -import Chip from '~/components/Chip'; -import Menu from '~/components/Menu'; -import StatusCircle from '~/components/StatusCircle'; -import { ExitNodeTag } from '~/components/tags/ExitNode'; -import { ExpiryTag } from '~/components/tags/Expiry'; -import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent'; -import { SubnetTag } from '~/components/tags/Subnet'; -import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH'; -import type { User } from '~/types'; -import cn from '~/utils/cn'; -import * as hinfo from '~/utils/host-info'; -import { PopulatedNode } from '~/utils/node-info'; -import { formatTimeDelta } from '~/utils/time'; -import toast from '~/utils/toast'; -import MenuOptions from './menu'; +import { ChevronDown, Copy } from "lucide-react"; +import { useMemo } from "react"; +import { Link } from "react-router"; + +import type { User } from "~/types"; + +import Chip from "~/components/Chip"; +import Menu from "~/components/Menu"; +import StatusCircle from "~/components/StatusCircle"; +import { ExitNodeTag } from "~/components/tags/ExitNode"; +import { ExpiryTag } from "~/components/tags/Expiry"; +import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent"; +import { SubnetTag } from "~/components/tags/Subnet"; +import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH"; +import cn from "~/utils/cn"; +import * as hinfo from "~/utils/host-info"; +import { PopulatedNode } from "~/utils/node-info"; +import { formatTimeDelta } from "~/utils/time"; +import toast from "~/utils/toast"; + +import MenuOptions from "./menu"; interface Props { - node: PopulatedNode; - users: User[]; - isAgent?: boolean; - magic?: string; - isDisabled?: boolean; - existingTags?: string[]; + node: PopulatedNode; + users: User[]; + isAgent?: boolean; + magic?: string; + isDisabled?: boolean; + existingTags?: string[]; } export default function MachineRow({ - node, - users, - isAgent, - magic, - isDisabled, - existingTags, + node, + users, + isAgent, + magic, + isDisabled, + existingTags, }: Props) { - const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]); - - const ipOptions = useMemo(() => { - if (magic) { - return [...node.ipAddresses, `${node.givenName}.${magic}`]; - } - - return node.ipAddresses; - }, [magic, node.ipAddresses]); - - return ( - - - -

- {node.givenName} -

-

- {node.user.name || - node.user.displayName || - node.user.email || - node.user.id} -

-
- {mapTagsToComponents(node, uiTags)} - {node.validTags?.map((tag) => ( - - ))} - {node.tags?.map((tag) => ( - - ))} -
- - - -
- {node.ipAddresses[0]} - - - - - { - await navigator.clipboard.writeText(key.toString()); - toast('Copied IP address to clipboard'); - }} - > - - {ipOptions.map((ip) => ( - -
- {ip} - -
-
- ))} -
-
-
-
- - {/* We pass undefined when agents are not enabled */} - {isAgent !== undefined ? ( - - {node.hostInfo !== undefined ? ( - <> -

- {hinfo.getTSVersion(node.hostInfo)} -

-

- {hinfo.getOSInfo(node.hostInfo)} -

- - ) : ( -

Unknown

- )} - - ) : undefined} - -
- -
-

- {node.online && !node.expired - ? 'Connected' - : new Date(node.lastSeen).toLocaleString()} -

- {!(node.online && !node.expired) && ( -

- {formatTimeDelta(new Date(node.lastSeen))} -

- )} -
-
- - - - - - ); + const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]); + + const ipOptions = useMemo(() => { + if (magic) { + return [...node.ipAddresses, `${node.givenName}.${magic}`]; + } + + return node.ipAddresses; + }, [magic, node.ipAddresses]); + + return ( + + + +

+ {node.givenName} +

+

+ {node.user.name || node.user.displayName || node.user.email || node.user.id} +

+
+ {mapTagsToComponents(node, uiTags)} + {node.tags?.map((tag) => ( + + ))} +
+ + + +
+ {node.ipAddresses[0]} + + + + + { + await navigator.clipboard.writeText(key.toString()); + toast("Copied IP address to clipboard"); + }} + > + + {ipOptions.map((ip) => ( + +
+ {ip} + +
+
+ ))} +
+
+
+
+ + {/* We pass undefined when agents are not enabled */} + {isAgent !== undefined ? ( + + {node.hostInfo !== undefined ? ( + <> +

{hinfo.getTSVersion(node.hostInfo)}

+

+ {hinfo.getOSInfo(node.hostInfo)} +

+ + ) : ( +

Unknown

+ )} + + ) : undefined} + +
+ +
+

+ {node.online && !node.expired + ? "Connected" + : new Date(node.lastSeen).toLocaleString()} +

+ {!(node.online && !node.expired) && ( +

+ {formatTimeDelta(new Date(node.lastSeen))} +

+ )} +
+
+ + + + + + ); } export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) { - const uiTags: string[] = []; - if (node.expired) { - uiTags.push('expired'); - } - - if (node.expiry === null) { - uiTags.push('no-expiry'); - } - - if (node.customRouting.exitRoutes.length > 0) { - if (node.customRouting.exitApproved) { - uiTags.push('exit-approved'); - } else { - uiTags.push('exit-waiting'); - } - } - - if (node.customRouting.subnetWaitingRoutes.length > 0) { - uiTags.push('subnet-waiting'); - } else if (node.customRouting.subnetApprovedRoutes.length > 0) { - uiTags.push('subnet-approved'); - } - - if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) { - uiTags.push('tailscale-ssh'); - } - - if (isAgent === true) { - uiTags.push('headplane-agent'); - } - - return uiTags; + const uiTags: string[] = []; + if (node.expired) { + uiTags.push("expired"); + } + + if (node.expiry === null) { + uiTags.push("no-expiry"); + } + + if (node.customRouting.exitRoutes.length > 0) { + if (node.customRouting.exitApproved) { + uiTags.push("exit-approved"); + } else { + uiTags.push("exit-waiting"); + } + } + + if (node.customRouting.subnetWaitingRoutes.length > 0) { + uiTags.push("subnet-waiting"); + } else if (node.customRouting.subnetApprovedRoutes.length > 0) { + uiTags.push("subnet-approved"); + } + + if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) { + uiTags.push("tailscale-ssh"); + } + + if (isAgent === true) { + uiTags.push("headplane-agent"); + } + + return uiTags; } export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) { - return uiTags.map((tag) => { - switch (tag) { - case 'exit-approved': - case 'exit-waiting': - return ; - - case 'subnet-approved': - case 'subnet-waiting': - return ; - - case 'expired': - case 'no-expiry': - return ( - - ); - - case 'tailscale-ssh': - return ; - - case 'headplane-agent': - return ; - - default: - return null; - } - }); + return uiTags.map((tag) => { + switch (tag) { + case "exit-approved": + case "exit-waiting": + return ; + + case "subnet-approved": + case "subnet-waiting": + return ; + + case "expired": + case "no-expiry": + return ; + + case "tailscale-ssh": + return ; + + case "headplane-agent": + return ; + + default: + return null; + } + }); } diff --git a/app/routes/machines/dialogs/tags.tsx b/app/routes/machines/dialogs/tags.tsx index d0f2f43b..24aba817 100644 --- a/app/routes/machines/dialogs/tags.tsx +++ b/app/routes/machines/dialogs/tags.tsx @@ -1,106 +1,96 @@ -import { Plus, TagsIcon, X } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import Button from '~/components/Button'; -import Dialog from '~/components/Dialog'; -import Link from '~/components/Link'; -import Select from '~/components/Select'; -import TableList from '~/components/TableList'; -import type { Machine } from '~/types'; -import cn from '~/utils/cn'; +import { Plus, TagsIcon, X } from "lucide-react"; +import { useMemo, useState } from "react"; + +import type { Machine } from "~/types"; + +import Button from "~/components/Button"; +import Dialog from "~/components/Dialog"; +import Link from "~/components/Link"; +import Select from "~/components/Select"; +import TableList from "~/components/TableList"; +import cn from "~/utils/cn"; interface TagsProps { - machine: Machine; - isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; - existingTags?: string[]; + machine: Machine; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + existingTags?: string[]; } -export default function Tags({ - machine, - isOpen, - setIsOpen, - existingTags, -}: TagsProps) { - const [tags, setTags] = useState([...machine.forcedTags, ...machine.tags]); - const [tag, setTag] = useState('tag:'); - const tagIsInvalid = useMemo(() => { - return tag.length === 0 || !tag.startsWith('tag:') || tags.includes(tag); - }, [tag, tags]); +export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsProps) { + const [tags, setTags] = useState([...machine.tags]); + const [tag, setTag] = useState("tag:"); + const tagIsInvalid = useMemo(() => { + return tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag); + }, [tag, tags]); - const validNodeTags = useMemo(() => { - return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || []; - }, [tags]); + const validNodeTags = useMemo(() => { + return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || []; + }, [tags]); - return ( - - - Edit ACL tags for {machine.givenName} - - ACL tags can be used to reference machines in your ACL policies. See - the{' '} - - Tailscale documentation - {' '} - for more information. - - - - - - {tags.length === 0 ? ( - - -

No tags are set on this machine

-
- ) : ( - tags.map((item) => ( - - {item} - - - )) - )} -
+ return ( + + + Edit ACL tags for {machine.givenName} + + ACL tags can be used to reference machines in your ACL policies. See the{" "} + + Tailscale documentation + {" "} + for more information. + + + + + + {tags.length === 0 ? ( + + +

No tags are set on this machine

+
+ ) : ( + tags.map((item) => ( + + {item} + + + )) + )} +
-
- - -
-
-
- ); +
+ + +
+
+
+ ); } diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 2bb43ca4..db47ec16 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -1,408 +1,353 @@ -import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react'; -import { useMemo, useState } from 'react'; -import { data, Link as RemixLink } from 'react-router'; -import Attribute from '~/components/Attribute'; -import Button from '~/components/Button'; -import Card from '~/components/Card'; -import Chip from '~/components/Chip'; -import Link from '~/components/Link'; -import StatusCircle from '~/components/StatusCircle'; -import Tooltip from '~/components/Tooltip'; -import cn from '~/utils/cn'; -import { getOSInfo, getTSVersion } from '~/utils/host-info'; -import { mapNodes, sortNodeTags } from '~/utils/node-info'; -import type { Route } from './+types/machine'; -import { mapTagsToComponents, uiTagsForNode } from './components/machine-row'; -import MenuOptions from './components/menu'; -import Routes from './dialogs/routes'; -import { machineAction } from './machine-actions'; +import { CheckCircle, CircleSlash, Info, UserCircle } from "lucide-react"; +import { useMemo, useState } from "react"; +import { data, Link as RemixLink } from "react-router"; + +import Attribute from "~/components/Attribute"; +import Button from "~/components/Button"; +import Card from "~/components/Card"; +import Chip from "~/components/Chip"; +import Link from "~/components/Link"; +import StatusCircle from "~/components/StatusCircle"; +import Tooltip from "~/components/Tooltip"; +import cn from "~/utils/cn"; +import { getOSInfo, getTSVersion } from "~/utils/host-info"; +import { mapNodes, sortNodeTags } from "~/utils/node-info"; + +import type { Route } from "./+types/machine"; + +import { mapTagsToComponents, uiTagsForNode } from "./components/machine-row"; +import MenuOptions from "./components/menu"; +import Routes from "./dialogs/routes"; +import { machineAction } from "./machine-actions"; export async function loader({ request, params, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - if (!params.id) { - throw new Error('No machine ID provided'); - } + const session = await context.sessions.auth(request); + if (!params.id) { + throw new Error("No machine ID provided"); + } - if (params.id.endsWith('.ico')) { - throw data(null, { status: 204 }); - } + if (params.id.endsWith(".ico")) { + throw data(null, { status: 204 }); + } - let magic: string | undefined; - if (context.hs.readable()) { - if (context.hs.c?.dns.magic_dns) { - magic = context.hs.c.dns.base_domain; - } - } + let magic: string | undefined; + if (context.hs.readable()) { + if (context.hs.c?.dns.magic_dns) { + magic = context.hs.c.dns.base_domain; + } + } - const api = context.hsApi.getRuntimeClient(session.api_key); - const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]); - const node = nodes.find((node) => node.id === params.id); + const api = context.hsApi.getRuntimeClient(session.api_key); + const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]); + const node = nodes.find((node) => node.id === params.id); - const lookup = await context.agents?.lookup([node.nodeKey]); - const [enhancedNode] = mapNodes([node], lookup); - const tags = Array.from( - new Set([...node.tags, ...node.validTags, ...node.forcedTags]), - ).sort(); + const lookup = await context.agents?.lookup([node.nodeKey]); + const [enhancedNode] = mapNodes([node], lookup); + const tags = [...node.tags].sort(); - return { - node: enhancedNode, - tags, - users, - magic, - agent: context.agents?.agentID(), - stats: lookup?.[enhancedNode.nodeKey], - existingTags: sortNodeTags(nodes), - }; + return { + node: enhancedNode, + tags, + users, + magic, + agent: context.agents?.agentID(), + stats: lookup?.[enhancedNode.nodeKey], + existingTags: sortNodeTags(nodes), + }; } export const action = machineAction; export default function Page({ - loaderData: { node, tags, users, magic, agent, stats, existingTags }, + loaderData: { node, tags, users, magic, agent, stats, existingTags }, }: Route.ComponentProps) { - const [showRouting, setShowRouting] = useState(false); + const [showRouting, setShowRouting] = useState(false); - const uiTags = useMemo(() => { - const tags = uiTagsForNode(node, agent === node.nodeKey); - return tags; - }, [node, agent]); + const uiTags = useMemo(() => { + const tags = uiTagsForNode(node, agent === node.nodeKey); + return tags; + }, [node, agent]); - return ( -
-

- - All Machines - - / - {node.givenName} -

-
- -

{node.givenName}

- -
- -
-
-
- - Managed by - - - - By default, a machine’s permissions match its creator’s. - - - -
- - {node.user.name || - node.user.displayName || - node.user.email || - node.user.id} -
-
-
-

- Status -

-
- {mapTagsToComponents(node, uiTags)} - {tags.map((tag) => ( - - ))} -
-
-
- -

Subnets & Routing

-
-

- Subnets let you expose physical network routes onto Tailscale.{' '} - - Learn More - -

- -
- -
- - Approved - - - - Traffic to these routes are being routed through this machine. - - - -
- {node.customRouting.subnetApprovedRoutes.length === 0 ? ( - - ) : ( -
    - {node.customRouting.subnetApprovedRoutes.map((route) => ( -
  • {route}
  • - ))} -
- )} -
- -
-
- - Awaiting Approval - - - - This machine is advertising these routes, but they must be - approved before traffic will be routed to them. - - - -
- {node.customRouting.subnetWaitingRoutes.length === 0 ? ( - - ) : ( -
    - {node.customRouting.subnetWaitingRoutes.map((route) => ( -
  • {route}
  • - ))} -
- )} -
- -
-
- - Exit Node - - - - Whether this machine can act as an exit node for your tailnet. - - - -
- {node.customRouting.exitRoutes.length === 0 ? ( - - ) : node.customRouting.exitApproved ? ( - - - Allowed - - ) : ( - - - Awaiting Approval - - )} -
- -
-
-

Machine Details

-

- Information about this machine’s network. Used to debug connection - issues. -

- -
- - - - {stats ? ( - <> - - - - ) : undefined} - - - - - - {magic ? ( - - ) : undefined} -
-
-

- Addresses -

- - - - {magic ? ( - - ) : undefined} - {stats ? ( - <> -

- Client Connectivity -

- - - - - - - - - ) : undefined} -
-
-
- ); + return ( +
+

+ + All Machines + + / + {node.givenName} +

+
+ +

{node.givenName}

+ +
+ +
+
+
+ + Managed by + + + By default, a machine’s permissions match its creator’s. + + +
+ + {node.user.name || node.user.displayName || node.user.email || node.user.id} +
+
+
+

Status

+
+ {mapTagsToComponents(node, uiTags)} + {tags.map((tag) => ( + + ))} +
+
+
+ +

Subnets & Routing

+
+

+ Subnets let you expose physical network routes onto Tailscale.{" "} + + Learn More + +

+ +
+ +
+ + Approved + + + + Traffic to these routes are being routed through this machine. + + + +
+ {node.customRouting.subnetApprovedRoutes.length === 0 ? ( + + ) : ( +
    + {node.customRouting.subnetApprovedRoutes.map((route) => ( +
  • {route}
  • + ))} +
+ )} +
+ +
+
+ + Awaiting Approval + + + + This machine is advertising these routes, but they must be approved before traffic + will be routed to them. + + + +
+ {node.customRouting.subnetWaitingRoutes.length === 0 ? ( + + ) : ( +
    + {node.customRouting.subnetWaitingRoutes.map((route) => ( +
  • {route}
  • + ))} +
+ )} +
+ +
+
+ + Exit Node + + + + Whether this machine can act as an exit node for your tailnet. + + + +
+ {node.customRouting.exitRoutes.length === 0 ? ( + + ) : node.customRouting.exitApproved ? ( + + + Allowed + + ) : ( + + + Awaiting Approval + + )} +
+ +
+
+

Machine Details

+

+ Information about this machine’s network. Used to debug connection issues. +

+ +
+ + + + {stats ? ( + <> + + + + ) : undefined} + + + + + + {magic ? ( + + ) : undefined} +
+
+

Addresses

+ + + + {magic ? ( + + ) : undefined} + {stats ? ( + <> +

Client Connectivity

+ + + + + + + + + ) : undefined} +
+
+
+ ); } function getIpv4Address(addresses: string[]) { - for (const address of addresses) { - if (address.startsWith('100.')) { - // Return the first CGNAT address - return address; - } - } + for (const address of addresses) { + if (address.startsWith("100.")) { + // Return the first CGNAT address + return address; + } + } - return '—'; + return "—"; } function getIpv6Address(addresses: string[]) { - for (const address of addresses) { - if (address.startsWith('fd')) { - // Return the first IPv6 address - return address; - } - } + for (const address of addresses) { + if (address.startsWith("fd")) { + // Return the first IPv6 address + return address; + } + } - return '—'; + return "—"; } diff --git a/app/server/headscale/api/endpoints/nodes.ts b/app/server/headscale/api/endpoints/nodes.ts index 6be7db76..fe6c03ef 100644 --- a/app/server/headscale/api/endpoints/nodes.ts +++ b/app/server/headscale/api/endpoints/nodes.ts @@ -1,150 +1,164 @@ -import type { Machine } from '~/types'; -import { defineApiEndpoints } from '../factory'; +import type { Machine } from "~/types"; + +import type { HeadscaleApiInterface } from ".."; + +import { defineApiEndpoints } from "../factory"; + +interface RawMachine extends Omit { + tags?: string[]; + forcedTags?: string[]; + validTags?: string[]; + invalidTags?: string[]; +} + +/** + * Normalizes the tags of a RawMachine based on the Headscale version. + * + * @param client The Headscale API client helper. + * @param node The RawMachine object to normalize. + * @returns A Machine object with normalized tags. + */ +function normalizeTags(client: HeadscaleApiInterface["clientHelpers"], node: RawMachine): Machine { + if (client.isAtleast("0.28.0-beta.1")) { + return { ...node, tags: node.tags ?? [] } as Machine; + } + + const tags = Array.from(new Set([...(node.forcedTags ?? []), ...(node.validTags ?? [])])); + + return { ...node, tags } as Machine; +} export interface NodeEndpoints { - /** - * Retrieves all nodes (machines) from the Headscale instance. - * - * @returns An array of `Machine` objects representing the nodes. - */ - getNodes(): Promise; - - /** - * Retrieves a specific node (machine) by its ID. - * - * @param id The ID of the node to retrieve. - * @returns A `Machine` object representing the node. - */ - getNode(id: string): Promise; - - /** - * Deletes a specific node (machine) by its ID. - * - * @param id The ID of the node to delete. - */ - deleteNode(id: string): Promise; - - /** - * Registers a new node (machine) with the given user and key. - * - * @param user The user to associate with the node. - * @param key The registration key for the node. - * @returns A `Machine` object representing the newly registered node. - */ - registerNode(user: string, key: string): Promise; - - /** - * Approves routes for a specific node (machine) by its ID. - * - * @param id The ID of the node. - * @param routes An array of routes to approve for the node. - */ - approveNodeRoutes(id: string, routes: string[]): Promise; - - /** - * Expires a specific node (machine) by its ID. - * - * @param id The ID of the node to expire. - */ - expireNode(id: string): Promise; - - /** - * Renames a specific node (machine) by its ID. - * - * @param id The ID of the node to rename. - * @param newName The new name for the node. - */ - renameNode(id: string, newName: string): Promise; - - /** - * Sets tags for a specific node (machine) by its ID. - * - * @param id The ID of the node. - * @param tags An array of tags to set for the node. - */ - setNodeTags(id: string, tags: string[]): Promise; - - /** - * Sets the user for a specific node (machine) by its ID. - * - * @param id The ID of the node. - * @param user The user to set for the node. - */ - setNodeUser(id: string, user: string): Promise; + /** + * Retrieves all nodes (machines) from the Headscale instance. + * + * @returns An array of `Machine` objects representing the nodes. + */ + getNodes(): Promise; + + /** + * Retrieves a specific node (machine) by its ID. + * + * @param id The ID of the node to retrieve. + * @returns A `Machine` object representing the node. + */ + getNode(id: string): Promise; + + /** + * Deletes a specific node (machine) by its ID. + * + * @param id The ID of the node to delete. + */ + deleteNode(id: string): Promise; + + /** + * Registers a new node (machine) with the given user and key. + * + * @param user The user to associate with the node. + * @param key The registration key for the node. + * @returns A `Machine` object representing the newly registered node. + */ + registerNode(user: string, key: string): Promise; + + /** + * Approves routes for a specific node (machine) by its ID. + * + * @param id The ID of the node. + * @param routes An array of routes to approve for the node. + */ + approveNodeRoutes(id: string, routes: string[]): Promise; + + /** + * Expires a specific node (machine) by its ID. + * + * @param id The ID of the node to expire. + */ + expireNode(id: string): Promise; + + /** + * Renames a specific node (machine) by its ID. + * + * @param id The ID of the node to rename. + * @param newName The new name for the node. + */ + renameNode(id: string, newName: string): Promise; + + /** + * Sets tags for a specific node (machine) by its ID. + * + * @param id The ID of the node. + * @param tags An array of tags to set for the node. + */ + setNodeTags(id: string, tags: string[]): Promise; + + /** + * Sets the user for a specific node (machine) by its ID. + * + * @param id The ID of the node. + * @param user The user to set for the node. + */ + setNodeUser(id: string, user: string): Promise; } export default defineApiEndpoints((client, apiKey) => ({ - getNodes: async () => { - const { nodes } = await client.apiFetch<{ nodes: Machine[] }>( - 'GET', - 'v1/node', - apiKey, - ); - - return nodes; - }, - - getNode: async (nodeId) => { - const { node } = await client.apiFetch<{ node: Machine }>( - 'GET', - `v1/node/${nodeId}`, - apiKey, - ); - - return node; - }, - - deleteNode: async (nodeId) => { - await client.apiFetch('DELETE', `v1/node/${nodeId}`, apiKey); - }, - - registerNode: async (user, key) => { - const qp = new URLSearchParams(); - qp.append('user', user); - qp.append('key', key); - const { node } = await client.apiFetch<{ node: Machine }>( - 'POST', - `v1/node/register?${qp.toString()}`, - apiKey, - { - user, - key, - }, - ); - - return node; - }, - - approveNodeRoutes: async (nodeId, routes) => { - await client.apiFetch( - 'POST', - `v1/node/${nodeId}/approve_routes`, - apiKey, - { routes }, - ); - }, - - expireNode: async (nodeId) => { - await client.apiFetch('POST', `v1/node/${nodeId}/expire`, apiKey); - }, - - renameNode: async (nodeId, newName) => { - await client.apiFetch( - 'POST', - `v1/node/${nodeId}/rename/${newName}`, - apiKey, - ); - }, - - setNodeTags: async (nodeId, tags) => { - await client.apiFetch('POST', `v1/node/${nodeId}/tags`, apiKey, { - tags, - }); - }, - - setNodeUser: async (nodeId, user) => { - await client.apiFetch('POST', `v1/node/${nodeId}/user`, apiKey, { - user, - }); - }, + getNodes: async () => { + const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey); + + return nodes.map((node) => normalizeTags(client, node)); + }, + + getNode: async (nodeId) => { + const { node } = await client.apiFetch<{ node: RawMachine }>( + "GET", + `v1/node/${nodeId}`, + apiKey, + ); + + return normalizeTags(client, node); + }, + + deleteNode: async (nodeId) => { + await client.apiFetch("DELETE", `v1/node/${nodeId}`, apiKey); + }, + + registerNode: async (user, key) => { + const qp = new URLSearchParams(); + qp.append("user", user); + qp.append("key", key); + const { node } = await client.apiFetch<{ node: RawMachine }>( + "POST", + `v1/node/register?${qp.toString()}`, + apiKey, + { + user, + key, + }, + ); + + return normalizeTags(client, node); + }, + + approveNodeRoutes: async (nodeId, routes) => { + await client.apiFetch("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes }); + }, + + expireNode: async (nodeId) => { + await client.apiFetch("POST", `v1/node/${nodeId}/expire`, apiKey); + }, + + renameNode: async (nodeId, newName) => { + await client.apiFetch("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey); + }, + + setNodeTags: async (nodeId, tags) => { + await client.apiFetch("POST", `v1/node/${nodeId}/tags`, apiKey, { + tags, + }); + }, + + setNodeUser: async (nodeId, user) => { + await client.apiFetch("POST", `v1/node/${nodeId}/user`, apiKey, { + user, + }); + }, })); diff --git a/app/types/Machine.ts b/app/types/Machine.ts index 3e7629a8..c644310c 100644 --- a/app/types/Machine.ts +++ b/app/types/Machine.ts @@ -1,36 +1,33 @@ -import type { PreAuthKey } from './PreAuthKey'; -import type { User } from './User'; +import type { PreAuthKey } from "./PreAuthKey"; +import type { User } from "./User"; export interface Machine { - id: string; - machineKey: string; - nodeKey: string; - discoKey: string; - ipAddresses: string[]; - name: string; + id: string; + machineKey: string; + nodeKey: string; + discoKey: string; + ipAddresses: string[]; + name: string; - user: User; - lastSeen: string; - expiry: string | null; + user: User; + lastSeen: string; + expiry: string | null; - preAuthKey?: PreAuthKey; + preAuthKey?: PreAuthKey; - createdAt: string; - registerMethod: - | 'REGISTER_METHOD_UNSPECIFIED' - | 'REGISTER_METHOD_AUTH_KEY' - | 'REGISTER_METHOD_CLI' - | 'REGISTER_METHOD_OIDC'; + createdAt: string; + registerMethod: + | "REGISTER_METHOD_UNSPECIFIED" + | "REGISTER_METHOD_AUTH_KEY" + | "REGISTER_METHOD_CLI" + | "REGISTER_METHOD_OIDC"; - forcedTags: string[]; - invalidTags: string[]; - validTags: string[]; - tags: string[]; - givenName: string; - online: boolean; + tags: string[]; + givenName: string; + online: boolean; - // Added in Headscale 0.26+ - approvedRoutes: string[]; - availableRoutes: string[]; - subnetRoutes: string[]; + // Added in Headscale 0.26+ + approvedRoutes: string[]; + availableRoutes: string[]; + subnetRoutes: string[]; } diff --git a/app/utils/node-info.ts b/app/utils/node-info.ts index f34ee029..f26d096d 100644 --- a/app/utils/node-info.ts +++ b/app/utils/node-info.ts @@ -1,73 +1,50 @@ -import { HostInfo, Machine } from '~/types'; +import { HostInfo, Machine } from "~/types"; export interface PopulatedNode extends Machine { - routes: string[]; - hostInfo?: HostInfo; - expired: boolean; - customRouting: { - exitRoutes: string[]; - exitApproved: boolean; - subnetApprovedRoutes: string[]; - subnetWaitingRoutes: string[]; - }; + routes: string[]; + hostInfo?: HostInfo; + expired: boolean; + customRouting: { + exitRoutes: string[]; + exitApproved: boolean; + subnetApprovedRoutes: string[]; + subnetWaitingRoutes: string[]; + }; } export function mapNodes( - nodes: Machine[], - stats?: Record | undefined, + nodes: Machine[], + stats?: Record | undefined, ): PopulatedNode[] { - return nodes.map((node) => { - const customRouting = { - exitRoutes: node.availableRoutes.filter( - (route) => route === '::/0' || route === '0.0.0.0/0', - ), - exitApproved: node.approvedRoutes.some( - (route) => route === '::/0' || route === '0.0.0.0/0', - ), - subnetApprovedRoutes: node.approvedRoutes.filter( - (route) => - route !== '::/0' && - route !== '0.0.0.0/0' && - node.availableRoutes.includes(route), - ), - subnetWaitingRoutes: node.availableRoutes.filter( - (route) => - route !== '::/0' && - route !== '0.0.0.0/0' && - !node.approvedRoutes.includes(route), - ), - } satisfies PopulatedNode['customRouting']; + return nodes.map((node) => { + const customRouting = { + exitRoutes: node.availableRoutes.filter((route) => route === "::/0" || route === "0.0.0.0/0"), + exitApproved: node.approvedRoutes.some((route) => route === "::/0" || route === "0.0.0.0/0"), + subnetApprovedRoutes: node.approvedRoutes.filter( + (route) => + route !== "::/0" && route !== "0.0.0.0/0" && node.availableRoutes.includes(route), + ), + subnetWaitingRoutes: node.availableRoutes.filter( + (route) => + route !== "::/0" && route !== "0.0.0.0/0" && !node.approvedRoutes.includes(route), + ), + } satisfies PopulatedNode["customRouting"]; - return { - ...node, - routes: Array.from(new Set(node.availableRoutes)), - hostInfo: stats?.[node.nodeKey], - customRouting, - expired: - node.expiry === '0001-01-01 00:00:00' || - node.expiry === '0001-01-01T00:00:00Z' || - node.expiry === null - ? false - : new Date(node.expiry).getTime() < Date.now(), - }; - }); + return { + ...node, + routes: Array.from(new Set(node.availableRoutes)), + hostInfo: stats?.[node.nodeKey], + customRouting, + expired: + node.expiry === "0001-01-01 00:00:00" || + node.expiry === "0001-01-01T00:00:00Z" || + node.expiry === null + ? false + : new Date(node.expiry).getTime() < Date.now(), + }; + }); } export function sortNodeTags(nodes: Machine[]): string[] { - try { - return Array.from( - new Set( - nodes.flatMap((node) => node.tags), - ), - ).sort(); - } catch { - return Array.from( - new Set( - nodes.flatMap(({ validTags, forcedTags }) => [ - ...validTags, - ...forcedTags, - ]), - ), - ).sort(); - } + return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort(); } diff --git a/tests/generate-openapi-hashes.ts b/tests/generate-openapi-hashes.ts index 9a270848..a04c31e1 100644 --- a/tests/generate-openapi-hashes.ts +++ b/tests/generate-openapi-hashes.ts @@ -1,98 +1,102 @@ -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'; +import type { OpenAPIV2 } from "openapi-types"; -const HASH_FILE_LOCATION = 'app/openapi-operation-hashes.json'; -const CANONICAL_LOCATION = 'app/openapi-canonical-families.json'; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { cwd } from "node:process"; +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.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-beta.1": "/v0.28.0-beta.1/gen/openapiv2/headscale/v1/headscale.swagger.json", + "0.28.0-beta.2": "/v0.28.0-beta.2/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); + 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])[], + newHashes: readonly (readonly [string, Record])[], ) { - const canonicalizedFamilies: Record = {}; - for (const [version, hashes] of newHashes) { - const signature = JSON.stringify(hashes); - let canonical: string | null = null; - - for (const existingCanonical of Object.keys(canonicalizedFamilies)) { - const existingSignature = JSON.stringify( - newHashes.find(([v]) => v === existingCanonical)![1], - ); - - if (existingSignature === signature) { - canonical = existingCanonical; - break; - } - } - - if (!canonical) { - canonicalizedFamilies[version] = [version]; - continue; - } - - canonicalizedFamilies[canonical].push(version); - if ( - version.localeCompare(canonical, undefined, { - numeric: true, - sensitivity: 'base', - }) > 0 - ) { - canonicalizedFamilies[version] = canonicalizedFamilies[canonical]; - delete canonicalizedFamilies[canonical]; - } - } - - for (const [canonical, family] of Object.entries(canonicalizedFamilies)) { - canonicalizedFamilies[canonical] = family.sort((a, b) => - a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }), - ); - } - - return canonicalizedFamilies; + const canonicalizedFamilies: Record = {}; + for (const [version, hashes] of newHashes) { + const signature = JSON.stringify(hashes); + let canonical: string | null = null; + + for (const existingCanonical of Object.keys(canonicalizedFamilies)) { + const existingSignature = JSON.stringify( + newHashes.find(([v]) => v === existingCanonical)![1], + ); + + if (existingSignature === signature) { + canonical = existingCanonical; + break; + } + } + + if (!canonical) { + canonicalizedFamilies[version] = [version]; + continue; + } + + canonicalizedFamilies[canonical].push(version); + if ( + version.localeCompare(canonical, undefined, { + numeric: true, + sensitivity: "base", + }) > 0 + ) { + canonicalizedFamilies[version] = canonicalizedFamilies[canonical]; + delete canonicalizedFamilies[canonical]; + } + } + + for (const [canonical, family] of Object.entries(canonicalizedFamilies)) { + canonicalizedFamilies[canonical] = family.sort((a, b) => + a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }), + ); + } + + return canonicalizedFamilies; } async function writeHashes(hashes: Record>) { - const path = resolve(cwd(), HASH_FILE_LOCATION); - await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, 'utf-8'); + const path = resolve(cwd(), HASH_FILE_LOCATION); + await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, "utf-8"); } async function writeCanonicalizedFamilies(families: Record) { - const path = resolve(cwd(), CANONICAL_LOCATION); - await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, 'utf-8'); + const 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; - }), + 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'); +console.log("Writing new OpenAPI operation hashes to file"); await writeHashes(Object.fromEntries(newHashes)); await writeCanonicalizedFamilies(canonicalizedFamilies);