connect tailscale node status through platform gateway
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user