initial platform gateway service

This commit is contained in:
2026-07-08 14:44:20 +09:00
commit 05d0fd80f0
21 changed files with 1770 additions and 0 deletions
+29
View File
@@ -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)
}
+42
View File
@@ -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}`)
})
+30
View File
@@ -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)
}
}
+25
View File
@@ -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)
}
})
})
+252
View File
@@ -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 ?? {}
}
+91
View File
@@ -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)
})
+78
View File
@@ -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
}
})
+65
View File
@@ -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()
+151
View File
@@ -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()
+27
View File
@@ -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()
+241
View File
@@ -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()
+266
View File
@@ -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()
+178
View File
@@ -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()
+81
View File
@@ -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()
+84
View File
@@ -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>
}