瀏覽代碼

feat(desktop-host): 新增远程桌面主机注册与机器交换端点

设置管理端可注册桌面主机并一次性下发配对令牌(仅存哈希);
/api/desktop-host/v1/:hostId/exchange 以 Bearer + 协议头准入,
拒绝 Origin、不进用户会话,为多机物理控制铺通道。
Samien 1 月之前
父節點
當前提交
f788001b26
共有 4 個文件被更改,包括 176 次插入 和 2 次删除
  1. 9 2
      src/server/app.ts
  2. 76 0
      src/server/desktopHosts.ts
  3. 27 0
      src/shared/desktopHost.ts
  4. 64 0
      tests/server/desktopHosts.test.ts

+ 9 - 2
src/server/app.ts

@@ -1,5 +1,6 @@
 import { chatTranscriptRouter } from './chatTranscriptApi.js'
 import {DesktopEnvironments} from './desktopEnvironments.js'
+import {DesktopHostHub} from './desktopHosts.js'
 import type {GrokAuth} from './grokAuth.js'
 import {GrokCloud} from './grokCloud.js'
 import {WorkspaceInspector} from './workspaceInspector.js'
@@ -115,6 +116,7 @@ export interface ApplicationRuntime {
   chatPushJobs: ChatPushJobManager
   chatCache?: ChatCacheCoordinator
   desktopEnvironments:DesktopEnvironments
+  desktopHosts:DesktopHostHub
   grokAuth:GrokAuth
   workspaceRoutines:WorkspaceRoutines
   workspacePlugins:WorkspacePlugins
@@ -251,6 +253,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   workspaceMemory.start()
   const grokCloud=new GrokCloud(workspace,auth,workspaceNodes,sharedComputers,options.grokFetch)
   const desktopEnvironments=new DesktopEnvironments(workspace,auth,workspaceNodes)
+  const desktopHosts=new DesktopHostHub(workspace,auth)
   workspaceRuntime.desktopEnvironments=desktopEnvironments
   grokCloud.preferDesktop=(owner,agent)=>!!desktopEnvironments.selected(owner,agent)
   grokCloud.beforeSelection=(owner,id)=>desktopEnvironments.assertIdle(owner,id)
@@ -361,6 +364,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     await next()
   })
   app.use(runners.middleware())
+  app.use(desktopHosts.middleware())
   app.use(async (ctx, next) => {
     const user = auth.current(ctx)
     if (user && user.role !== 'admin' && (ctx.path.startsWith('/api/') || ctx.path.startsWith('/node/') || ctx.path.startsWith('/ws/') || ctx.path.startsWith('/Users/') || ctx.path.startsWith('/attachments/'))) {
@@ -441,6 +445,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const computerRouter=computerControls.router();app.use(computerRouter.routes());app.use(computerRouter.allowedMethods())
   const applicationRouter = workspaceRouter(workspace, workspaceRuntime, workspaceNodes, workspaceAssets, uploads, auth, push, csrf)
   app.use(runners.adminRouter().routes())
+  app.use(desktopHosts.adminRouter().routes())
   app.use(applicationRouter.routes())
   app.use(async (ctx, next) => {
     if (ctx.path.includes('/plugins/yaoyao') || ctx.path.startsWith('/api/app/groups')) throw new HttpError(410, '请使用新版聊天接口', 'retired_plugin_api')
@@ -510,6 +515,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     workspaceRoutines,
     workspacePlugins,
     desktopEnvironments,
+    desktopHosts,
     grokAuth:grokCloud.authorization,
     realtime,
     app,
@@ -535,8 +541,9 @@ export function createApplication(options: ApplicationOptions = {}): Application
     close: () => {
       workspaceMemory.close()
       runners.close()
-      workspaceAssets.close()
-      desktopEnvironments.close()
+    workspaceAssets.close()
+    desktopEnvironments.close()
+    desktopHosts.close()
       grokCloud.authorization.close()
       workspaceRoutines.close()
       workspacePlugins.close()

+ 76 - 0
src/server/desktopHosts.ts

@@ -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() }
+}

+ 27 - 0
src/shared/desktopHost.ts

@@ -0,0 +1,27 @@
+export const DESKTOP_HOST_PROTOCOL = 1
+export interface DesktopHostRecord {
+  id: string
+  name: string
+  tokenHash: string
+  enabled: boolean
+  createdAt: number
+}
+export interface DesktopHostSummary extends Omit<DesktopHostRecord, 'tokenHash'> {
+  online: boolean
+  lastSeen?: number
+  hostName?: string
+  platform?: string
+}
+/** Downloaded once as desktop-host.json and imported by the remote Mac App. */
+export interface DesktopHostConfiguration {
+  protocol: 1
+  serverURL: string
+  hostId: string
+  token: string
+  allowInsecureLan?: boolean
+}
+/** Wire format exchanged between a remote desktop host and the server. */
+export interface DesktopHostExchange {
+  host: { id: string; name: string; platform: string; screen: boolean; accessibility: boolean; approved: string[] }
+  results: { id: string; value?: unknown; error?: string }[]
+}

+ 64 - 0
tests/server/desktopHosts.test.ts

@@ -0,0 +1,64 @@
+// @vitest-environment node
+import {afterEach,beforeEach,expect,it} from 'vitest'
+import {mkdtempSync,rmSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+import Koa from 'koa'
+import {bodyParser} from '@koa/bodyparser'
+import request from 'supertest'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {DesktopHostHub} from '../../src/server/desktopHosts'
+import type {DesktopHostExchange} from '../../src/shared/desktopHost'
+let home:string,store:WorkspaceStore,hub:DesktopHostHub,token:string,hostId:string,exchanged:DesktopHostExchange[]
+const auth={requireAdmin:()=>({id:'admin',role:'admin'}),require:()=>({id:'owner'})} as any
+const body=():DesktopHostExchange=>({host:{id:'11111111-1111-4111-8111-111111111111',name:'书房 iMac',platform:'darwin',screen:true,accessibility:true,approved:[]},results:[]})
+beforeEach(()=>{home=mkdtempSync(join(tmpdir(),'desktop-hosts-'));store=new WorkspaceStore(home);hub=new DesktopHostHub(store,auth);exchanged=[];hub.exchange=(id,value)=>{exchanged.push(value);hostId=id;return {commands:[]}}})
+afterEach(()=>{hub.close();store.close();rmSync(home,{recursive:true,force:true})})
+const app=()=>{const a=new Koa();a.use(async(ctx,next)=>{try{await next()}catch(e:any){ctx.status=e.status??500;ctx.body={error:e.message,code:e.code}}});a.use((ctx,next)=>ctx.path.startsWith('/api/app/')?bodyParser()(ctx,next):next());a.use(hub.middleware());a.use(hub.adminRouter().routes());return a.callback()}
+async function enroll(name='书房 iMac'){const response=await request(app()).post('/api/app/admin/desktop-hosts').send({name}).expect(201);token=response.body.token;hostId=response.body.host.id;return response.body}
+const post=(url:string)=>request(app()).post(url).set('Authorization','Bearer '+token).set('x-desktop-host-protocol','1')
+it('issues the token once and keeps it out of listings',async()=>{
+  const enrolled=await enroll()
+  expect(token).toMatch(/^[\w-]{40,}$/)
+  expect(enrolled.host).not.toHaveProperty('tokenHash')
+  const list=(await request(app()).get('/api/app/admin/desktop-hosts').expect(200)).body
+  expect(list.protocol).toBe(1)
+  expect(JSON.stringify(list)).not.toContain(token)
+  expect(list.hosts).toHaveLength(1)
+  expect(list.hosts[0]).toMatchObject({name:'书房 iMac',enabled:true,online:false})
+})
+it('rejects unauthorized transports before parsing the body',async()=>{
+  await enroll()
+  await request(app()).post(`/api/desktop-host/v1/${hostId}/exchange`).send(body()).expect(403)
+  await post(`/api/desktop-host/v1/${hostId}/exchange`).set('Origin','http://evil.test').send(body()).expect(403)
+  await request(app()).post(`/api/desktop-host/v1/${hostId}/exchange`).set('Authorization','Bearer wrong-token-wrong-token-wrong').set('x-desktop-host-protocol','1').send(body()).expect(403)
+  await request(app()).post(`/api/desktop-host/v1/11111111-2222-4333-8444-555555555555/exchange`).set('Authorization','Bearer '+token).set('x-desktop-host-protocol','1').send(body()).expect(403)
+})
+it('requires the machine protocol header and POST method',async()=>{
+  await enroll()
+  await request(app()).post(`/api/desktop-host/v1/${hostId}/exchange`).set('Authorization','Bearer '+token).send(body()).expect(409)
+  await request(app()).get(`/api/desktop-host/v1/${hostId}/exchange`).set('Authorization','Bearer '+token).set('x-desktop-host-protocol','1').send(body()).expect(405)
+})
+it('forwards a valid exchange to the desktop environment and reports it online',async()=>{
+  await enroll()
+  const response=await post(`/api/desktop-host/v1/${hostId}/exchange`).send(body()).expect(200)
+  expect(response.body).toEqual({commands:[]})
+  expect(exchanged).toHaveLength(1)
+  expect(exchanged[0].host.name).toBe('书房 iMac')
+  const list=(await request(app()).get('/api/app/admin/desktop-hosts').expect(200)).body
+  expect(list.hosts[0]).toMatchObject({online:true,hostName:'书房 iMac',platform:'darwin'})
+})
+it('revocation fences the transport and notifies the live session',async()=>{
+  const dropped:string[]=[]
+  hub.drop=id=>dropped.push(id)
+  await enroll()
+  await post(`/api/desktop-host/v1/${hostId}/exchange`).send(body()).expect(200)
+  await request(app()).delete(`/api/app/admin/desktop-hosts/${hostId}`).expect(200)
+  expect(dropped).toEqual([hostId])
+  await post(`/api/desktop-host/v1/${hostId}/exchange`).send(body()).expect(403)
+})
+it('validates the exchange payload shape',async()=>{
+  await enroll()
+  await post(`/api/desktop-host/v1/${hostId}/exchange`).send({host:{id:'not-a-uuid',name:'x',platform:'darwin',screen:true,accessibility:true,approved:[]},results:[]}).expect(400)
+  await post(`/api/desktop-host/v1/${hostId}/exchange`).send('string').expect(400)
+})