diff --git a/.env.example b/.env.example index 1eb326e..5b62c82 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ AGENT_PORT=3100 AGENT_GATEWAY_TOKEN= HEADSCALE_BASE_URL=https://atnmy-hs.duckdns.org HEADSCALE_API_KEY= +HEADSCALE_TEACHING_DEVICE_OWNER=tagged-devices LMS_GATEWAY_TOKEN=change-me # Headscale must have a policy path configured on the server for /api/v1/policy diff --git a/src/config.ts b/src/config.ts index 6d665c4..a0e1410 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,7 @@ export const config = { agentGatewayToken: process.env.AGENT_GATEWAY_TOKEN ?? '', headscaleBaseUrl: required('HEADSCALE_BASE_URL', 'http://localhost:8080'), headscaleApiKey: process.env.HEADSCALE_API_KEY ?? '', + headscaleTeachingDeviceOwner: (process.env.HEADSCALE_TEACHING_DEVICE_OWNER ?? 'tagged-devices').trim().replace(/@+$/, ''), 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 ?? '', adminGatewayToken: process.env.ADMIN_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '', diff --git a/src/routes/lms.ts b/src/routes/lms.ts index bff0c44..7b6f14f 100644 --- a/src/routes/lms.ts +++ b/src/routes/lms.ts @@ -17,7 +17,9 @@ const sessionSchema = z.object({ name: z.string().optional(), role: z.string().optional(), }), - agentOwnerEmail: z.string().email().optional(), + // LMS stores the Headscale user identifier here. It may be an email address + // or a Headscale-only username such as "tagged-devices". + agentOwnerEmail: z.string().trim().min(1).optional(), ttlSeconds: z.number().int().positive().max(8 * 60 * 60).default(config.defaultSessionTtlSeconds), }) @@ -27,6 +29,16 @@ const visualizationSchema = z.object({ profile: z.unknown().optional(), }) +const extendSessionSchema = z.object({ + user: z.object({ + id: z.string().min(1), + email: z.string().optional(), + name: z.string().optional(), + role: z.string().optional(), + }), + ttlSeconds: z.number().int().positive().max(8 * 60 * 60), +}) + const accessSyncSchema = z.object({ assignments: z.array(z.object({ platformId: z.string().min(1), @@ -69,11 +81,15 @@ async function createPlatformSession(c: Context) { platformId: body.data.platformId, ttlSeconds: body.data.ttlSeconds, user: body.data.user as AuthUser, + // LMS authorizes the student against its course/group assignment before + // calling this route. The gateway's in-memory grants are for its own API. + skipAccessCheck: true, }) 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_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) } @@ -83,6 +99,26 @@ async function createPlatformSession(c: Context) { lmsRoutes.post('/lms/platform-sessions', createPlatformSession) +lmsRoutes.post('/lms/platform-sessions/:platformId/extend', async (c) => { + const body = extendSessionSchema.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.extend( + c.req.param('platformId'), + body.data.user as AuthUser, + body.data.ttlSeconds, + ) + if (!session) return c.json({ error: 'platform_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 + } +}) + lmsRoutes.get('/lms/agents', async (c) => { const accountEmail = c.req.query('accountEmail') || undefined return c.json({ agents: await headscaleNodeDiscovery.listTeachingAgents(accountEmail) }) diff --git a/src/services/headscale-adapter.ts b/src/services/headscale-adapter.ts index 5cb9964..3e53031 100644 --- a/src/services/headscale-adapter.ts +++ b/src/services/headscale-adapter.ts @@ -103,7 +103,7 @@ export class HeadscaleAdapter { const policy: HeadscalePolicy = { groups, tagOwners: { - 'tag:teaching-device': ['autogroup:admin'], + 'tag:teaching-device': [`${config.headscaleTeachingDeviceOwner}@`], }, acls, } @@ -244,11 +244,11 @@ export class HeadscaleAdapter { } private findPlatformNode(nodes: HeadscaleNode[], assignment: AccessSyncAssignment): HeadscaleNode | null { + // A single instructor account can own multiple devices. ACL targets must + // be resolved by the platform's saved node/hostname, never by owner email. const tokens = [ assignment.platformId, - assignment.platformName, assignment.agentHostname, - assignment.agentOwnerEmail, ] .map((value) => this.normalize(String(value ?? ''))) .filter(Boolean) @@ -260,19 +260,11 @@ export class HeadscaleAdapter { 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), - ), - ) + return nodeTokens.some((nodeToken) => tokens.includes(nodeToken)) }) ?? null } @@ -336,7 +328,7 @@ export class HeadscaleAdapter { } private toHeadscaleUserName(email: string) { - return this.normalize(email.replace(/@/g, '-at-')).slice(0, 63) + return `${this.normalize(email.replace(/@/g, '-at-')).slice(0, 63)}@` } } diff --git a/src/services/headscale-node-discovery.ts b/src/services/headscale-node-discovery.ts index c8ea43b..86277c2 100644 --- a/src/services/headscale-node-discovery.ts +++ b/src/services/headscale-node-discovery.ts @@ -108,10 +108,13 @@ class HeadscaleNodeDiscovery { async resolveAgent(platform: Platform, accountEmail?: string): Promise { const nodes = await this.listNodes().catch(() => []) + // A platform binding identifies a device by its node ID or hostname. The + // account can own several teaching devices, so it is only a legacy fallback. + const platformNode = nodes.find((candidate) => this.matchesPlatform(candidate, platform)) const accountNode = accountEmail ? nodes.find((candidate) => this.matchesAccount(candidate, accountEmail)) : null - const node = accountNode ?? nodes.find((candidate) => this.matchesPlatform(candidate, platform)) + const node = platformNode ?? accountNode if (!node) return null const ipAddress = this.extractIpAddress(node) @@ -230,14 +233,10 @@ class HeadscaleNodeDiscovery { .map((value) => this.normalize(value)) .filter(Boolean) - return nodeTokens.some((nodeToken) => - platformTokens.some( - (platformToken) => - nodeToken === platformToken || - nodeToken.includes(platformToken) || - platformToken.includes(nodeToken), - ), - ) + // Platform bindings carry the Headscale node hostname or ID. Partial + // matching makes a stale generic hostname such as "ubuntu" win over the + // explicitly registered hostname (for example teacher-...-ubuntu). + return nodeTokens.some((nodeToken) => platformTokens.includes(nodeToken)) } private matchesAccount(node: HeadscaleNode, accountEmail: string) { diff --git a/src/services/session-manager.ts b/src/services/session-manager.ts index d33b8b7..7f8df2e 100644 --- a/src/services/session-manager.ts +++ b/src/services/session-manager.ts @@ -16,13 +16,13 @@ export class SessionManager { return this.listActiveSessions().find((session) => session.platformId === platformId) ?? null } - start(input: { platformId: string; user: AuthUser; ttlSeconds: number }): PlatformSession { + start(input: { platformId: string; user: AuthUser; ttlSeconds: number; skipAccessCheck?: boolean }): 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)) { + if (!input.skipAccessCheck && !accessControl.canUsePlatform(input.user, input.platformId)) { throw new Error('platform_access_denied') } if (this.getActiveSessionByPlatform(input.platformId)) { @@ -58,6 +58,21 @@ export class SessionManager { return next } + extend(platformId: string, user: AuthUser, ttlSeconds: number): 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, + expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(), + } + sessions.set(session.id, next) + return next + } + markEmergencyStopped(platformId: string): PlatformSession | null { const session = this.getActiveSessionByPlatform(platformId) if (!session) return null