Compare commits
6 Commits
1ccbe82d64
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 0032085139 | |||
| 6570ebb109 | |||
| 204300c927 | |||
| c90e8d5745 | |||
| 46da0a3f37 | |||
| e7d93198a7 |
@@ -11,6 +11,8 @@ AGENT_PORT=3100
|
|||||||
AGENT_GATEWAY_TOKEN=
|
AGENT_GATEWAY_TOKEN=
|
||||||
HEADSCALE_BASE_URL=https://atnmy-hs.duckdns.org
|
HEADSCALE_BASE_URL=https://atnmy-hs.duckdns.org
|
||||||
HEADSCALE_API_KEY=
|
HEADSCALE_API_KEY=
|
||||||
|
HEADSCALE_GATEWAY_USERS=platform-gateway
|
||||||
|
HEADSCALE_TEACHING_DEVICE_OWNER=tagged-devices
|
||||||
LMS_GATEWAY_TOKEN=change-me
|
LMS_GATEWAY_TOKEN=change-me
|
||||||
|
|
||||||
# Headscale must have a policy path configured on the server for /api/v1/policy
|
# Headscale must have a policy path configured on the server for /api/v1/policy
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ steps:
|
|||||||
volumes:
|
volumes:
|
||||||
- /home/future2/Studio_v2/woodpecker/kube/config:/tmp/kubeconfig:ro
|
- /home/future2/Studio_v2/woodpecker/kube/config:/tmp/kubeconfig:ro
|
||||||
commands:
|
commands:
|
||||||
|
- kubectl --kubeconfig=/tmp/kubeconfig apply -f k8s/platform-gateway.yaml
|
||||||
- kubectl --kubeconfig=/tmp/kubeconfig -n studio-system rollout restart deployment/platform-gateway
|
- kubectl --kubeconfig=/tmp/kubeconfig -n studio-system rollout restart deployment/platform-gateway
|
||||||
- kubectl --kubeconfig=/tmp/kubeconfig -n studio-system rollout status deployment/platform-gateway
|
- kubectl --kubeconfig=/tmp/kubeconfig -n studio-system rollout status deployment/platform-gateway
|
||||||
when:
|
when:
|
||||||
|
|||||||
@@ -30,3 +30,16 @@ Default URL: `http://localhost:4500`
|
|||||||
## Network Model
|
## Network Model
|
||||||
|
|
||||||
Students and instructors never connect directly to Headscale/Tailscale device endpoints. Clients call this gateway only. The gateway verifies auth, checks platform grants/session ownership, records audit context, and proxies allowed operations to the mobility agent running on each platform node.
|
Students and instructors never connect directly to Headscale/Tailscale device endpoints. Clients call this gateway only. The gateway verifies auth, checks platform grants/session ownership, records audit context, and proxies allowed operations to the mobility agent running on each platform node.
|
||||||
|
|
||||||
|
## Kubernetes Tailscale Sidecar
|
||||||
|
|
||||||
|
The Kubernetes deployment runs a Tailscale sidecar in the same network namespace as
|
||||||
|
the gateway. Create the one-time Headscale key secret before the first deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n studio-system create secret generic platform-gateway-tailscale-auth \
|
||||||
|
--from-literal=authkey='tskey-auth-...'
|
||||||
|
```
|
||||||
|
|
||||||
|
The sidecar persists its machine state in `platform-gateway-tailscale-state`, so
|
||||||
|
subsequent Gateway pod restarts do not consume another pre-auth key.
|
||||||
|
|||||||
@@ -1,3 +1,33 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: platform-gateway
|
||||||
|
namespace: studio-system
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: platform-gateway-tailscale-state
|
||||||
|
namespace: studio-system
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["secrets"]
|
||||||
|
verbs: ["get", "create", "update", "patch"]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: platform-gateway-tailscale-state
|
||||||
|
namespace: studio-system
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: platform-gateway
|
||||||
|
namespace: studio-system
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: platform-gateway-tailscale-state
|
||||||
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
@@ -15,7 +45,30 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: platform-gateway
|
app: platform-gateway
|
||||||
spec:
|
spec:
|
||||||
|
serviceAccountName: platform-gateway
|
||||||
containers:
|
containers:
|
||||||
|
- name: tailscale
|
||||||
|
image: tailscale/tailscale:stable
|
||||||
|
env:
|
||||||
|
- name: TS_KUBE_SECRET
|
||||||
|
value: platform-gateway-tailscale-state
|
||||||
|
- name: TS_AUTHKEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: platform-gateway-tailscale-auth
|
||||||
|
key: authkey
|
||||||
|
- name: TS_HOSTNAME
|
||||||
|
value: platform-gateway
|
||||||
|
- name: TS_USERSPACE
|
||||||
|
value: "false"
|
||||||
|
- name: TS_EXTRA_ARGS
|
||||||
|
value: --login-server=https://atnmy-hs.duckdns.org --accept-routes
|
||||||
|
securityContext:
|
||||||
|
capabilities:
|
||||||
|
add: ["NET_ADMIN"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: dev-net-tun
|
||||||
|
mountPath: /dev/net/tun
|
||||||
- name: platform-gateway
|
- name: platform-gateway
|
||||||
image: localhost:5000/platform-gateway:0.1.0
|
image: localhost:5000/platform-gateway:0.1.0
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
@@ -37,6 +90,11 @@ spec:
|
|||||||
port: 4500
|
port: 4500
|
||||||
initialDelaySeconds: 15
|
initialDelaySeconds: 15
|
||||||
periodSeconds: 20
|
periodSeconds: 20
|
||||||
|
volumes:
|
||||||
|
- name: dev-net-tun
|
||||||
|
hostPath:
|
||||||
|
path: /dev/net/tun
|
||||||
|
type: CharDevice
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export const config = {
|
|||||||
agentGatewayToken: process.env.AGENT_GATEWAY_TOKEN ?? '',
|
agentGatewayToken: process.env.AGENT_GATEWAY_TOKEN ?? '',
|
||||||
headscaleBaseUrl: required('HEADSCALE_BASE_URL', 'http://localhost:8080'),
|
headscaleBaseUrl: required('HEADSCALE_BASE_URL', 'http://localhost:8080'),
|
||||||
headscaleApiKey: process.env.HEADSCALE_API_KEY ?? '',
|
headscaleApiKey: process.env.HEADSCALE_API_KEY ?? '',
|
||||||
|
headscaleTeachingDeviceOwner: (process.env.HEADSCALE_TEACHING_DEVICE_OWNER ?? 'tagged-devices').trim().replace(/@+$/, ''),
|
||||||
|
headscaleGatewayUsers: (process.env.HEADSCALE_GATEWAY_USERS ?? process.env.HEADSCALE_GATEWAY_USER ?? 'platform-gateway')
|
||||||
|
.split(',')
|
||||||
|
.map((user) => user.trim().replace(/@+$/, ''))
|
||||||
|
.filter(Boolean),
|
||||||
lmsGatewayToken: process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
|
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 ?? '',
|
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 ?? '',
|
adminGatewayToken: process.env.ADMIN_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ adminRoutes.get('/admin/tailscale/nodes', async (c) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
adminRoutes.delete('/admin/tailscale/nodes/:nodeId', async (c) => {
|
||||||
|
const nodeId = c.req.param('nodeId').trim()
|
||||||
|
if (!nodeId) {
|
||||||
|
return c.json({ error: 'node_id_required' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
await headscaleNodeDiscovery.deleteNode(nodeId)
|
||||||
|
return c.json({ deleted: true, nodeId })
|
||||||
|
})
|
||||||
|
|
||||||
adminRoutes.get('/admin/tailscale/routes', async (c) => {
|
adminRoutes.get('/admin/tailscale/routes', async (c) => {
|
||||||
return c.json({
|
return c.json({
|
||||||
routes: await headscaleNodeDiscovery.listRouteStatus(),
|
routes: await headscaleNodeDiscovery.listRouteStatus(),
|
||||||
|
|||||||
+39
-1
@@ -17,7 +17,9 @@ const sessionSchema = z.object({
|
|||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
role: 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),
|
ttlSeconds: z.number().int().positive().max(8 * 60 * 60).default(config.defaultSessionTtlSeconds),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -27,6 +29,16 @@ const visualizationSchema = z.object({
|
|||||||
profile: z.unknown().optional(),
|
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({
|
const accessSyncSchema = z.object({
|
||||||
assignments: z.array(z.object({
|
assignments: z.array(z.object({
|
||||||
platformId: z.string().min(1),
|
platformId: z.string().min(1),
|
||||||
@@ -69,11 +81,15 @@ async function createPlatformSession(c: Context) {
|
|||||||
platformId: body.data.platformId,
|
platformId: body.data.platformId,
|
||||||
ttlSeconds: body.data.ttlSeconds,
|
ttlSeconds: body.data.ttlSeconds,
|
||||||
user: body.data.user as AuthUser,
|
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)
|
return c.json({ session, connection: connectionPayload(platform) }, 201)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof Error)) throw error
|
if (!(error instanceof Error)) throw error
|
||||||
if (error.message === 'platform_not_found') return c.json({ error: error.message }, 404)
|
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') {
|
if (error.message === 'platform_busy' || error.message === 'platform_unavailable') {
|
||||||
return c.json({ error: error.message }, 409)
|
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', 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) => {
|
lmsRoutes.get('/lms/agents', async (c) => {
|
||||||
const accountEmail = c.req.query('accountEmail') || undefined
|
const accountEmail = c.req.query('accountEmail') || undefined
|
||||||
return c.json({ agents: await headscaleNodeDiscovery.listTeachingAgents(accountEmail) })
|
return c.json({ agents: await headscaleNodeDiscovery.listTeachingAgents(accountEmail) })
|
||||||
@@ -107,6 +143,8 @@ lmsRoutes.get('/lms/platforms/:id/status', async (c) => {
|
|||||||
sshUser: platform.sshUser ?? null,
|
sshUser: platform.sshUser ?? null,
|
||||||
workspaceRoot: platform.workspaceRoot ?? null,
|
workspaceRoot: platform.workspaceRoot ?? null,
|
||||||
lastSeenAt: platform.lastSeenAt,
|
lastSeenAt: platform.lastSeenAt,
|
||||||
|
agentStartedAt: agentHealth.startedAt ?? null,
|
||||||
|
agentUptimeSeconds: agentHealth.uptimeSeconds ?? null,
|
||||||
state: platform.state,
|
state: platform.state,
|
||||||
message: agentHealth.message ?? null,
|
message: agentHealth.message ?? null,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { config } from '../config'
|
|||||||
import type { Platform } from '../types'
|
import type { Platform } from '../types'
|
||||||
|
|
||||||
export class AgentClient {
|
export class AgentClient {
|
||||||
async health(platform: Platform): Promise<{ ok: boolean; message?: string }> {
|
async health(platform: Platform): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
message?: string;
|
||||||
|
startedAt?: string | null;
|
||||||
|
uptimeSeconds?: number | null;
|
||||||
|
}> {
|
||||||
if (!platform.agentUrl) return { ok: false, message: 'platform_agent_url_missing' }
|
if (!platform.agentUrl) return { ok: false, message: 'platform_agent_url_missing' }
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -14,8 +19,14 @@ export class AgentClient {
|
|||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
})
|
})
|
||||||
if (!response.ok) return { ok: false, message: `agent_health_failed:${response.status}` }
|
if (!response.ok) return { ok: false, message: `agent_health_failed:${response.status}` }
|
||||||
const payload = await response.json().catch(() => ({ ok: true }))
|
const payload = await response.json().catch(() => ({ ok: true })) as Record<string, unknown>
|
||||||
return { ok: payload?.ok !== false }
|
return {
|
||||||
|
ok: payload?.ok !== false,
|
||||||
|
startedAt: typeof payload?.startedAt === 'string' ? payload.startedAt : null,
|
||||||
|
uptimeSeconds: typeof payload?.uptimeSeconds === 'number' && Number.isFinite(payload.uptimeSeconds)
|
||||||
|
? Math.max(0, Math.floor(payload.uptimeSeconds))
|
||||||
|
: null,
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ export type HeadscalePolicyStatus = {
|
|||||||
raw: HeadscalePolicy | Record<string, unknown> | null
|
raw: HeadscalePolicy | Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const generatedPrefix = 'group:lms-platform-'
|
|
||||||
|
|
||||||
export class HeadscaleAdapter {
|
export class HeadscaleAdapter {
|
||||||
async applyGrant(_grant: AccessGrant): Promise<{ applied: boolean; mode: 'noop' | 'headscale' }> {
|
async applyGrant(_grant: AccessGrant): Promise<{ applied: boolean; mode: 'noop' | 'headscale' }> {
|
||||||
return { applied: true, mode: 'noop' }
|
return { applied: true, mode: 'noop' }
|
||||||
@@ -75,37 +73,31 @@ export class HeadscaleAdapter {
|
|||||||
unresolved: string[]
|
unresolved: string[]
|
||||||
error?: string
|
error?: string
|
||||||
}> {
|
}> {
|
||||||
const nodes = await this.listNodes()
|
const gatewaySources = [...new Set(
|
||||||
const unresolved: string[] = []
|
config.headscaleGatewayUsers.map((user) => this.toHeadscaleUserName(user)),
|
||||||
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 = {
|
const policy: HeadscalePolicy = {
|
||||||
groups,
|
groups: {},
|
||||||
tagOwners: {
|
tagOwners: {
|
||||||
'tag:teaching-device': ['autogroup:admin'],
|
'tag:teaching-device': [`${config.headscaleTeachingDeviceOwner}@`],
|
||||||
},
|
},
|
||||||
acls,
|
acls: [
|
||||||
|
{
|
||||||
|
action: 'accept',
|
||||||
|
src: ['*'],
|
||||||
|
dst: ['*:*'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'accept',
|
||||||
|
src: gatewaySources,
|
||||||
|
dst: ['*:*'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'accept',
|
||||||
|
src: ['tag:teaching-device'],
|
||||||
|
dst: ['*:*'],
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.applyPolicy(policy)
|
const result = await this.applyPolicy(policy)
|
||||||
@@ -113,8 +105,8 @@ export class HeadscaleAdapter {
|
|||||||
applied: result.applied,
|
applied: result.applied,
|
||||||
mode: 'headscale',
|
mode: 'headscale',
|
||||||
assignmentCount: assignments.length,
|
assignmentCount: assignments.length,
|
||||||
aclCount: acls.length,
|
aclCount: policy.acls.length,
|
||||||
unresolved,
|
unresolved: [],
|
||||||
...(result.error ? { error: result.error } : {}),
|
...(result.error ? { error: result.error } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,11 +236,11 @@ export class HeadscaleAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private findPlatformNode(nodes: HeadscaleNode[], assignment: AccessSyncAssignment): HeadscaleNode | null {
|
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 = [
|
const tokens = [
|
||||||
assignment.platformId,
|
assignment.platformId,
|
||||||
assignment.platformName,
|
|
||||||
assignment.agentHostname,
|
assignment.agentHostname,
|
||||||
assignment.agentOwnerEmail,
|
|
||||||
]
|
]
|
||||||
.map((value) => this.normalize(String(value ?? '')))
|
.map((value) => this.normalize(String(value ?? '')))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -260,19 +252,11 @@ export class HeadscaleAdapter {
|
|||||||
this.readString(node, 'given_name'),
|
this.readString(node, 'given_name'),
|
||||||
this.readString(node, 'name'),
|
this.readString(node, 'name'),
|
||||||
this.readString(node, 'hostname'),
|
this.readString(node, 'hostname'),
|
||||||
this.extractUserName(node),
|
|
||||||
]
|
]
|
||||||
.map((value) => this.normalize(value))
|
.map((value) => this.normalize(value))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
return nodeTokens.some((nodeToken) =>
|
return nodeTokens.some((nodeToken) => tokens.includes(nodeToken))
|
||||||
tokens.some(
|
|
||||||
(token) =>
|
|
||||||
nodeToken === token ||
|
|
||||||
nodeToken.includes(token) ||
|
|
||||||
token.includes(nodeToken),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}) ?? null
|
}) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +320,7 @@ export class HeadscaleAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toHeadscaleUserName(email: string) {
|
private toHeadscaleUserName(email: string) {
|
||||||
return this.normalize(email.replace(/@/g, '-at-')).slice(0, 63)
|
return `${this.normalize(email.replace(/@/g, '-at-')).slice(0, 63)}@`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,23 @@ class HeadscaleNodeDiscovery {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteNode(nodeId: string): Promise<void> {
|
||||||
|
if (!config.headscaleApiKey) {
|
||||||
|
throw new Error('HEADSCALE_API_KEY is not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedNodeId = encodeURIComponent(nodeId)
|
||||||
|
let response = await this.fetchHeadscale(`/api/v1/node/${encodedNodeId}`, { method: 'DELETE' })
|
||||||
|
if (response.status === 404) {
|
||||||
|
response = await this.fetchHeadscale(`/api/v1/machine/${encodedNodeId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text().catch(() => '')
|
||||||
|
throw new Error(`Headscale node deletion failed (${response.status})${detail ? `: ${detail}` : ''}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async listRouteStatus(): Promise<HeadscaleRouteStatus[]> {
|
async listRouteStatus(): Promise<HeadscaleRouteStatus[]> {
|
||||||
const nodes = await this.listNodes()
|
const nodes = await this.listNodes()
|
||||||
return nodes.flatMap((node) => {
|
return nodes.flatMap((node) => {
|
||||||
@@ -108,10 +125,13 @@ class HeadscaleNodeDiscovery {
|
|||||||
|
|
||||||
async resolveAgent(platform: Platform, accountEmail?: string): Promise<ResolvedAgent | null> {
|
async resolveAgent(platform: Platform, accountEmail?: string): Promise<ResolvedAgent | null> {
|
||||||
const nodes = await this.listNodes().catch(() => [])
|
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
|
const accountNode = accountEmail
|
||||||
? nodes.find((candidate) => this.matchesAccount(candidate, accountEmail))
|
? nodes.find((candidate) => this.matchesAccount(candidate, accountEmail))
|
||||||
: null
|
: null
|
||||||
const node = accountNode ?? nodes.find((candidate) => this.matchesPlatform(candidate, platform))
|
const node = platformNode ?? accountNode
|
||||||
if (!node) return null
|
if (!node) return null
|
||||||
|
|
||||||
const ipAddress = this.extractIpAddress(node)
|
const ipAddress = this.extractIpAddress(node)
|
||||||
@@ -138,9 +158,10 @@ class HeadscaleNodeDiscovery {
|
|||||||
return this.extractNodeList(await response.json().catch(() => null))
|
return this.extractNodeList(await response.json().catch(() => null))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchHeadscale(path: string) {
|
private fetchHeadscale(path: string, init?: RequestInit) {
|
||||||
return fetch(`${config.headscaleBaseUrl}${path}`, {
|
return fetch(`${config.headscaleBaseUrl}${path}`, {
|
||||||
headers: { authorization: `Bearer ${config.headscaleApiKey}` },
|
headers: { authorization: `Bearer ${config.headscaleApiKey}` },
|
||||||
|
...init,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,14 +251,10 @@ class HeadscaleNodeDiscovery {
|
|||||||
.map((value) => this.normalize(value))
|
.map((value) => this.normalize(value))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
return nodeTokens.some((nodeToken) =>
|
// Platform bindings carry the Headscale node hostname or ID. Partial
|
||||||
platformTokens.some(
|
// matching makes a stale generic hostname such as "ubuntu" win over the
|
||||||
(platformToken) =>
|
// explicitly registered hostname (for example teacher-...-ubuntu).
|
||||||
nodeToken === platformToken ||
|
return nodeTokens.some((nodeToken) => platformTokens.includes(nodeToken))
|
||||||
nodeToken.includes(platformToken) ||
|
|
||||||
platformToken.includes(nodeToken),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private matchesAccount(node: HeadscaleNode, accountEmail: string) {
|
private matchesAccount(node: HeadscaleNode, accountEmail: string) {
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ export class SessionManager {
|
|||||||
return this.listActiveSessions().find((session) => session.platformId === platformId) ?? null
|
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)
|
const platform = platformRegistry.getPlatform(input.platformId)
|
||||||
if (!platform) throw new Error('platform_not_found')
|
if (!platform) throw new Error('platform_not_found')
|
||||||
if (platform.state === 'offline' || platform.state === 'maintenance' || platform.state === 'emergency_stopped') {
|
if (platform.state === 'offline' || platform.state === 'maintenance' || platform.state === 'emergency_stopped') {
|
||||||
throw new Error('platform_unavailable')
|
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')
|
throw new Error('platform_access_denied')
|
||||||
}
|
}
|
||||||
if (this.getActiveSessionByPlatform(input.platformId)) {
|
if (this.getActiveSessionByPlatform(input.platformId)) {
|
||||||
@@ -58,6 +58,21 @@ export class SessionManager {
|
|||||||
return next
|
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 {
|
markEmergencyStopped(platformId: string): PlatformSession | null {
|
||||||
const session = this.getActiveSessionByPlatform(platformId)
|
const session = this.getActiveSessionByPlatform(platformId)
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
|
|||||||
Reference in New Issue
Block a user