desktopHosts.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
  2. import Router from '@koa/router'
  3. import type Koa from 'koa'
  4. import { z } from 'zod'
  5. import { HttpError } from './errors.js'
  6. import { parse, type WorkspaceStore } from './workspaceStore.js'
  7. import type { LocalAuthStore } from './localAuth.js'
  8. import { DESKTOP_HOST_PROTOCOL, type DesktopHostExchange, type DesktopHostRecord, type DesktopHostSummary } from '../shared/desktopHost.js'
  9. import { desktopHostExchangeSchema } from './desktopHostProtocol.js'
  10. import { readComputerNames, saveComputerName } from './computerNames.js'
  11. const hash = (value: string) => createHash('sha256').update(value).digest('hex')
  12. const enrollInput = z.object({
  13. name: z.string().trim().min(1).max(100),
  14. installId: z.string().uuid().optional(),
  15. previousHostId: z.string().uuid().optional(),
  16. }).strict()
  17. /** Outbound-only machine transport for remote computers. Browser auth and
  18. * machine auth remain separate: these endpoints never enter the user session,
  19. * CSRF or CORS pipeline, and reject any request carrying an Origin header. */
  20. export class DesktopHostHub {
  21. /** Bound to DesktopEnvironments.remoteExchange once the runtime exists. */
  22. exchange: (id: string, value: DesktopHostExchange) => unknown = () => { throw new HttpError(503, '电脑尚未接入', 'desktop_host_offline') }
  23. /** Bound to DesktopEnvironments.dropHost so revocation fences live commands. */
  24. drop: (id: string) => void = () => {}
  25. private online = new Map<string, { seen: number; name: string; platform: string }>()
  26. constructor(readonly store: WorkspaceStore, readonly auth: LocalAuthStore) {}
  27. records(): DesktopHostRecord[] { return this.store.list('_system', 'desktop-host') }
  28. enroll(value: unknown) {
  29. const body = parse(enrollInput, value)
  30. const token = randomBytes(32).toString('base64url')
  31. const records = this.records()
  32. let record = body.installId ? records.find(item => item.installId === body.installId) : undefined
  33. if (!record && body.previousHostId) record = records.find(item => item.id === body.previousHostId)
  34. if (!record && body.installId) {
  35. const unnamed = records.filter(item => item.enabled && item.name === body.name && !item.installId).sort((a, b) => a.createdAt - b.createdAt)
  36. record = unnamed[0]
  37. for (const extra of unnamed.slice(1)) {
  38. extra.enabled = false
  39. this.store.put('_system', 'desktop-host', extra.id, extra)
  40. this.online.delete(extra.id)
  41. this.drop(extra.id)
  42. }
  43. }
  44. if (record) {
  45. record.name = body.name
  46. record.installId = body.installId
  47. record.tokenHash = hash(token)
  48. record.enabled = true
  49. this.store.put('_system', 'desktop-host', record.id, record)
  50. return { host: this.summary(record), token }
  51. }
  52. const created: DesktopHostRecord = { name: body.name, installId: body.installId, id: randomUUID(), tokenHash: hash(token), enabled: true, createdAt: Date.now() }
  53. this.store.put('_system', 'desktop-host', created.id, created)
  54. return { host: this.summary(created), token }
  55. }
  56. summary(record: DesktopHostRecord): DesktopHostSummary {
  57. const { tokenHash: _secret, ...publicRecord } = record
  58. const state = this.online.get(record.id)
  59. const displayName = readComputerNames(this.store.home)[record.id] || ''
  60. return { ...publicRecord, online: !!state && Date.now() - state.seen < 15_000, lastSeen: state?.seen, hostName: state?.name, platform: state?.platform, displayName }
  61. }
  62. remove(id: string) {
  63. const record = this.store.require<DesktopHostRecord>('_system', 'desktop-host', id)
  64. record.enabled = false
  65. this.store.put('_system', 'desktop-host', id, record)
  66. this.online.delete(id)
  67. this.drop(id)
  68. }
  69. adminRouter(): Router {
  70. const router = new Router()
  71. router.get('/api/app/admin/desktop-hosts', ctx => { this.auth.requireAdmin(ctx); ctx.set('Cache-Control', 'no-store'); const names = readComputerNames(this.store.home); ctx.body = { protocol: DESKTOP_HOST_PROTOCOL, localName: names.local || '', hosts: this.records().map(record => this.summary(record)) } })
  72. router.put('/api/app/admin/desktop-hosts/:id/name', ctx => { this.auth.requireAdmin(ctx); const id = ctx.params.id; if (id !== 'local' && !this.records().some(record => record.id === id && record.enabled)) throw new HttpError(404, '这台电脑不存在', 'desktop_host_unknown'); const name = typeof (ctx.request as any).body?.name === 'string' ? (ctx.request as any).body.name : ''; ctx.body = { id, name: saveComputerName(this.store.home, id, name)[id] || '' } })
  73. router.post('/api/app/admin/desktop-hosts', ctx => { this.auth.requireAdmin(ctx); ctx.body = this.enroll((ctx.request as any).body); ctx.status = 201 })
  74. router.delete('/api/app/admin/desktop-hosts/:id', ctx => { this.auth.requireAdmin(ctx); this.remove(ctx.params.id); ctx.body = { ok: true } })
  75. return router
  76. }
  77. middleware(): Koa.Middleware {
  78. return async (ctx, next) => {
  79. const match = /^\/api\/desktop-host\/v1\/([0-9a-f-]{36})\/exchange$/.exec(ctx.path)
  80. if (!match) return next()
  81. const record = this.store.get<DesktopHostRecord>('_system', 'desktop-host', match[1]!)
  82. const authorization = ctx.get('authorization')
  83. if (ctx.get('origin') || !record?.enabled || !authorization.startsWith('Bearer ') || !timingSafeEqual(Buffer.from(record.tokenHash), Buffer.from(hash(authorization.slice(7)))))
  84. throw new HttpError(403, '电脑授权无效', 'desktop_host_unauthorized')
  85. if (ctx.method !== 'POST') throw new HttpError(405, '仅允许 POST', 'method_not_allowed')
  86. if (ctx.get('x-desktop-host-protocol') !== String(DESKTOP_HOST_PROTOCOL)) throw new HttpError(409, '电脑连接协议不兼容', 'desktop_host_protocol_mismatch')
  87. let bytes = 0
  88. const chunks: Buffer[] = []
  89. 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)) }
  90. let json: unknown
  91. try { json = JSON.parse(Buffer.concat(chunks).toString()) } catch { throw new HttpError(400, '电脑请求格式无效', 'desktop_invalid') }
  92. const value = parse(desktopHostExchangeSchema, json)
  93. this.online.set(record.id, { seen: Date.now(), name: value.host.name, platform: value.host.platform })
  94. ctx.set('Cache-Control', 'no-store')
  95. ctx.body = this.exchange(record.id, value)
  96. }
  97. }
  98. close() { this.online.clear() }
  99. }