diff --git a/src/routes/admin.ts b/src/routes/admin.ts index cfc1f8c..fc393a8 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -20,6 +20,16 @@ adminRoutes.get('/admin/tailscale/nodes', async (c) => { }) }) +adminRoutes.delete('/admin/tailscale/nodes/:nodeId', async (c) => { + const nodeId = c.req.param('nodeId').trim() + if (!nodeId) { + return c.json({ error: 'node_id_required' }, 400) + } + + await headscaleNodeDiscovery.deleteNode(nodeId) + return c.json({ deleted: true, nodeId }) +}) + adminRoutes.get('/admin/tailscale/routes', async (c) => { return c.json({ routes: await headscaleNodeDiscovery.listRouteStatus(), diff --git a/src/services/headscale-node-discovery.ts b/src/services/headscale-node-discovery.ts index 86277c2..6656c7a 100644 --- a/src/services/headscale-node-discovery.ts +++ b/src/services/headscale-node-discovery.ts @@ -71,6 +71,23 @@ class HeadscaleNodeDiscovery { }) } + async deleteNode(nodeId: string): Promise { + if (!config.headscaleApiKey) { + throw new Error('HEADSCALE_API_KEY is not configured') + } + + const encodedNodeId = encodeURIComponent(nodeId) + let response = await this.fetchHeadscale(`/api/v1/node/${encodedNodeId}`, { method: 'DELETE' }) + if (response.status === 404) { + response = await this.fetchHeadscale(`/api/v1/machine/${encodedNodeId}`, { method: 'DELETE' }) + } + + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`Headscale node deletion failed (${response.status})${detail ? `: ${detail}` : ''}`) + } + } + async listRouteStatus(): Promise { const nodes = await this.listNodes() return nodes.flatMap((node) => { @@ -141,9 +158,10 @@ class HeadscaleNodeDiscovery { return this.extractNodeList(await response.json().catch(() => null)) } - private fetchHeadscale(path: string) { + private fetchHeadscale(path: string, init?: RequestInit) { return fetch(`${config.headscaleBaseUrl}${path}`, { headers: { authorization: `Bearer ${config.headscaleApiKey}` }, + ...init, }) }