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
+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()