From 46da0a3f374aed2badf7358aa6bbb6a80d9c0f6d Mon Sep 17 00:00:00 2001 From: chyoo Date: Thu, 6 Aug 2026 16:13:37 +0900 Subject: [PATCH] feat: display platform agent status and version --- .env.example | 1 + src/config.ts | 4 ++++ src/routes/lms.ts | 2 ++ src/services/agent-client.ts | 17 ++++++++++++++--- src/services/headscale-adapter.ts | 21 +++++++++++++++++++-- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 5b62c82..44a5e08 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ AGENT_PORT=3100 AGENT_GATEWAY_TOKEN= HEADSCALE_BASE_URL=https://atnmy-hs.duckdns.org HEADSCALE_API_KEY= +HEADSCALE_GATEWAY_USERS=local-platform-gateway,platform-gateway HEADSCALE_TEACHING_DEVICE_OWNER=tagged-devices LMS_GATEWAY_TOKEN=change-me diff --git a/src/config.ts b/src/config.ts index a0e1410..5bd47b4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,10 @@ export const config = { headscaleBaseUrl: required('HEADSCALE_BASE_URL', 'http://localhost:8080'), headscaleApiKey: process.env.HEADSCALE_API_KEY ?? '', headscaleTeachingDeviceOwner: (process.env.HEADSCALE_TEACHING_DEVICE_OWNER ?? 'tagged-devices').trim().replace(/@+$/, ''), + headscaleGatewayUsers: (process.env.HEADSCALE_GATEWAY_USERS ?? process.env.HEADSCALE_GATEWAY_USER ?? 'local-platform-gateway') + .split(',') + .map((user) => user.trim().replace(/@+$/, '')) + .filter(Boolean), 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 ?? '', diff --git a/src/routes/lms.ts b/src/routes/lms.ts index 7b6f14f..4d5a50d 100644 --- a/src/routes/lms.ts +++ b/src/routes/lms.ts @@ -143,6 +143,8 @@ lmsRoutes.get('/lms/platforms/:id/status', async (c) => { sshUser: platform.sshUser ?? null, workspaceRoot: platform.workspaceRoot ?? null, lastSeenAt: platform.lastSeenAt, + agentStartedAt: agentHealth.startedAt ?? null, + agentUptimeSeconds: agentHealth.uptimeSeconds ?? null, state: platform.state, message: agentHealth.message ?? null, }) diff --git a/src/services/agent-client.ts b/src/services/agent-client.ts index 2e19b2c..bcbd34c 100644 --- a/src/services/agent-client.ts +++ b/src/services/agent-client.ts @@ -2,7 +2,12 @@ import { config } from '../config' import type { Platform } from '../types' export class AgentClient { - async health(platform: Platform): Promise<{ ok: boolean; message?: string }> { + async health(platform: Platform): Promise<{ + ok: boolean; + message?: string; + startedAt?: string | null; + uptimeSeconds?: number | null; + }> { if (!platform.agentUrl) return { ok: false, message: 'platform_agent_url_missing' } const controller = new AbortController() @@ -14,8 +19,14 @@ export class AgentClient { signal: controller.signal }) if (!response.ok) return { ok: false, message: `agent_health_failed:${response.status}` } - const payload = await response.json().catch(() => ({ ok: true })) - return { ok: payload?.ok !== false } + const payload = await response.json().catch(() => ({ ok: true })) as Record + return { + ok: payload?.ok !== false, + startedAt: typeof payload?.startedAt === 'string' ? payload.startedAt : null, + uptimeSeconds: typeof payload?.uptimeSeconds === 'number' && Number.isFinite(payload.uptimeSeconds) + ? Math.max(0, Math.floor(payload.uptimeSeconds)) + : null, + } } catch (error) { return { ok: false, message: error instanceof Error ? error.message : String(error) } } finally { diff --git a/src/services/headscale-adapter.ts b/src/services/headscale-adapter.ts index 3e53031..69bff97 100644 --- a/src/services/headscale-adapter.ts +++ b/src/services/headscale-adapter.ts @@ -28,6 +28,7 @@ export type HeadscalePolicyStatus = { } const generatedPrefix = 'group:lms-platform-' +const gatewayGroup = 'group:lms-platform-gateway' export class HeadscaleAdapter { async applyGrant(_grant: AccessGrant): Promise<{ applied: boolean; mode: 'noop' | 'headscale' }> { @@ -77,8 +78,24 @@ export class HeadscaleAdapter { }> { const nodes = await this.listNodes() const unresolved: string[] = [] - const groups: Record = {} - const acls: HeadscalePolicy['acls'] = [] + const groups: Record = { + [gatewayGroup]: [...new Set(config.headscaleGatewayUsers.map((user) => this.toHeadscaleUserName(user)))], + } + const acls: HeadscalePolicy['acls'] = [ + { + // The local bridge initiates discovery from the trusted gateway host. + action: 'accept', + src: [gatewayGroup], + dst: ['tag:teaching-device:*'], + }, + { + // DDS discovery and topic payloads use independent UDP flows, so the + // teaching device must also be able to reach the bridge on the gateway. + action: 'accept', + src: ['tag:teaching-device'], + dst: [`${gatewayGroup}:*`], + }, + ] assignments.forEach((assignment, index) => { const users = [...new Set(assignment.studentEmails.map((email) => this.toHeadscaleUserName(email)).filter(Boolean))]