patch..
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-08-03 13:42:38 +09:00
parent 1ccbe82d64
commit e7d93198a7
6 changed files with 69 additions and 25 deletions
+1
View File
@@ -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
+1
View File
@@ -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 ?? '',
+37 -1
View File
@@ -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) })
+5 -13
View File
@@ -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)}@`
}
}
+8 -9
View File
@@ -108,10 +108,13 @@ class HeadscaleNodeDiscovery {
async resolveAgent(platform: Platform, accountEmail?: string): Promise<ResolvedAgent | null> {
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) {
+17 -2
View File
@@ -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