Compare commits

..

7 Commits

Author SHA1 Message Date
chyoo b6c0503f27 fix: allow gateway access with open ACL policy
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-07 14:55:39 +09:00
chyoo 2be752d7f8 no acl
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-07 14:48:26 +09:00
chyoo d89b989596 add headscale api modify..
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-13 09:52:28 +09:00
chyoo 204300c927 feat: run platform gateway through Tailscale sidecar
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-12 18:11:24 +09:00
chyoo c90e8d5745 add tailscale node delete!
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-07 10:23:44 +09:00
chyoo 46da0a3f37 feat: display platform agent status and version
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 16:13:37 +09:00
chyoo e7d93198a7 patch..
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-03 13:42:38 +09:00
11 changed files with 214 additions and 60 deletions
+2
View File
@@ -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
+1
View File
@@ -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:
+13
View File
@@ -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.
+58
View File
@@ -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
+5
View File
@@ -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 ?? '',
+10
View File
@@ -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
View File
@@ -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,
}) })
+14 -3
View File
@@ -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 {
+28 -44
View File
@@ -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)}@`
} }
} }
+27 -10
View File
@@ -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) {
+17 -2
View File
@@ -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