initial platform gateway service
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user