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 ?? '',
|
headscaleApiKey: process.env.HEADSCALE_API_KEY ?? '',
|
||||||
lmsGatewayToken: process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
|
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 ?? '',
|
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')
|
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(',')
|
.split(',')
|
||||||
.map((origin) => origin.trim())
|
.map((origin) => origin.trim())
|
||||||
|
|||||||
+3
-1
@@ -4,6 +4,7 @@ import { cors } from 'hono/cors'
|
|||||||
import { logger } from 'hono/logger'
|
import { logger } from 'hono/logger'
|
||||||
import { config } from './config'
|
import { config } from './config'
|
||||||
import { eventRoutes } from './routes/events'
|
import { eventRoutes } from './routes/events'
|
||||||
|
import { adminRoutes } from './routes/admin'
|
||||||
import { lmsRoutes } from './routes/lms'
|
import { lmsRoutes } from './routes/lms'
|
||||||
import { platformRoutes } from './routes/platforms'
|
import { platformRoutes } from './routes/platforms'
|
||||||
import { sessionRoutes } from './routes/sessions'
|
import { sessionRoutes } from './routes/sessions'
|
||||||
@@ -18,7 +19,7 @@ app.use(
|
|||||||
if (!origin) return null
|
if (!origin) return null
|
||||||
return config.corsOrigins.includes(origin) ? origin : 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'],
|
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
||||||
credentials: true
|
credentials: true
|
||||||
})
|
})
|
||||||
@@ -33,6 +34,7 @@ app.get('/health', (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.route('/api', lmsRoutes)
|
app.route('/api', lmsRoutes)
|
||||||
|
app.route('/api', adminRoutes)
|
||||||
app.route('/api', platformRoutes)
|
app.route('/api', platformRoutes)
|
||||||
app.route('/api', sessionRoutes)
|
app.route('/api', sessionRoutes)
|
||||||
app.route('/api', eventRoutes)
|
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
|
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 {
|
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[]> {
|
async listTeachingAgents(accountEmail?: string): Promise<TeachingAgentNode[]> {
|
||||||
const nodes = await this.listNodes().catch(() => [])
|
const nodes = await this.listNodes().catch(() => [])
|
||||||
const agents = nodes
|
const agents = nodes
|
||||||
@@ -190,6 +224,10 @@ class HeadscaleNodeDiscovery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractIpAddress(node: HeadscaleNode): string | null {
|
private extractIpAddress(node: HeadscaleNode): string | null {
|
||||||
|
return this.extractIpAddresses(node)[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractIpAddresses(node: HeadscaleNode): string[] {
|
||||||
const values = [
|
const values = [
|
||||||
node.ipAddresses,
|
node.ipAddresses,
|
||||||
node.ip_addresses,
|
node.ip_addresses,
|
||||||
@@ -198,14 +236,38 @@ class HeadscaleNodeDiscovery {
|
|||||||
]
|
]
|
||||||
|
|
||||||
for (const value of values) {
|
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)) {
|
if (Array.isArray(value)) {
|
||||||
const ipAddress = value.find((entry) => typeof entry === 'string' && this.isTailscaleIp(entry))
|
const ipAddresses = value.filter((entry): entry is string => typeof entry === 'string' && this.isTailscaleIp(entry))
|
||||||
if (typeof ipAddress === 'string') return ipAddress
|
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 {
|
private extractLastSeenAt(node: HeadscaleNode): string | null {
|
||||||
|
|||||||
Reference in New Issue
Block a user