|
|
@@ -0,0 +1,76 @@
|
|
|
+import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
|
+import Router from '@koa/router'
|
|
|
+import type Koa from 'koa'
|
|
|
+import { z } from 'zod'
|
|
|
+import { HttpError } from './errors.js'
|
|
|
+import { parse, type WorkspaceStore } from './workspaceStore.js'
|
|
|
+import type { LocalAuthStore } from './localAuth.js'
|
|
|
+import { DESKTOP_HOST_PROTOCOL, type DesktopHostExchange, type DesktopHostRecord, type DesktopHostSummary } from '../shared/desktopHost.js'
|
|
|
+
|
|
|
+const hash = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
|
+const enrollInput = z.object({ name: z.string().trim().min(1).max(100) }).strict()
|
|
|
+const exchangeInput = z.object({
|
|
|
+ host: z.object({ id: z.string().uuid(), name: z.string().max(128), platform: z.string().max(16), screen: z.boolean(), accessibility: z.boolean(), approved: z.array(z.string().regex(/^[a-f0-9]{64}$/)).max(100) }).strict(),
|
|
|
+ results: z.array(z.object({ id: z.string().uuid(), value: z.unknown().optional(), error: z.string().max(500).optional() }).strict()).max(20),
|
|
|
+}).strict()
|
|
|
+
|
|
|
+/** Outbound-only machine transport for remote desktop hosts. Browser auth and
|
|
|
+ * machine auth remain separate: these endpoints never enter the user session,
|
|
|
+ * CSRF or CORS pipeline, and reject any request carrying an Origin header. */
|
|
|
+export class DesktopHostHub {
|
|
|
+ /** Bound to DesktopEnvironments.remoteExchange once the runtime exists. */
|
|
|
+ exchange: (id: string, value: DesktopHostExchange) => unknown = () => { throw new HttpError(503, '桌面主机尚未接入', 'desktop_host_offline') }
|
|
|
+ /** Bound to DesktopEnvironments.dropHost so revocation fences live commands. */
|
|
|
+ drop: (id: string) => void = () => {}
|
|
|
+ private online = new Map<string, { seen: number; name: string; platform: string }>()
|
|
|
+ constructor(readonly store: WorkspaceStore, readonly auth: LocalAuthStore) {}
|
|
|
+ records(): DesktopHostRecord[] { return this.store.list('_system', 'desktop-host') }
|
|
|
+ enroll(value: unknown) {
|
|
|
+ const body = parse(enrollInput, value)
|
|
|
+ const token = randomBytes(32).toString('base64url')
|
|
|
+ const record: DesktopHostRecord = { ...body, id: randomUUID(), tokenHash: hash(token), enabled: true, createdAt: Date.now() }
|
|
|
+ this.store.put('_system', 'desktop-host', record.id, record)
|
|
|
+ return { host: this.summary(record), token }
|
|
|
+ }
|
|
|
+ summary(record: DesktopHostRecord): DesktopHostSummary {
|
|
|
+ const { tokenHash: _secret, ...publicRecord } = record
|
|
|
+ const state = this.online.get(record.id)
|
|
|
+ return { ...publicRecord, online: !!state && Date.now() - state.seen < 15_000, lastSeen: state?.seen, hostName: state?.name, platform: state?.platform }
|
|
|
+ }
|
|
|
+ remove(id: string) {
|
|
|
+ const record = this.store.require<DesktopHostRecord>('_system', 'desktop-host', id)
|
|
|
+ record.enabled = false
|
|
|
+ this.store.put('_system', 'desktop-host', id, record)
|
|
|
+ this.online.delete(id)
|
|
|
+ this.drop(id)
|
|
|
+ }
|
|
|
+ adminRouter(): Router {
|
|
|
+ const router = new Router()
|
|
|
+ router.get('/api/app/admin/desktop-hosts', ctx => { this.auth.requireAdmin(ctx); ctx.set('Cache-Control', 'no-store'); ctx.body = { protocol: DESKTOP_HOST_PROTOCOL, hosts: this.records().map(record => this.summary(record)) } })
|
|
|
+ router.post('/api/app/admin/desktop-hosts', ctx => { this.auth.requireAdmin(ctx); ctx.body = this.enroll((ctx.request as any).body); ctx.status = 201 })
|
|
|
+ router.delete('/api/app/admin/desktop-hosts/:id', ctx => { this.auth.requireAdmin(ctx); this.remove(ctx.params.id); ctx.body = { ok: true } })
|
|
|
+ return router
|
|
|
+ }
|
|
|
+ middleware(): Koa.Middleware {
|
|
|
+ return async (ctx, next) => {
|
|
|
+ const match = /^\/api\/desktop-host\/v1\/([0-9a-f-]{36})\/exchange$/.exec(ctx.path)
|
|
|
+ if (!match) return next()
|
|
|
+ const record = this.store.get<DesktopHostRecord>('_system', 'desktop-host', match[1]!)
|
|
|
+ const authorization = ctx.get('authorization')
|
|
|
+ if (ctx.get('origin') || !record?.enabled || !authorization.startsWith('Bearer ') || !timingSafeEqual(Buffer.from(record.tokenHash), Buffer.from(hash(authorization.slice(7)))))
|
|
|
+ throw new HttpError(403, '桌面主机授权无效', 'desktop_host_unauthorized')
|
|
|
+ if (ctx.method !== 'POST') throw new HttpError(405, '仅允许 POST', 'method_not_allowed')
|
|
|
+ if (ctx.get('x-desktop-host-protocol') !== String(DESKTOP_HOST_PROTOCOL)) throw new HttpError(409, '桌面主机协议不兼容', 'desktop_host_protocol_mismatch')
|
|
|
+ let bytes = 0
|
|
|
+ const chunks: Buffer[] = []
|
|
|
+ for await (const chunk of ctx.req) { bytes += chunk.length; if (bytes > 24 * 1024 * 1024) throw new HttpError(413, '桌面主机请求过大', 'desktop_limit'); chunks.push(Buffer.from(chunk)) }
|
|
|
+ let json: unknown
|
|
|
+ try { json = JSON.parse(Buffer.concat(chunks).toString()) } catch { throw new HttpError(400, '桌面主机请求格式无效', 'desktop_invalid') }
|
|
|
+ const value = parse(exchangeInput, json)
|
|
|
+ this.online.set(record.id, { seen: Date.now(), name: value.host.name, platform: value.host.platform })
|
|
|
+ ctx.set('Cache-Control', 'no-store')
|
|
|
+ ctx.body = this.exchange(record.id, value)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ close() { this.online.clear() }
|
|
|
+}
|