commit 05d0fd80f03d1d7db0dc21c585b9fb12ad8b076e Author: chyoo Date: Wed Jul 8 14:44:20 2026 +0900 initial platform gateway service diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1eb326e --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +NODE_ENV=development +PORT=4500 +CORS_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:4200,http://localhost:4300,http://localhost:4400 + +AUTH_SERVER_URL=http://localhost:4100 +LMS_SERVER_URL=http://localhost:4400 + +DEFAULT_SESSION_TTL_SECONDS=7200 +AGENT_REQUEST_TIMEOUT_MS=8000 +AGENT_PORT=3100 +AGENT_GATEWAY_TOKEN= +HEADSCALE_BASE_URL=https://atnmy-hs.duckdns.org +HEADSCALE_API_KEY= +LMS_GATEWAY_TOKEN=change-me + +# Headscale must have a policy path configured on the server for /api/v1/policy +# to accept policy updates. + +# Optional platform registry override. Platform IDs should match LMS platform_key or +# platform studioSettings.gatewayPlatformId. +# nodeKey should match the Tailscale hostname or Headscale node name. +# PLATFORM_REGISTRY_JSON=[{"id":"futuredrive-mkmini-02","name":"MK-mini","nodeKey":"futuredrive-mkmini-02","agentUrl":"","headscaleUser":"mobility","tags":["jetson","ros"],"state":"offline","lastSeenAt":null}] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53f2a1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +.env.* +!.env.example +node_modules/ +dist/ +build/ +coverage/ +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..bcbef11 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# platform-gateway + +Bun + Hono service for mobility platform access control. + +This service is intentionally separate from LMS APIs. LMS decides education-domain eligibility, while this gateway owns platform access grants, session ownership, agent command proxying, connection status, monitoring events, release, and emergency-stop workflows. + +## Run + +```bash +bun install +bun run dev +``` + +Default URL: `http://localhost:4500` + +## Core APIs + +- `GET /health` +- `GET /api/platforms` +- `GET /api/platforms/:id/status` +- `GET /api/connections` +- `POST /api/platform-access-grants` +- `DELETE /api/platform-access-grants/:grantId` +- `POST /api/platform-sessions` +- `POST /api/platforms/:id/commands` +- `POST /api/platforms/:id/release` +- `POST /api/platforms/:id/emergency-stop` +- `GET /api/events` + +## Network Model + +Students and instructors never connect directly to Headscale/Tailscale device endpoints. Clients call this gateway only. The gateway verifies auth, checks platform grants/session ownership, records audit context, and proxies allowed operations to the mobility agent running on each platform node. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..04dea59 --- /dev/null +++ b/bun.lock @@ -0,0 +1,35 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "platform-gateway", + "dependencies": { + "@hono/node-server": "^1.14.4", + "hono": "^4.7.11", + "zod": "^3.25.48", + }, + "devDependencies": { + "@types/bun": "^1.2.15", + "typescript": "^5.8.3", + }, + }, + }, + "packages": { + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..687f91f --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "platform-gateway", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --hot src/index.ts", + "start": "bun src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "^1.14.4", + "hono": "^4.7.11", + "zod": "^3.25.48" + }, + "devDependencies": { + "@types/bun": "^1.2.15", + "typescript": "^5.8.3" + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..4a1fda4 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,29 @@ +const toInt = (value: string | undefined, fallback: number): number => { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback +} + +const required = (name: string, fallback?: string): string => { + const value = process.env[name] ?? fallback + if (!value) throw new Error(`Missing required env: ${name}`) + return value.replace(/\/+$/, '') +} + +export const config = { + port: toInt(process.env.PORT, 4500), + authServerUrl: required('AUTH_SERVER_URL', 'http://localhost:4100'), + launcherServerUrl: required('LAUNCHER_SERVER_URL', 'http://localhost:4200'), + lmsServerUrl: required('LMS_SERVER_URL', 'http://localhost:4400'), + defaultSessionTtlSeconds: toInt(process.env.DEFAULT_SESSION_TTL_SECONDS, 2 * 60 * 60), + agentRequestTimeoutMs: toInt(process.env.AGENT_REQUEST_TIMEOUT_MS, 8000), + agentPort: toInt(process.env.AGENT_PORT, 3100), + agentGatewayToken: process.env.AGENT_GATEWAY_TOKEN ?? '', + headscaleBaseUrl: required('HEADSCALE_BASE_URL', 'http://localhost:8080'), + 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 ?? '', + 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()) + .filter(Boolean) +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3a725fe --- /dev/null +++ b/src/index.ts @@ -0,0 +1,42 @@ +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { logger } from 'hono/logger' +import { config } from './config' +import { eventRoutes } from './routes/events' +import { lmsRoutes } from './routes/lms' +import { platformRoutes } from './routes/platforms' +import { sessionRoutes } from './routes/sessions' + +const app = new Hono() + +app.use('*', logger()) +app.use( + '*', + cors({ + origin: (origin) => { + if (!origin) return null + return config.corsOrigins.includes(origin) ? origin : null + }, + allowHeaders: ['authorization', 'content-type', 'x-lms-gateway-token'], + allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + credentials: true + }) +) + +app.get('/health', (c) => { + return c.json({ + ok: true, + service: 'platform-gateway', + time: new Date().toISOString() + }) +}) + +app.route('/api', lmsRoutes) +app.route('/api', platformRoutes) +app.route('/api', sessionRoutes) +app.route('/api', eventRoutes) + +serve({ fetch: app.fetch, port: config.port }, (info) => { + console.log(`Platform gateway listening on http://localhost:${info.port}`) +}) diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..ee74cbc --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,30 @@ +import type { MiddlewareHandler } from 'hono' +import { config } from '../config' +import type { AuthUser } from '../types' + +export type AuthVariables = { + user: AuthUser + accessToken: string +} + +export const authRequired: MiddlewareHandler<{ Variables: AuthVariables }> = async (c, next) => { + const header = c.req.header('authorization') + const token = header?.match(/^Bearer\s+(.+)$/i)?.[1] + if (!token) return c.json({ error: 'missing_authorization' }, 401) + + try { + const response = await fetch(`${config.authServerUrl}/auth/me`, { + headers: { authorization: `Bearer ${token}` } + }) + if (!response.ok) return c.json({ error: 'invalid_authorization' }, 401) + + const payload = await response.json() as { user?: AuthUser } + if (!payload.user?.id) return c.json({ error: 'invalid_authorization' }, 401) + + c.set('user', payload.user) + c.set('accessToken', token) + return next() + } catch { + return c.json({ error: 'auth_server_unavailable' }, 503) + } +} diff --git a/src/routes/events.ts b/src/routes/events.ts new file mode 100644 index 0000000..84ff82c --- /dev/null +++ b/src/routes/events.ts @@ -0,0 +1,25 @@ +import { Hono } from 'hono' +import { streamSSE } from 'hono/streaming' +import type { AuthVariables } from '../middleware/auth' +import { authRequired } from '../middleware/auth' +import { eventBus } from '../services/event-bus' + +export const eventRoutes = new Hono<{ Variables: AuthVariables }>() + +eventRoutes.get('/events', authRequired, (c) => { + return streamSSE(c, async (stream) => { + const unsubscribe = eventBus.subscribe((event) => { + void stream.writeSSE({ + id: event.id, + event: event.type, + data: JSON.stringify(event) + }) + }) + + stream.onAbort(unsubscribe) + while (!stream.aborted) { + await stream.writeSSE({ event: 'heartbeat', data: JSON.stringify({ at: new Date().toISOString() }) }) + await stream.sleep(15000) + } + }) +}) diff --git a/src/routes/lms.ts b/src/routes/lms.ts new file mode 100644 index 0000000..3973777 --- /dev/null +++ b/src/routes/lms.ts @@ -0,0 +1,252 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import { config } from '../config' +import { agentClient } from '../services/agent-client' +import { headscaleAdapter } from '../services/headscale-adapter' +import { headscaleNodeDiscovery } from '../services/headscale-node-discovery' +import { platformRegistry } from '../services/platform-registry' +import { sessionManager } from '../services/session-manager' +import type { AuthUser } from '../types' + +const sessionSchema = z.object({ + platformId: z.string().min(1), + user: z.object({ + id: z.string().min(1), + email: z.string().optional(), + name: z.string().optional(), + role: z.string().optional(), + }), + agentOwnerEmail: z.string().email().optional(), + ttlSeconds: z.number().int().positive().max(8 * 60 * 60).default(config.defaultSessionTtlSeconds), +}) + +const visualizationSchema = z.object({ + stack: z.string().optional(), + rosDomainId: z.string().optional(), + profile: z.unknown().optional(), +}) + +const accessSyncSchema = z.object({ + assignments: z.array(z.object({ + platformId: z.string().min(1), + platformName: z.string().nullable().optional(), + agentHostname: z.string().nullable().optional(), + agentOwnerEmail: z.string().nullable().optional(), + studentEmails: z.array(z.string().email()).default([]), + })).default([]), +}) + +export const lmsRoutes = new Hono() + +lmsRoutes.use('/lms/*', async (c, next) => { + const token = c.req.header('x-lms-gateway-token') ?? '' + if (!config.lmsGatewayToken || token !== config.lmsGatewayToken) { + return c.json({ error: 'invalid_lms_gateway_token' }, 401) + } + return next() +}) + +lmsRoutes.post('/lms/platform-sessions', async (c) => { + const body = sessionSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_body', issues: body.error.flatten() }, 400) + + const platform = await platformRegistry.resolvePlatform( + body.data.platformId, + body.data.agentOwnerEmail ?? body.data.user.email, + ) + if (!platform) return c.json({ error: 'platform_not_found' }, 404) + if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) + + const active = sessionManager.getActiveSessionByPlatform(body.data.platformId) + if (active) { + if (active.userId !== body.data.user.id) return c.json({ error: 'platform_busy', session: active }, 409) + return c.json({ session: active, connection: connectionPayload(platform) }) + } + + try { + const session = sessionManager.start({ + platformId: body.data.platformId, + ttlSeconds: body.data.ttlSeconds, + user: body.data.user as AuthUser, + }) + return c.json({ session, connection: connectionPayload(platform) }, 201) + } catch (error) { + if (!(error instanceof Error)) throw error + if (error.message === 'platform_not_found') return c.json({ error: error.message }, 404) + if (error.message === 'platform_busy' || error.message === 'platform_unavailable') { + return c.json({ error: error.message }, 409) + } + throw error + } +}) + +lmsRoutes.get('/lms/agents', async (c) => { + const accountEmail = c.req.query('accountEmail') || undefined + return c.json({ agents: await headscaleNodeDiscovery.listTeachingAgents(accountEmail) }) +}) + +lmsRoutes.get('/lms/platforms/:id/status', async (c) => { + const accountEmail = c.req.query('accountEmail') || undefined + const platform = await platformRegistry.resolvePlatform(c.req.param('id'), accountEmail) + if (!platform) return c.json({ error: 'platform_not_found' }, 404) + + const headscaleOnline = Boolean(platform.agentUrl && platform.state !== 'offline') + const agentHealth = headscaleOnline + ? await agentClient.health(platform) + : { ok: false, message: 'headscale_offline' } + + return c.json({ + platformId: c.req.param('id'), + registered: true, + headscaleOnline, + agentHealthy: Boolean(agentHealth.ok), + agentUrl: platform.agentUrl || null, + sshUser: platform.sshUser ?? null, + workspaceRoot: platform.workspaceRoot ?? null, + lastSeenAt: platform.lastSeenAt, + state: platform.state, + message: agentHealth.message ?? null, + }) +}) + +lmsRoutes.post('/lms/access-sync', async (c) => { + const body = accessSyncSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_body', issues: body.error.flatten() }, 400) + + const result = await headscaleAdapter.syncAssignments(body.data.assignments) + if (!result.applied) return c.json({ error: 'headscale_policy_apply_failed', result }, 502) + const ssh = await syncPlatformSshKeys(body.data.assignments) + return c.json({ ...result, ssh }) +}) + +lmsRoutes.post('/lms/platforms/:id/release', async (c) => { + const user = await c.req.json<{ user?: AuthUser }>().catch(() => ({} as { user?: AuthUser })) + const session = sessionManager.release(c.req.param('id'), user.user ?? { id: 'lms-system', role: 'ADMIN' }) + const platform = await platformRegistry.resolvePlatform(c.req.param('id')) + if (platform) await agentClient.visualizationStop(platform).catch(() => undefined) + return c.json({ session }) +}) + +lmsRoutes.get('/lms/platforms/:id/visualization/status', async (c) => { + const platform = await platformRegistry.resolvePlatform(c.req.param('id')) + if (!platform) return c.json({ error: 'platform_not_found' }, 404) + if (!platform.agentUrl) return c.json({ ok: false, running: false, stack: null, message: 'platform_agent_not_connected' }, 409) + return c.json(await agentClient.visualizationStatus(platform)) +}) + +lmsRoutes.get('/lms/platforms/:id/modules/statuses', async (c) => { + const accountEmail = c.req.query('accountEmail') || undefined + const platform = await platformRegistry.resolvePlatform(c.req.param('id'), accountEmail) + if (!platform) return c.json({ error: 'platform_not_found' }, 404) + if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) + return c.json(await agentClient.moduleStatuses(platform)) +}) + +lmsRoutes.post('/lms/platforms/:id/modules/start', async (c) => { + const accountEmail = c.req.query('accountEmail') || undefined + const platform = await platformRegistry.resolvePlatform(c.req.param('id'), accountEmail) + if (!platform) return c.json({ error: 'platform_not_found' }, 404) + if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) + const body = await c.req.json<{ folderName?: string; rosDomainId?: string }>().catch(() => ({})) + return c.json(await agentClient.moduleStart(platform, body)) +}) + +lmsRoutes.post('/lms/platforms/:id/modules/stop', async (c) => { + const accountEmail = c.req.query('accountEmail') || undefined + const platform = await platformRegistry.resolvePlatform(c.req.param('id'), accountEmail) + if (!platform) return c.json({ error: 'platform_not_found' }, 404) + if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) + const body = await c.req.json<{ containerKey?: string }>().catch(() => ({})) + return c.json(await agentClient.moduleStop(platform, body)) +}) + +lmsRoutes.post('/lms/platforms/:id/visualization/start', async (c) => { + const platform = await platformRegistry.resolvePlatform(c.req.param('id')) + if (!platform) return c.json({ error: 'platform_not_found' }, 404) + if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) + const body = visualizationSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_body', issues: body.error.flatten() }, 400) + return c.json(await agentClient.visualizationStart(platform, body.data)) +}) + +lmsRoutes.post('/lms/platforms/:id/visualization/stop', async (c) => { + const platform = await platformRegistry.resolvePlatform(c.req.param('id')) + if (!platform) return c.json({ error: 'platform_not_found' }, 404) + if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) + const body = visualizationSchema.safeParse(await c.req.json().catch(() => ({}))) + return c.json(await agentClient.visualizationStop(platform, body.success ? body.data : {})) +}) + +function connectionPayload(platform: { agentUrl: string; sshUser?: string | null; workspaceRoot?: string | null }) { + const agentUrl = platform.agentUrl.replace(/\/+$/, '') + const bridgeHttpUrl = (() => { + try { + const url = new URL(agentUrl) + url.port = '8765' + return url.toString().replace(/\/+$/, '') + } catch { + return '' + } + })() + + return { + agentUrl, + sshUser: platform.sshUser ?? null, + workspaceRoot: platform.workspaceRoot ?? null, + bridgeHttpUrl, + bridgeWsUrl: bridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:'), + } +} + +async function syncPlatformSshKeys(assignments: Array<{ + platformId: string + agentOwnerEmail?: string | null + studentEmails: string[] +}>) { + const emails = Array.from(new Set(assignments.flatMap((assignment) => assignment.studentEmails))) + const publicKeysByEmail = await fetchLauncherSshKeys(emails).catch(() => ({} as Record>)) + const results: Array<{ platformId: string; applied: boolean; keyCount: number; message?: string }> = [] + + for (const assignment of assignments) { + const platform = await platformRegistry.resolvePlatform( + assignment.platformId, + assignment.agentOwnerEmail ?? undefined, + ) + const publicKeys = assignment.studentEmails.flatMap((email) => + (publicKeysByEmail[email.toLowerCase()] ?? []).map((key) => key.publicKey), + ) + if (!platform?.agentUrl) { + results.push({ platformId: assignment.platformId, applied: false, keyCount: publicKeys.length, message: 'platform_agent_not_connected' }) + continue + } + try { + await agentClient.syncAuthorizedKeys(platform, publicKeys, platform.sshUser) + results.push({ platformId: assignment.platformId, applied: true, keyCount: publicKeys.length }) + } catch (error) { + results.push({ + platformId: assignment.platformId, + applied: false, + keyCount: publicKeys.length, + message: error instanceof Error ? error.message : String(error), + }) + } + } + + return results +} + +async function fetchLauncherSshKeys(emails: string[]): Promise>> { + if (!emails.length) return {} + if (!config.launcherGatewayToken) return {} + const response = await fetch(`${config.launcherServerUrl}/api/provision/ssh-keys/lookup`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-lms-gateway-token': config.launcherGatewayToken, + }, + body: JSON.stringify({ emails }), + }) + if (!response.ok) throw new Error(`launcher_ssh_key_lookup_failed:${response.status}`) + const payload = await response.json().catch(() => ({})) as { keysByEmail?: Record> } + return payload.keysByEmail ?? {} +} diff --git a/src/routes/platforms.ts b/src/routes/platforms.ts new file mode 100644 index 0000000..2837fe2 --- /dev/null +++ b/src/routes/platforms.ts @@ -0,0 +1,91 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import type { AuthVariables } from '../middleware/auth' +import { authRequired } from '../middleware/auth' +import { accessControl } from '../services/access-control' +import { agentClient } from '../services/agent-client' +import { eventBus } from '../services/event-bus' +import { platformRegistry } from '../services/platform-registry' +import { sessionManager } from '../services/session-manager' + +const commandSchema = z.object({ + command: z.enum(['ros.launch', 'ros.stop', 'sim.start', 'sim.stop', 'terminal.exec']), + args: z.array(z.string()).default([]) +}) + +export const platformRoutes = new Hono<{ Variables: AuthVariables }>() + +platformRoutes.use('*', authRequired) + +platformRoutes.get('/platforms', (c) => { + return c.json({ platforms: platformRegistry.listPlatforms() }) +}) + +platformRoutes.get('/platforms/:id/status', (c) => { + const status = platformRegistry.getStatus(c.req.param('id')) + if (!status) return c.json({ error: 'platform_not_found' }, 404) + return c.json({ status }) +}) + +platformRoutes.get('/connections', (c) => { + const connections = platformRegistry.listConnections( + sessionManager.listActiveSessions(), + accessControl.listActiveGrants() + ) + return c.json({ connections }) +}) + +platformRoutes.post('/platforms/:id/commands', async (c) => { + const platformId = c.req.param('id') + const session = sessionManager.getActiveSessionByPlatform(platformId) + const user = c.get('user') + if (!session) return c.json({ error: 'session_required' }, 409) + if (session.userId !== user.id && !['ADMIN', 'INSTRUCTOR'].includes(String(user.role ?? '').toUpperCase())) { + return c.json({ error: 'session_not_owned' }, 403) + } + + const body = commandSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_body', issues: body.error.flatten() }, 400) + + const platform = await platformRegistry.resolvePlatform(platformId) + if (!platform) return c.json({ error: 'platform_not_found' }, 404) + if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) + + const result = await agentClient.runCommand(platform, body.data.command, body.data.args) + eventBus.publish('platform.command.output', { + platformId, + sessionId: session.id, + commandId: result.commandId, + accepted: result.accepted + }) + return c.json(result, result.accepted ? 202 : 502) +}) + +platformRoutes.post('/platforms/:id/release', (c) => { + try { + const session = sessionManager.release(c.req.param('id'), c.get('user')) + if (!session) return c.json({ error: 'active_session_not_found' }, 404) + return c.json({ session }) + } catch (error) { + if (error instanceof Error && error.message === 'session_not_owned') return c.json({ error: error.message }, 403) + throw error + } +}) + +platformRoutes.post('/platforms/:id/emergency-stop', async (c) => { + const platformId = c.req.param('id') + const platform = await platformRegistry.resolvePlatform(platformId) + if (!platform) return c.json({ error: 'platform_not_found' }, 404) + if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) + + const result = await agentClient.emergencyStop(platform) + const session = sessionManager.markEmergencyStopped(platformId) + platformRegistry.setPlatformState(platformId, 'emergency_stopped') + eventBus.publish('platform.emergency.stop', { + platformId, + sessionId: session?.id ?? null, + requestedByUserId: c.get('user').id, + agentAccepted: result.accepted + }) + return c.json({ accepted: result.accepted, session }, result.accepted ? 202 : 502) +}) diff --git a/src/routes/sessions.ts b/src/routes/sessions.ts new file mode 100644 index 0000000..cbee1dc --- /dev/null +++ b/src/routes/sessions.ts @@ -0,0 +1,78 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import { config } from '../config' +import type { AuthVariables } from '../middleware/auth' +import { authRequired } from '../middleware/auth' +import { accessControl } from '../services/access-control' +import { headscaleAdapter } from '../services/headscale-adapter' +import { platformRegistry } from '../services/platform-registry' +import { sessionManager } from '../services/session-manager' + +const grantSchema = z.object({ + platformId: z.string().min(1), + granteeUserId: z.string().min(1), + ttlSeconds: z.number().int().positive().max(24 * 60 * 60).default(config.defaultSessionTtlSeconds), + reason: z.string().max(300).optional() +}) + +const sessionSchema = z.object({ + platformId: z.string().min(1), + ttlSeconds: z.number().int().positive().max(8 * 60 * 60).default(config.defaultSessionTtlSeconds) +}) + +export const sessionRoutes = new Hono<{ Variables: AuthVariables }>() + +sessionRoutes.use('*', authRequired) + +sessionRoutes.get('/platform-access-grants', (c) => { + return c.json({ grants: accessControl.listAllGrants() }) +}) + +sessionRoutes.post('/platform-access-grants', async (c) => { + const body = grantSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_body', issues: body.error.flatten() }, 400) + if (!platformRegistry.getPlatform(body.data.platformId)) return c.json({ error: 'platform_not_found' }, 404) + + try { + const grant = accessControl.grant({ ...body.data, grantedBy: c.get('user') }) + const headscale = await headscaleAdapter.applyGrant(grant) + return c.json({ grant, headscale }, 201) + } catch (error) { + if (error instanceof Error && error.message === 'forbidden') return c.json({ error: error.message }, 403) + throw error + } +}) + +sessionRoutes.delete('/platform-access-grants/:grantId', async (c) => { + try { + const grant = accessControl.revoke(c.req.param('grantId'), c.get('user')) + if (!grant) return c.json({ error: 'grant_not_found' }, 404) + const headscale = await headscaleAdapter.revokeGrant(grant.id) + return c.json({ grant, headscale }) + } catch (error) { + if (error instanceof Error && error.message === 'forbidden') return c.json({ error: error.message }, 403) + throw error + } +}) + +sessionRoutes.post('/platform-sessions', async (c) => { + const body = sessionSchema.safeParse(await c.req.json().catch(() => null)) + if (!body.success) return c.json({ error: 'invalid_body', issues: body.error.flatten() }, 400) + + try { + const session = sessionManager.start({ + platformId: body.data.platformId, + ttlSeconds: body.data.ttlSeconds, + user: c.get('user') + }) + return c.json({ session }, 201) + } catch (error) { + if (!(error instanceof Error)) throw error + if (error.message === 'platform_not_found') return c.json({ error: error.message }, 404) + if (error.message === 'platform_access_denied') return c.json({ error: error.message }, 403) + if (error.message === 'platform_busy' || error.message === 'platform_unavailable') { + return c.json({ error: error.message }, 409) + } + throw error + } +}) diff --git a/src/services/access-control.ts b/src/services/access-control.ts new file mode 100644 index 0000000..75d6022 --- /dev/null +++ b/src/services/access-control.ts @@ -0,0 +1,65 @@ +import type { AccessGrant, AuthUser } from '../types' +import { eventBus } from './event-bus' + +const grants = new Map() + +const nowIso = () => new Date().toISOString() +const expiresAtFrom = (ttlSeconds: number): string => new Date(Date.now() + ttlSeconds * 1000).toISOString() +const isPrivileged = (user: AuthUser): boolean => ['ADMIN', 'INSTRUCTOR'].includes(String(user.role ?? '').toUpperCase()) + +export class AccessControlService { + listActiveGrants(): AccessGrant[] { + const now = Date.now() + return Array.from(grants.values()).filter((grant) => !grant.revokedAt && Date.parse(grant.expiresAt) > now) + } + + listAllGrants(): AccessGrant[] { + return Array.from(grants.values()) + } + + grant(input: { + platformId: string + granteeUserId: string + grantedBy: AuthUser + ttlSeconds: number + reason?: string + }): AccessGrant { + if (!isPrivileged(input.grantedBy)) { + throw new Error('forbidden') + } + + const grant: AccessGrant = { + id: crypto.randomUUID(), + platformId: input.platformId, + granteeUserId: input.granteeUserId, + grantedByUserId: input.grantedBy.id, + reason: input.reason, + expiresAt: expiresAtFrom(input.ttlSeconds), + createdAt: nowIso(), + revokedAt: null + } + grants.set(grant.id, grant) + eventBus.publish('platform.access.granted', { grant }) + return grant + } + + revoke(grantId: string, revokedBy: AuthUser): AccessGrant | null { + if (!isPrivileged(revokedBy)) { + throw new Error('forbidden') + } + + const grant = grants.get(grantId) + if (!grant) return null + const next = { ...grant, revokedAt: nowIso() } + grants.set(grantId, next) + eventBus.publish('platform.access.revoked', { grantId, platformId: next.platformId, revokedByUserId: revokedBy.id }) + return next + } + + canUsePlatform(user: AuthUser, platformId: string): boolean { + if (isPrivileged(user)) return true + return this.listActiveGrants().some((grant) => grant.platformId === platformId && grant.granteeUserId === user.id) + } +} + +export const accessControl = new AccessControlService() diff --git a/src/services/agent-client.ts b/src/services/agent-client.ts new file mode 100644 index 0000000..2e19b2c --- /dev/null +++ b/src/services/agent-client.ts @@ -0,0 +1,151 @@ +import { config } from '../config' +import type { Platform } from '../types' + +export class AgentClient { + async health(platform: Platform): Promise<{ ok: boolean; message?: string }> { + if (!platform.agentUrl) return { ok: false, message: 'platform_agent_url_missing' } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), Math.min(config.agentRequestTimeoutMs, 3000)) + + try { + const response = await fetch(`${platform.agentUrl.replace(/\/+$/, '')}/health`, { + headers: this.agentHeaders(), + 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 } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } finally { + clearTimeout(timeout) + } + } + + async visualizationStart(platform: Platform, input: { + stack?: string; + rosDomainId?: string; + profile?: unknown; + }): Promise<{ ok: boolean; running: boolean; stack: string | null; message?: string }> { + return this.agentJson(platform, '/visualization/start', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }); + } + + async visualizationStop(platform: Platform, input: { + stack?: string; + profile?: unknown; + } = {}): Promise<{ ok: boolean; running: boolean; stack: string | null; message?: string }> { + return this.agentJson(platform, '/visualization/stop', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }); + } + + async visualizationStatus(platform: Platform): Promise<{ ok: boolean; running: boolean; stack: string | null; message?: string }> { + return this.agentJson(platform, '/visualization/status'); + } + + async moduleStatuses(platform: Platform): Promise { + return this.agentJson(platform, '/api/modules/docker-statuses'); + } + + async moduleStart(platform: Platform, input: { folderName?: string; rosDomainId?: string }): Promise { + return this.agentJson(platform, '/api/modules/start', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }); + } + + async moduleStop(platform: Platform, input: { containerKey?: string }): Promise { + return this.agentJson(platform, '/api/modules/stop', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }); + } + + async syncAuthorizedKeys(platform: Platform, publicKeys: string[], sshUser?: string | null): Promise { + return this.agentJson(platform, '/api/ssh/authorized-keys', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ publicKeys, sshUser }), + }); + } + + async runCommand(platform: Platform, command: string, args: string[]): Promise<{ commandId: string; accepted: boolean }> { + if (!platform.agentUrl) return { commandId: crypto.randomUUID(), accepted: false } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), config.agentRequestTimeoutMs) + + try { + const response = await fetch(`${platform.agentUrl}/commands`, { + method: 'POST', + headers: this.agentHeaders({ 'content-type': 'application/json' }), + body: JSON.stringify({ command, args }), + signal: controller.signal + }) + if (!response.ok) throw new Error(`agent_command_failed:${response.status}`) + const payload = await response.json().catch(() => ({})) + return { commandId: String(payload.commandId ?? crypto.randomUUID()), accepted: true } + } catch { + return { commandId: crypto.randomUUID(), accepted: false } + } finally { + clearTimeout(timeout) + } + } + + async emergencyStop(platform: Platform): Promise<{ accepted: boolean }> { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), config.agentRequestTimeoutMs) + + try { + const response = await fetch(`${platform.agentUrl}/emergency-stop`, { + method: 'POST', + headers: this.agentHeaders(), + signal: controller.signal + }) + return { accepted: response.ok } + } catch { + return { accepted: false } + } finally { + clearTimeout(timeout) + } + } + + private async agentJson(platform: Platform, path: string, init?: RequestInit): Promise { + if (!platform.agentUrl) throw new Error('platform_agent_url_missing') + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), config.agentRequestTimeoutMs) + + try { + const response = await fetch(`${platform.agentUrl.replace(/\/+$/, '')}${path}`, { + ...init, + headers: this.agentHeaders(init?.headers), + signal: controller.signal, + }) + const payload = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(String(payload?.message ?? payload?.error ?? `agent_request_failed:${response.status}`)) + return payload as T + } finally { + clearTimeout(timeout) + } + } + + private agentHeaders(headers?: HeadersInit): HeadersInit { + const next = new Headers(headers) + if (config.agentGatewayToken) { + next.set('x-agent-gateway-token', config.agentGatewayToken) + } + return next + } +} + +export const agentClient = new AgentClient() diff --git a/src/services/event-bus.ts b/src/services/event-bus.ts new file mode 100644 index 0000000..02d6894 --- /dev/null +++ b/src/services/event-bus.ts @@ -0,0 +1,27 @@ +import type { GatewayEvent, GatewayEventName } from '../types' + +type Listener = (event: GatewayEvent) => void + +export class EventBus { + private listeners = new Set() + private sequence = 0 + + subscribe(listener: Listener): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + publish(type: GatewayEventName, payload: Record): GatewayEvent { + const event: GatewayEvent = { + id: `${Date.now()}-${++this.sequence}`, + type, + at: new Date().toISOString(), + payload + } + + for (const listener of this.listeners) listener(event) + return event + } +} + +export const eventBus = new EventBus() diff --git a/src/services/headscale-adapter.ts b/src/services/headscale-adapter.ts new file mode 100644 index 0000000..2061779 --- /dev/null +++ b/src/services/headscale-adapter.ts @@ -0,0 +1,241 @@ +import { config } from '../config' +import type { AccessGrant, AccessSyncAssignment } from '../types' + +type HeadscaleNode = Record + +type HeadscalePolicy = { + groups: Record + tagOwners: Record + acls: Array<{ + action: 'accept' + src: string[] + dst: string[] + }> +} + +const generatedPrefix = 'group:lms-platform-' + +export class HeadscaleAdapter { + async applyGrant(_grant: AccessGrant): Promise<{ applied: boolean; mode: 'noop' | 'headscale' }> { + return { applied: true, mode: 'noop' } + } + + async revokeGrant(_grantId: string): Promise<{ revoked: boolean; mode: 'noop' | 'headscale' }> { + return { revoked: true, mode: 'noop' } + } + + async syncAssignments(assignments: AccessSyncAssignment[]): Promise<{ + applied: boolean + mode: 'headscale' + assignmentCount: number + aclCount: number + unresolved: string[] + error?: string + }> { + const nodes = await this.listNodes() + const unresolved: string[] = [] + const groups: Record = {} + const acls: HeadscalePolicy['acls'] = [] + + assignments.forEach((assignment, index) => { + const users = [...new Set(assignment.studentEmails.map((email) => this.toHeadscaleUserName(email)).filter(Boolean))] + if (!users.length) return + + const node = this.findPlatformNode(nodes, assignment) + const ip = node ? this.extractIpAddress(node) : null + if (!ip) { + unresolved.push(assignment.platformName || assignment.agentHostname || assignment.platformId) + return + } + + const groupName = `${generatedPrefix}${this.toPolicyToken(assignment.platformId || assignment.agentHostname || String(index))}` + groups[groupName] = users + acls.push({ + action: 'accept', + src: [groupName], + dst: [`${ip}:*`], + }) + }) + + const policy: HeadscalePolicy = { + groups, + tagOwners: { + 'tag:teaching-device': ['autogroup:admin'], + }, + acls, + } + + const result = await this.applyPolicy(policy) + return { + applied: result.applied, + mode: 'headscale', + assignmentCount: assignments.length, + aclCount: acls.length, + unresolved, + ...(result.error ? { error: result.error } : {}), + } + } + + private async applyPolicy(policy: HeadscalePolicy): Promise<{ applied: boolean; error?: string }> { + if (!config.headscaleApiKey) return { applied: false, error: 'HEADSCALE_API_KEY is not configured' } + + const policyText = JSON.stringify(policy, null, 2) + const attempts: Array<{ method: 'PUT' | 'POST'; body: unknown }> = [ + { method: 'PUT', body: { policy: policyText } }, + { method: 'POST', body: { policy: policyText } }, + { method: 'PUT', body: policy }, + { method: 'POST', body: policy }, + ] + + let lastError = '' + for (const attempt of attempts) { + const response = await this.fetchHeadscale('/api/v1/policy', { + method: attempt.method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(attempt.body), + }).catch((error) => { + lastError = error instanceof Error ? error.message : String(error) + return null + }) + if (!response) continue + const payload = await response.text().catch(() => '') + if (response.ok) return { applied: true } + lastError = payload || `policy_apply_failed:${response.status}` + } + + return { applied: false, error: lastError || 'policy_apply_failed' } + } + + private async listNodes(): Promise { + if (!config.headscaleApiKey) return [] + + const response = await this.fetchHeadscale('/api/v1/node') + if (!response.ok && response.status === 404) { + const legacyResponse = await this.fetchHeadscale('/api/v1/machine') + return this.extractNodeList(await legacyResponse.json().catch(() => null)) + } + + return this.extractNodeList(await response.json().catch(() => null)) + } + + private fetchHeadscale(path: string, init?: RequestInit) { + return fetch(`${config.headscaleBaseUrl}${path}`, { + ...init, + headers: { + authorization: `Bearer ${config.headscaleApiKey}`, + ...(init?.headers ?? {}), + }, + }) + } + + private extractNodeList(payload: unknown): HeadscaleNode[] { + if (Array.isArray(payload)) return payload as HeadscaleNode[] + if (!payload || typeof payload !== 'object') return [] + + const record = payload as Record + for (const key of ['nodes', 'machines']) { + const value = record[key] + if (Array.isArray(value)) return value as HeadscaleNode[] + } + + return [] + } + + private findPlatformNode(nodes: HeadscaleNode[], assignment: AccessSyncAssignment): HeadscaleNode | null { + const tokens = [ + assignment.platformId, + assignment.platformName, + assignment.agentHostname, + assignment.agentOwnerEmail, + ] + .map((value) => this.normalize(String(value ?? ''))) + .filter(Boolean) + + return nodes.find((node) => { + if (!this.extractTags(node).includes('tag:teaching-device')) return false + const nodeTokens = [ + this.readString(node, 'givenName'), + this.readString(node, 'given_name'), + this.readString(node, 'name'), + this.readString(node, 'hostname'), + this.extractUserName(node), + ] + .map((value) => this.normalize(value)) + .filter(Boolean) + + return nodeTokens.some((nodeToken) => + tokens.some( + (token) => + nodeToken === token || + nodeToken.includes(token) || + token.includes(nodeToken), + ), + ) + }) ?? null + } + + private extractIpAddress(node: HeadscaleNode): string | null { + const values = [ + node.ipAddresses, + node.ip_addresses, + node.ipAddress, + node.ip_address, + ] + + for (const value of values) { + 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 + } + } + + return null + } + + private extractTags(node: HeadscaleNode): string[] { + const value = node.tags ?? node.Tags + return Array.isArray(value) ? value.map(String) : [] + } + + private extractUserName(node: HeadscaleNode): string { + const user = node.user + if (typeof user === 'string') return user + if (user && typeof user === 'object') { + const record = user as Record + return this.readString(record, 'name') || this.readString(record, 'username') || this.readString(record, 'email') + } + return this.readString(node, 'user') || this.readString(node, 'username') + } + + private readString(record: Record, key: string) { + const value = record[key] + if (typeof value === 'string') return value + if (typeof value === 'number') return String(value) + return '' + } + + private isTailscaleIp(value: string) { + return /^100\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(value) + } + + private normalize(value: string) { + return value + .trim() + .toLowerCase() + .replace(/@/g, '-at-') + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + } + + private toPolicyToken(value: string) { + return this.normalize(value).slice(0, 48) || 'unknown' + } + + private toHeadscaleUserName(email: string) { + return this.normalize(email.replace(/@/g, '-at-')).slice(0, 63) + } +} + +export const headscaleAdapter = new HeadscaleAdapter() diff --git a/src/services/headscale-node-discovery.ts b/src/services/headscale-node-discovery.ts new file mode 100644 index 0000000..94fd55a --- /dev/null +++ b/src/services/headscale-node-discovery.ts @@ -0,0 +1,266 @@ +import { config } from '../config' +import type { Platform } from '../types' + +type HeadscaleNode = Record + +export type ResolvedAgent = { + agentUrl: string + lastSeenAt: string | null + sshUser: string | null + workspaceRoot: string | null +} + +export type TeachingAgentNode = { + id: string + hostname: string + name: string + user: string + tags: string[] + ip: string + sshUser: string | null + workspaceRoot: string | null + connected: boolean + expired: boolean + lastSeenAt: string | null +} + +class HeadscaleNodeDiscovery { + async listTeachingAgents(accountEmail?: string): Promise { + const nodes = await this.listNodes().catch(() => []) + const agents = nodes + .map((node) => this.toTeachingAgent(node)) + .filter((node): node is TeachingAgentNode => Boolean(node)) + .filter((node) => node.tags.includes('tag:teaching-device')) + return Promise.all(agents.map((agent) => this.enrichAgent(agent))) + } + + async resolveAgent(platform: Platform, accountEmail?: string): Promise { + const nodes = await this.listNodes().catch(() => []) + const accountNode = accountEmail + ? nodes.find((candidate) => this.matchesAccount(candidate, accountEmail)) + : null + const node = accountNode ?? nodes.find((candidate) => this.matchesPlatform(candidate, platform)) + if (!node) return null + + const ipAddress = this.extractIpAddress(node) + if (!ipAddress) return null + + const health = await this.readAgentHealth(ipAddress).catch(() => null) + return { + agentUrl: `http://${ipAddress}:${config.agentPort}`, + lastSeenAt: this.extractLastSeenAt(node), + sshUser: health?.sshUser ?? null, + workspaceRoot: health?.workspaceRoot ?? null, + } + } + + private async listNodes(): Promise { + if (!config.headscaleApiKey) return [] + + const response = await this.fetchHeadscale('/api/v1/node') + if (!response.ok && response.status === 404) { + const legacyResponse = await this.fetchHeadscale('/api/v1/machine') + return this.extractNodeList(await legacyResponse.json().catch(() => null)) + } + + return this.extractNodeList(await response.json().catch(() => null)) + } + + private fetchHeadscale(path: string) { + return fetch(`${config.headscaleBaseUrl}${path}`, { + headers: { authorization: `Bearer ${config.headscaleApiKey}` }, + }) + } + + private extractNodeList(payload: unknown): HeadscaleNode[] { + if (Array.isArray(payload)) return payload as HeadscaleNode[] + if (!payload || typeof payload !== 'object') return [] + + const record = payload as Record + for (const key of ['nodes', 'machines']) { + const value = record[key] + if (Array.isArray(value)) return value as HeadscaleNode[] + } + + return [] + } + + private toTeachingAgent(node: HeadscaleNode): TeachingAgentNode | null { + const ip = this.extractIpAddress(node) + if (!ip) return null + const tags = this.extractTags(node) + return { + id: this.readString(node, 'id') || this.readString(node, 'ID') || this.readString(node, 'nodeId') || this.readString(node, 'node_id') || ip, + hostname: this.readString(node, 'hostname') || this.readString(node, 'givenName') || this.readString(node, 'given_name') || this.readString(node, 'name'), + name: this.readString(node, 'name') || this.readString(node, 'givenName') || this.readString(node, 'given_name') || this.readString(node, 'hostname'), + user: this.extractUserName(node), + tags, + ip, + sshUser: null, + workspaceRoot: null, + connected: this.readBoolean(node, 'online') || this.readBoolean(node, 'connected') || this.readString(node, 'connected').toLowerCase() === 'online', + expired: this.readBoolean(node, 'expired'), + lastSeenAt: this.extractLastSeenAt(node), + } + } + + private async enrichAgent(agent: TeachingAgentNode): Promise { + if (!agent.connected || agent.expired) return agent + const health = await this.readAgentHealth(agent.ip).catch(() => null) + return { + ...agent, + sshUser: health?.sshUser ?? null, + workspaceRoot: health?.workspaceRoot ?? null, + } + } + + private async readAgentHealth(ip: string): Promise<{ sshUser: string | null; workspaceRoot: string | null } | null> { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), Math.min(config.agentRequestTimeoutMs, 1500)) + try { + const response = await fetch(`http://${ip}:${config.agentPort}/health`, { + signal: controller.signal, + }) + if (!response.ok) return null + const payload = (await response.json().catch(() => null)) as Record | null + const sshUser = typeof payload?.sshUser === 'string' && payload.sshUser.trim() + ? payload.sshUser.trim() + : null + const workspaceRoot = typeof payload?.workspaceRoot === 'string' && payload.workspaceRoot.trim() + ? payload.workspaceRoot.trim() + : null + return { sshUser, workspaceRoot } + } finally { + clearTimeout(timeout) + } + } + + private matchesPlatform(node: HeadscaleNode, platform: Platform) { + const platformTokens = [ + platform.id, + platform.name, + platform.nodeKey, + ...platform.tags, + ] + .map((value) => this.normalize(String(value))) + .filter(Boolean) + + const nodeTokens = [ + this.readString(node, 'givenName'), + this.readString(node, 'given_name'), + this.readString(node, 'name'), + this.readString(node, 'hostname'), + this.readString(node, 'machineKey'), + this.readString(node, 'machine_key'), + this.readString(node, 'nodeKey'), + this.readString(node, 'node_key'), + ] + .map((value) => this.normalize(value)) + .filter(Boolean) + + return nodeTokens.some((nodeToken) => + platformTokens.some( + (platformToken) => + nodeToken === platformToken || + nodeToken.includes(platformToken) || + platformToken.includes(nodeToken), + ), + ) + } + + private matchesAccount(node: HeadscaleNode, accountEmail: string) { + const accountToken = this.normalizeAccount(accountEmail) + if (!accountToken) return false + + const user = node.user + const userRecord = user && typeof user === 'object' ? (user as Record) : null + const nodeUserTokens = [ + typeof user === 'string' ? user : '', + userRecord ? this.readString(userRecord, 'name') : '', + userRecord ? this.readString(userRecord, 'email') : '', + this.readString(node, 'user'), + this.readString(node, 'username'), + this.readString(node, 'headscaleUser'), + this.readString(node, 'headscale_user'), + ] + .map((value) => this.normalizeAccount(value)) + .filter(Boolean) + + return nodeUserTokens.includes(accountToken) + } + + private extractIpAddress(node: HeadscaleNode): string | null { + const values = [ + node.ipAddresses, + node.ip_addresses, + node.ipAddress, + node.ip_address, + ] + + for (const value of values) { + 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 + } + } + + return null + } + + private extractLastSeenAt(node: HeadscaleNode): string | null { + return ( + this.readString(node, 'lastSeen') || + this.readString(node, 'last_seen') || + this.readString(node, 'createdAt') || + this.readString(node, 'created_at') || + null + ) + } + + private extractTags(node: HeadscaleNode): string[] { + const value = node.tags ?? node.Tags + return Array.isArray(value) ? value.map(String) : [] + } + + private extractUserName(node: HeadscaleNode): string { + const user = node.user + if (typeof user === 'string') return user + if (user && typeof user === 'object') { + const record = user as Record + return this.readString(record, 'name') || this.readString(record, 'email') + } + return this.readString(node, 'user') || this.readString(node, 'username') + } + + private readString(record: Record, key: string) { + const value = record[key] + if (typeof value === 'string') return value + if (typeof value === 'number') return String(value) + return '' + } + + private readBoolean(record: Record, key: string) { + return record[key] === true + } + + private isTailscaleIp(value: string) { + return /^100\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(value) + } + + private normalize(value: string) { + return value + .trim() + .toLowerCase() + .replace(/@/g, '-at-') + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + } + + private normalizeAccount(value: string) { + return this.normalize(value.replace(/@/g, '-at-')) + } +} + +export const headscaleNodeDiscovery = new HeadscaleNodeDiscovery() diff --git a/src/services/platform-registry.ts b/src/services/platform-registry.ts new file mode 100644 index 0000000..ede9913 --- /dev/null +++ b/src/services/platform-registry.ts @@ -0,0 +1,178 @@ +import type { AccessGrant, ConnectionView, Platform, PlatformSession, PlatformStatus } from '../types' +import { headscaleNodeDiscovery } from './headscale-node-discovery' + +const nowIso = () => new Date().toISOString() + +function platformListFromEnv(): Platform[] | null { + try { + const raw = process.env.PLATFORM_REGISTRY_JSON; + if (!raw?.trim()) return null; + const parsed = JSON.parse(raw) as Platform[]; + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +const defaultPlatforms: Platform[] = [ + { + id: 'futuredrive-f1tenth-01', + name: 'F1Tenth', + nodeKey: 'futuredrive-f1tenth-01', + agentUrl: '', + headscaleUser: 'mobility', + tags: ['jetson', 'ros', 'f1tenth'], + state: 'offline', + lastSeenAt: null + }, + { + id: 'futuredrive-mkmini-02', + name: 'MK-mini', + nodeKey: 'futuredrive-mkmini-02', + agentUrl: '', + headscaleUser: 'mobility', + tags: ['jetson', 'ros', 'mkmini'], + state: 'offline', + lastSeenAt: null + }, + { + id: 'futuredrive-simulator-01', + name: 'Simulator', + nodeKey: 'futuredrive-simulator-01', + agentUrl: '', + headscaleUser: 'mobility', + tags: ['simulator', 'ros'], + state: 'offline', + lastSeenAt: null + } +]; + +const platforms = new Map((platformListFromEnv() ?? defaultPlatforms).map((platform) => [platform.id, platform])); + +/* +const platforms = new Map([ + [ + 'jetson-lab-01', + { + id: 'jetson-lab-01', + name: 'Jetson Lab 01', + nodeKey: 'headscale-node-jetson-lab-01', + agentUrl: 'http://100.64.0.11:4600', + headscaleUser: 'mobility', + tags: ['jetson', 'ros', 'lab'], + state: 'ready', + lastSeenAt: nowIso() + } + ], + [ + 'sim-lab-01', + { + id: 'sim-lab-01', + name: 'Simulator Lab 01', + nodeKey: 'headscale-node-sim-lab-01', + agentUrl: 'http://100.64.0.21:4600', + headscaleUser: 'mobility', + tags: ['simulator', 'ros'], + state: 'ready', + lastSeenAt: nowIso() + } + ] +]) +*/ + +const statusByPlatformId = new Map( + Array.from(platforms.values()).map((platform) => [ + platform.id, + { + platformId: platform.id, + batteryPercent: platform.id.startsWith('jetson') ? 86 : null, + network: 'online', + ros: 'ok', + processes: [ + { name: 'mobility-agent', status: 'running' }, + { name: 'roscore', status: 'running' } + ], + updatedAt: nowIso() + } + ]) +) + +export class PlatformRegistry { + listPlatforms(): Platform[] { + return Array.from(platforms.values()) + } + + async resolvePlatform(platformId: string, accountEmail?: string): Promise { + const platform = this.getPlatform(platformId) ?? { + id: platformId, + name: platformId, + nodeKey: platformId, + agentUrl: '', + headscaleUser: 'tagged-devices', + tags: ['teaching-device'], + state: 'offline' as const, + lastSeenAt: null, + sshUser: null, + workspaceRoot: null, + } + if (!platform) return null + if (!accountEmail && platform.agentUrl) return platform + + const resolvedAgent = await headscaleNodeDiscovery.resolveAgent(platform, accountEmail) + if (!resolvedAgent) { + const next = { ...platform, agentUrl: '', state: 'offline' as const, lastSeenAt: platform.lastSeenAt } + platforms.set(platformId, next) + this.updateNetworkStatus(platformId, 'offline') + return next + } + + const next = { + ...platform, + agentUrl: resolvedAgent.agentUrl, + state: platform.state === 'maintenance' ? platform.state : 'ready' as const, + lastSeenAt: resolvedAgent.lastSeenAt ?? nowIso(), + sshUser: resolvedAgent.sshUser ?? platform.sshUser ?? null, + workspaceRoot: resolvedAgent.workspaceRoot ?? platform.workspaceRoot ?? null, + } + platforms.set(platformId, next) + this.updateNetworkStatus(platformId, 'online') + return next + } + + getPlatform(platformId: string): Platform | null { + return platforms.get(platformId) ?? null + } + + getStatus(platformId: string): PlatformStatus | null { + return statusByPlatformId.get(platformId) ?? null + } + + setPlatformState(platformId: string, state: Platform['state']): Platform | null { + const platform = platforms.get(platformId) + if (!platform) return null + const next = { ...platform, state, lastSeenAt: nowIso() } + platforms.set(platformId, next) + return next + } + + listConnections(activeSessions: PlatformSession[], activeGrants: AccessGrant[]): ConnectionView[] { + return this.listPlatforms().map((platform) => ({ + platform, + activeSession: activeSessions.find((session) => session.platformId === platform.id) ?? null, + activeGrants: activeGrants.filter((grant) => grant.platformId === platform.id), + status: this.getStatus(platform.id)! + })) + } + + private updateNetworkStatus(platformId: string, network: PlatformStatus['network']) { + const status = statusByPlatformId.get(platformId) + if (!status) return + statusByPlatformId.set(platformId, { + ...status, + network, + updatedAt: nowIso() + }) + } +} + +export const platformRegistry = new PlatformRegistry() diff --git a/src/services/session-manager.ts b/src/services/session-manager.ts new file mode 100644 index 0000000..d33b8b7 --- /dev/null +++ b/src/services/session-manager.ts @@ -0,0 +1,81 @@ +import type { AuthUser, PlatformSession } from '../types' +import { accessControl } from './access-control' +import { eventBus } from './event-bus' +import { platformRegistry } from './platform-registry' + +const sessions = new Map() +const nowIso = () => new Date().toISOString() + +export class SessionManager { + listActiveSessions(): PlatformSession[] { + this.expireOldSessions() + return Array.from(sessions.values()).filter((session) => session.status === 'active') + } + + getActiveSessionByPlatform(platformId: string): PlatformSession | null { + return this.listActiveSessions().find((session) => session.platformId === platformId) ?? null + } + + start(input: { platformId: string; user: AuthUser; ttlSeconds: number }): PlatformSession { + const platform = platformRegistry.getPlatform(input.platformId) + if (!platform) throw new Error('platform_not_found') + if (platform.state === 'offline' || platform.state === 'maintenance' || platform.state === 'emergency_stopped') { + throw new Error('platform_unavailable') + } + if (!accessControl.canUsePlatform(input.user, input.platformId)) { + throw new Error('platform_access_denied') + } + if (this.getActiveSessionByPlatform(input.platformId)) { + throw new Error('platform_busy') + } + + const session: PlatformSession = { + id: crypto.randomUUID(), + platformId: input.platformId, + userId: input.user.id, + status: 'active', + startedAt: nowIso(), + expiresAt: new Date(Date.now() + input.ttlSeconds * 1000).toISOString(), + releasedAt: null + } + + sessions.set(session.id, session) + platformRegistry.setPlatformState(input.platformId, 'busy') + eventBus.publish('platform.session.started', { session }) + return session + } + + release(platformId: string, user: AuthUser): PlatformSession | null { + const session = this.getActiveSessionByPlatform(platformId) + if (!session) return null + if (session.userId !== user.id && !['ADMIN', 'INSTRUCTOR'].includes(String(user.role ?? '').toUpperCase())) { + throw new Error('session_not_owned') + } + + const next: PlatformSession = { ...session, status: 'released', releasedAt: nowIso() } + sessions.set(session.id, next) + platformRegistry.setPlatformState(platformId, 'ready') + return next + } + + markEmergencyStopped(platformId: string): PlatformSession | null { + const session = this.getActiveSessionByPlatform(platformId) + if (!session) return null + const next: PlatformSession = { ...session, status: 'emergency_stopped', releasedAt: nowIso() } + sessions.set(session.id, next) + return next + } + + private expireOldSessions(): void { + const now = Date.now() + for (const session of sessions.values()) { + if (session.status !== 'active' || Date.parse(session.expiresAt) > now) continue + const next: PlatformSession = { ...session, status: 'expired', releasedAt: nowIso() } + sessions.set(session.id, next) + platformRegistry.setPlatformState(session.platformId, 'ready') + eventBus.publish('platform.session.expired', { session: next }) + } + } +} + +export const sessionManager = new SessionManager() diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..cad9ee1 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,84 @@ +export type UserRole = 'ADMIN' | 'INSTRUCTOR' | 'STUDENT' | string + +export type AuthUser = { + id: string + email?: string + name?: string + role?: UserRole +} + +export type PlatformState = 'ready' | 'busy' | 'offline' | 'maintenance' | 'emergency_stopped' + +export type Platform = { + id: string + name: string + nodeKey: string + agentUrl: string + headscaleUser: string + tags: string[] + state: PlatformState + lastSeenAt: string | null + sshUser?: string | null + workspaceRoot?: string | null +} + +export type PlatformStatus = { + platformId: string + batteryPercent: number | null + network: 'online' | 'degraded' | 'offline' + ros: 'ok' | 'warning' | 'error' | 'unknown' + processes: Array<{ name: string; status: 'running' | 'stopped' | 'error' }> + updatedAt: string +} + +export type AccessGrant = { + id: string + platformId: string + granteeUserId: string + grantedByUserId: string + reason?: string + expiresAt: string + createdAt: string + revokedAt: string | null +} + +export type AccessSyncAssignment = { + platformId: string + platformName?: string | null + agentHostname?: string | null + agentOwnerEmail?: string | null + studentEmails: string[] +} + +export type PlatformSession = { + id: string + platformId: string + userId: string + status: 'active' | 'released' | 'expired' | 'emergency_stopped' + startedAt: string + expiresAt: string + releasedAt: string | null +} + +export type ConnectionView = { + platform: Platform + activeSession: PlatformSession | null + activeGrants: AccessGrant[] + status: PlatformStatus +} + +export type GatewayEventName = + | 'platform.status.updated' + | 'platform.session.started' + | 'platform.session.expired' + | 'platform.command.output' + | 'platform.emergency.stop' + | 'platform.access.granted' + | 'platform.access.revoked' + +export type GatewayEvent = { + id: string + type: GatewayEventName + at: string + payload: Record +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5c3ff17 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "types": ["bun"], + "noEmit": true + }, + "include": ["src/**/*.ts"] +}