From a25239702317075a21c76b55e39fb349254c60f2 Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 8 Jul 2026 16:39:47 +0900 Subject: [PATCH] connect tailscale node status through platform gateway --- src/config.ts | 1 + src/index.ts | 4 +- src/routes/admin.ts | 20 +++++++ src/services/headscale-node-discovery.ts | 70 ++++++++++++++++++++++-- 4 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 src/routes/admin.ts diff --git a/src/config.ts b/src/config.ts index 4a1fda4..a536d14 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ export const config = { headscaleApiKey: process.env.HEADSCALE_API_KEY ?? '', lmsGatewayToken: process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '', launcherGatewayToken: process.env.LAUNCHER_GATEWAY_TOKEN ?? process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '', + adminGatewayToken: process.env.ADMIN_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '', corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:4200,http://localhost:4300,http://localhost:4400') .split(',') .map((origin) => origin.trim()) diff --git a/src/index.ts b/src/index.ts index 3a725fe..c440733 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { cors } from 'hono/cors' import { logger } from 'hono/logger' import { config } from './config' import { eventRoutes } from './routes/events' +import { adminRoutes } from './routes/admin' import { lmsRoutes } from './routes/lms' import { platformRoutes } from './routes/platforms' import { sessionRoutes } from './routes/sessions' @@ -18,7 +19,7 @@ app.use( if (!origin) return null return config.corsOrigins.includes(origin) ? origin : null }, - allowHeaders: ['authorization', 'content-type', 'x-lms-gateway-token'], + allowHeaders: ['authorization', 'content-type', 'x-lms-gateway-token', 'x-platform-gateway-token'], allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], credentials: true }) @@ -33,6 +34,7 @@ app.get('/health', (c) => { }) app.route('/api', lmsRoutes) +app.route('/api', adminRoutes) app.route('/api', platformRoutes) app.route('/api', sessionRoutes) app.route('/api', eventRoutes) diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..85789c6 --- /dev/null +++ b/src/routes/admin.ts @@ -0,0 +1,20 @@ +import { Hono } from 'hono' +import { config } from '../config' +import { headscaleNodeDiscovery } from '../services/headscale-node-discovery' + +export const adminRoutes = new Hono() + +adminRoutes.use('/admin/*', async (c, next) => { + const token = c.req.header('x-platform-gateway-token') ?? '' + if (!config.adminGatewayToken || token !== config.adminGatewayToken) { + return c.json({ error: 'invalid_platform_gateway_token' }, 401) + } + return next() +}) + +adminRoutes.get('/admin/tailscale/nodes', async (c) => { + return c.json({ + nodes: await headscaleNodeDiscovery.listNodesStatus(), + fetchedAt: new Date().toISOString(), + }) +}) diff --git a/src/services/headscale-node-discovery.ts b/src/services/headscale-node-discovery.ts index 94fd55a..8ec40a1 100644 --- a/src/services/headscale-node-discovery.ts +++ b/src/services/headscale-node-discovery.ts @@ -24,7 +24,41 @@ export type TeachingAgentNode = { lastSeenAt: string | null } +export type HeadscaleNodeStatus = { + id: string + name: string + hostname: string + user: string + ip: string + ips: string[] + tags: string[] + routes: string[] + connected: boolean + expired: boolean + lastSeenAt: string | null +} + class HeadscaleNodeDiscovery { + async listNodesStatus(): Promise { + const nodes = await this.listNodes() + return nodes.map((node) => { + const ip = this.extractIpAddress(node) + return { + id: this.readString(node, 'id') || this.readString(node, 'ID') || this.readString(node, 'nodeId') || this.readString(node, 'node_id') || ip || '', + name: this.readString(node, 'name') || this.readString(node, 'givenName') || this.readString(node, 'given_name') || this.readString(node, 'hostname') || ip || '-', + hostname: this.readString(node, 'hostname') || this.readString(node, 'givenName') || this.readString(node, 'given_name') || this.readString(node, 'name') || '-', + user: this.extractUserName(node) || '-', + ip: ip ?? '-', + ips: this.extractIpAddresses(node), + tags: this.extractTags(node), + routes: this.extractRoutes(node), + connected: this.readBoolean(node, 'online') || this.readBoolean(node, 'connected') || this.readString(node, 'connected').toLowerCase() === 'online', + expired: this.readBoolean(node, 'expired'), + lastSeenAt: this.extractLastSeenAt(node), + } + }) + } + async listTeachingAgents(accountEmail?: string): Promise { const nodes = await this.listNodes().catch(() => []) const agents = nodes @@ -190,6 +224,10 @@ class HeadscaleNodeDiscovery { } private extractIpAddress(node: HeadscaleNode): string | null { + return this.extractIpAddresses(node)[0] ?? null + } + + private extractIpAddresses(node: HeadscaleNode): string[] { const values = [ node.ipAddresses, node.ip_addresses, @@ -198,14 +236,38 @@ class HeadscaleNodeDiscovery { ] for (const value of values) { - if (typeof value === 'string' && this.isTailscaleIp(value)) return value + if (typeof value === 'string' && this.isTailscaleIp(value)) return [value] if (Array.isArray(value)) { - const ipAddress = value.find((entry) => typeof entry === 'string' && this.isTailscaleIp(entry)) - if (typeof ipAddress === 'string') return ipAddress + const ipAddresses = value.filter((entry): entry is string => typeof entry === 'string' && this.isTailscaleIp(entry)) + if (ipAddresses.length) return ipAddresses } } - return null + return [] + } + + private extractRoutes(node: HeadscaleNode): string[] { + const direct = [ + node.advertisedRoutes, + node.approvedRoutes, + node.subnetRoutes, + node.routes, + ] + + for (const value of direct) { + if (!Array.isArray(value)) continue + const routes = value + .map((entry) => { + if (typeof entry === 'string') return entry + if (!entry || typeof entry !== 'object') return '' + const record = entry as Record + return this.readString(record, 'prefix') || this.readString(record, 'route') || this.readString(record, 'ipPrefix') + }) + .filter(Boolean) + if (routes.length) return routes + } + + return [] } private extractLastSeenAt(node: HeadscaleNode): string | null {