initial platform gateway service
This commit is contained in:
@@ -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}]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -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.
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, Array<{ publicKey: string }>>))
|
||||||
|
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<Record<string, Array<{ publicKey: string }>>> {
|
||||||
|
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<string, Array<{ publicKey: string }>> }
|
||||||
|
return payload.keysByEmail ?? {}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { AccessGrant, AuthUser } from '../types'
|
||||||
|
import { eventBus } from './event-bus'
|
||||||
|
|
||||||
|
const grants = new Map<string, AccessGrant>()
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -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<unknown> {
|
||||||
|
return this.agentJson(platform, '/api/modules/docker-statuses');
|
||||||
|
}
|
||||||
|
|
||||||
|
async moduleStart(platform: Platform, input: { folderName?: string; rosDomainId?: string }): Promise<unknown> {
|
||||||
|
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<unknown> {
|
||||||
|
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<unknown> {
|
||||||
|
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<T>(platform: Platform, path: string, init?: RequestInit): Promise<T> {
|
||||||
|
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()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { GatewayEvent, GatewayEventName } from '../types'
|
||||||
|
|
||||||
|
type Listener = (event: GatewayEvent) => void
|
||||||
|
|
||||||
|
export class EventBus {
|
||||||
|
private listeners = new Set<Listener>()
|
||||||
|
private sequence = 0
|
||||||
|
|
||||||
|
subscribe(listener: Listener): () => void {
|
||||||
|
this.listeners.add(listener)
|
||||||
|
return () => this.listeners.delete(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(type: GatewayEventName, payload: Record<string, unknown>): 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()
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { config } from '../config'
|
||||||
|
import type { AccessGrant, AccessSyncAssignment } from '../types'
|
||||||
|
|
||||||
|
type HeadscaleNode = Record<string, unknown>
|
||||||
|
|
||||||
|
type HeadscalePolicy = {
|
||||||
|
groups: Record<string, string[]>
|
||||||
|
tagOwners: Record<string, string[]>
|
||||||
|
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<string, string[]> = {}
|
||||||
|
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<HeadscaleNode[]> {
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>, 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()
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { config } from '../config'
|
||||||
|
import type { Platform } from '../types'
|
||||||
|
|
||||||
|
type HeadscaleNode = Record<string, unknown>
|
||||||
|
|
||||||
|
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<TeachingAgentNode[]> {
|
||||||
|
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<ResolvedAgent | null> {
|
||||||
|
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<HeadscaleNode[]> {
|
||||||
|
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<string, unknown>
|
||||||
|
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<TeachingAgentNode> {
|
||||||
|
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<string, unknown> | 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<string, unknown>) : 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<string, unknown>
|
||||||
|
return this.readString(record, 'name') || this.readString(record, 'email')
|
||||||
|
}
|
||||||
|
return this.readString(node, 'user') || this.readString(node, 'username')
|
||||||
|
}
|
||||||
|
|
||||||
|
private readString(record: Record<string, unknown>, key: string) {
|
||||||
|
const value = record[key]
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
if (typeof value === 'number') return String(value)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private readBoolean(record: Record<string, unknown>, 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()
|
||||||
@@ -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<string, Platform>((platformListFromEnv() ?? defaultPlatforms).map((platform) => [platform.id, platform]));
|
||||||
|
|
||||||
|
/*
|
||||||
|
const platforms = new Map<string, Platform>([
|
||||||
|
[
|
||||||
|
'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<string, PlatformStatus>(
|
||||||
|
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<Platform | null> {
|
||||||
|
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()
|
||||||
@@ -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<string, PlatformSession>()
|
||||||
|
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()
|
||||||
@@ -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<string, unknown>
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["bun"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user