connect tailscale node status through platform gateway
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-08 16:39:47 +09:00
parent f21c350400
commit a252397023
4 changed files with 90 additions and 5 deletions
+1
View File
@@ -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())
+3 -1
View File
@@ -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)
+20
View File
@@ -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(),
})
})
+66 -4
View File
@@ -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<HeadscaleNodeStatus[]> {
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<TeachingAgentNode[]> {
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<string, unknown>
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 {