Эх сурвалжийг харах

feat(runner): 卫星执行节点改为连接服务器 Hermes

卫星配置拒绝回环地址,避免会话打到卫星本机 9119。本机 Runner 仍只连接回环 Hermes。
Samien 1 сар өмнө
parent
commit
7f197e43c7

+ 17 - 9
src/client/components/app/RunnerSettingsPanel.vue

@@ -10,14 +10,14 @@ const bundleAvailable=ref(false),fixedDesktops=ref(false)
 const runners = ref<Runner[]>([]), sources = ref<Array<{id:string;name:string}>>([])
 const busy = ref(false), error = ref(''), notice = ref('')
 const name = ref(''), source = ref('local'), profiles = ref('default')
-const serverURL = ref(window.location.origin), hermesURL = ref('http://127.0.0.1:9119')
+const serverURL = ref(window.location.origin), hermesURL = ref('http://127.0.0.1:9119'), satellite = ref(false), serverHermes = ref('')
 const computerNetwork=ref<'none'|'public-proxy'>('public-proxy')
 const computers=ref(!!props.computerSetup),computerRuntime=ref<'docker'|'podman'>('docker'),computerImage=ref(''),computerPython=ref(''),hermesSource=ref(''),hermesHome=ref('')
 const artifactRoots = ref(''), insecure = ref(false), configuration = ref<RunnerConfiguration>()
 
 async function refresh() {
-  const data = await apiRequest<{runners:Runner[];sources:Array<{id:string;name:string}>;bundleAvailable?:boolean;fixedDesktops?:boolean}>('/api/app/admin/runners')
-  runners.value = data.runners; sources.value = data.sources;bundleAvailable.value=!!data.bundleAvailable;fixedDesktops.value=!!data.fixedDesktops
+  const data = await apiRequest<{runners:Runner[];sources:Array<{id:string;name:string}>;bundleAvailable?:boolean;fixedDesktops?:boolean;hermesUpstream?:string}>('/api/app/admin/runners')
+  runners.value = data.runners; sources.value = data.sources;bundleAvailable.value=!!data.bundleAvailable;fixedDesktops.value=!!data.fixedDesktops;serverHermes.value=data.hermesUpstream||''
 }
 async function action(work:()=>Promise<void>) {
   if(busy.value) return
@@ -27,15 +27,21 @@ async function action(work:()=>Promise<void>) {
 }
 async function register() {
   await action(async()=>{
-    const web = new URL(serverURL.value), local = new URL(hermesURL.value)
+    const hermesTarget = satellite.value ? serverHermes.value : hermesURL.value
+    if(satellite.value && !hermesTarget)throw new Error('服务器尚未配置 Hermes 地址')
+    const web = new URL(serverURL.value), local = new URL(hermesTarget)
     if(!['http:','https:'].includes(web.protocol)||web.username||web.password||web.search||web.hash||web.pathname!=='/')throw new Error('请填写不带路径的夭夭服务地址')
     if(web.protocol==='http:'&&!['localhost','127.0.0.1','[::1]'].includes(web.hostname)&&!insecure.value)throw new Error('远程节点请使用 HTTPS,或勾选可信局域网 HTTP')
-    if(!['127.0.0.1','[::1]'].includes(local.hostname)||!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('Hermes 地址必须指向 Runner 所在电脑的回环地址')
+    if(satellite.value){
+      if(['127.0.0.1','localhost','[::1]'].includes(local.hostname))throw new Error('服务器 Hermes 仍是本机回环地址,卫星连不上。请把上游配成卫星能访问的地址。')
+      if(!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('服务器 Hermes 地址无效')
+    }else if(!['127.0.0.1','[::1]'].includes(local.hostname)||!['http:','https:'].includes(local.protocol)||local.username||local.password)throw new Error('Hermes 地址必须指向 Runner 所在电脑的回环地址')
     const allowedProfiles = [...new Set(profiles.value.split(/[\s,,]+/).filter(Boolean))]
     if(computers.value&&((!!computerImage.value&&!/^sha256:[a-f0-9]{64}$/.test(computerImage.value))||[computerPython.value,hermesSource.value,hermesHome.value].some(path=>path&&!path.startsWith('/'))))throw new Error('自定义路径必须为执行电脑上的绝对路径;默认安装可留空。镜像 ID 可留空或填写完整 sha256 ID。')
     const result = await apiRequest<{runner:Runner;token:string}>('/api/app/admin/runners', {method:'POST',body:{name:name.value.trim(),sourceNodeId:source.value,allowedProfiles}})
-    configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value?{allowInsecureLan:true}:{}),...(computers.value?{computers:{...(fixedDesktops.value?{managedBy:'compose' as const}:{}),network:fixedDesktops.value?'none':computerNetwork.value,runtime:computerRuntime.value,imageId:computerImage.value||UNCONFIGURED_COMPUTER_IMAGE,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
-    notice.value = '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
+    const lanHermes = satellite.value && local.protocol==='http:'
+    configuration.value = {protocol:1,serverURL:web.origin,runnerId:result.runner.id,token:result.token,hermesURL:local.toString(),...(satellite.value?{satellite:true}:{}),allowedProfiles,artifactRoots:artifactRoots.value.split('\n').map(p=>p.trim()).filter(Boolean),...(insecure.value||lanHermes?{allowInsecureLan:true}:{}),...(computers.value?{computers:{...(fixedDesktops.value?{managedBy:'compose' as const}:{}),network:fixedDesktops.value?'none':computerNetwork.value,runtime:computerRuntime.value,imageId:computerImage.value||UNCONFIGURED_COMPUTER_IMAGE,python:computerPython.value,hermesSource:hermesSource.value,hermesHome:hermesHome.value}}:{})}
+    notice.value = satellite.value ? '节点已注册。请在卫星电脑上启动 Runner。会话连接服务器 Hermes,不要在卫星上启动本机 Hermes。' : '节点已注册,请下载配置并在 Hermes 所在电脑启动 Runner。'
     await refresh()
   })
 }
@@ -71,8 +77,10 @@ onMounted(()=>{void action(refresh)})
       <label>节点名称<input v-model="name" required maxlength="100" autocomplete="off" placeholder="我的 Mac" :disabled="busy"></label>
       <label>接管的 Bot 来源<select v-model="source" :disabled="busy"><option v-for="item in sources" :key="item.id" :value="item.id">{{ item.name }}</option></select></label>
       <label>允许的 Profile<input v-model="profiles" required placeholder="default, writer" :disabled="busy"><small>填写 Hermes 的 Profile 名称,以逗号分隔;账号权限仍然生效。</small></label>
+      <label class="runner-check"><input v-model="satellite" type="checkbox" :disabled="busy">卫星电脑(会话使用服务器 Hermes,不连接本机 9119)</label>
       <label>夭夭服务地址<input v-model="serverURL" type="url" required :disabled="busy"><small>必须能从 Runner 所在电脑访问。</small></label>
-      <label>Runner 本机 Hermes 地址<input v-model="hermesURL" type="url" required :disabled="busy"></label>
+      <label v-if="!satellite">Runner 本机 Hermes 地址<input v-model="hermesURL" type="url" required :disabled="busy"></label>
+      <p v-else>会话连接服务器 Hermes:{{ serverHermes || '尚未配置' }}</p>
       <label>允许导出的产物目录<textarea v-model="artifactRoots" rows="2" :disabled="busy" placeholder="每行一个绝对路径,留空时禁止导出文件"></textarea></label>
       <label class="runner-check"><input v-model="insecure" type="checkbox" :disabled="busy">使用可信局域网 HTTP</label>
       <label class="runner-check"><input v-model="computers" type="checkbox" :disabled="busy">启用隔离电脑 Worker</label>
@@ -92,7 +100,7 @@ onMounted(()=>{void action(refresh)})
     </form>
     <div v-else class="runner-setup">
       <button class="solid-button" type="button" @click="download">下载 Runner 配置</button>
-      <p>配置包含本节点的连接凭据,仅本次可下载。将文件放在 Hermes 所在电脑,设置为仅自己可读:</p>
+      <p>{{ configuration.satellite ? '配置包含连接凭据,仅本次可下载。将文件放到卫星电脑,设置为仅自己可读。不要在卫星上启动 Hermes。' : '配置包含本节点的连接凭据,仅本次可下载。将文件放在 Hermes 所在电脑,设置为仅自己可读:' }}</p>
       <code v-if="bundleAvailable">mkdir -p yaoyao-runner<br>tar -xzf yaoyao-runner.tar.gz -C yaoyao-runner<br>chmod 600 runner.json<br>node yaoyao-runner/runner.mjs --config /完整路径/runner.json</code>
       <code v-else>chmod 600 runner.json<br>npm run runner -- --config /完整路径/runner.json</code>
       <small>{{bundleAvailable?'在运行 Hermes 和 Docker/Podman 的电脑上执行,节点连接后返回本地虚拟机设置准备桌面。':'在 yaoyao 项目中先运行 npm run build。'}}配置丢失后可停用节点并重新注册。</small>

+ 2 - 1
src/runner/agent.ts

@@ -13,6 +13,7 @@ import { UpstreamServiceSession } from '../server/localAuth.js'
 import { WorkspaceGateway, type GatewayTarget, type GatewayFrame } from '../server/workspaceGateway.js'
 import { createWorkspaceToolLease, type WorkspaceToolLease } from '../server/workspaceToolLease.js'
 import {UNCONFIGURED_COMPUTER_IMAGE} from '../shared/runner.js'
+import {assertRunnerHermes} from './config.js'
 import type { RunnerCommand, RunnerConfiguration } from '../shared/runner.js'
 import { LoopbackTransport, isLocalAuthorizationTarget } from '../server/loopbackAuthorization.js'
 
@@ -42,7 +43,7 @@ export class RunnerAgent {
     const web=new URL(config.serverURL),hermes=new URL(config.hermesURL)
     if(web.username||web.password||web.search||web.hash||!['http:','https:'].includes(web.protocol))throw new Error('Web 地址无效')
     if(web.protocol==='http:'&&!['127.0.0.1','localhost','[::1]'].includes(web.hostname)&&!config.allowInsecureLan)throw new Error('远程执行节点必须使用 HTTPS,或明确配置可信局域网 HTTP')
-    if(hermes.username||hermes.password||hermes.search||hermes.hash||!['127.0.0.1','[::1]'].includes(hermes.hostname)||!['http:','https:'].includes(hermes.protocol))throw new Error('Runner 必须连接本机 Hermes')
+    assertRunnerHermes(hermes, config.satellite, config.allowInsecureLan)
     const client=new UpstreamClient(hermes)
     this.target={url:hermes,client,session:new UpstreamServiceSession(client,()=>config.hermesCredentials)}
     this.db=new DatabaseSync(join(home,'runner-commands.sqlite3'))

+ 13 - 2
src/runner/config.ts

@@ -6,7 +6,7 @@ import type { RunnerConfiguration } from '../shared/runner.js'
 const localPath=z.string().refine(value=>!value||isAbsolute(value),'路径必须为绝对路径').default('')
 const schema=z.object({
   protocol:z.literal(1),serverURL:z.string().url(),runnerId:z.string().uuid(),token:z.string().min(32).max(4096),
-  hermesURL:z.string().url().default('http://127.0.0.1:9119'),
+  hermesURL:z.string().url().default('http://127.0.0.1:9119'),satellite:z.boolean().optional(),
   allowedProfiles:z.array(z.string().trim().min(1).max(256).regex(/^[^/\\\u0000-\u001f]+$/)).min(1).max(256),
   artifactRoots:z.array(z.string().refine(isAbsolute,'产物目录必须为绝对路径')).max(64).default([]),
   computers:z.object({managedBy:z.literal('compose').optional(),network:z.enum(['none','public-proxy']).default('none'),runtime:z.enum(['docker','podman']),imageId:z.string().regex(/^sha256:[a-f0-9]{64}$/),python:localPath,hermesSource:localPath,hermesHome:localPath,maxConcurrent:z.number().int().min(1).max(8).optional()}).strict().transform(value=>{
@@ -21,6 +21,17 @@ export function parseRunnerConfiguration(value:unknown):RunnerConfiguration {
   const config=parsed.data,web=new URL(config.serverURL),hermes=new URL(config.hermesURL)
   if(web.username||web.password||web.search||web.hash||web.pathname!=='/'||!['http:','https:'].includes(web.protocol))throw new Error('夭夭服务地址无效')
   if(web.protocol==='http:'&&!['127.0.0.1','localhost','[::1]'].includes(web.hostname)&&!config.allowInsecureLan)throw new Error('远程执行节点需要 HTTPS 或明确启用可信局域网 HTTP')
-  if(hermes.username||hermes.password||hermes.search||hermes.hash||!['127.0.0.1','[::1]'].includes(hermes.hostname)||!['http:','https:'].includes(hermes.protocol))throw new Error('Runner 必须连接本机 Hermes')
+  assertRunnerHermes(hermes, config.satellite, config.allowInsecureLan)
   return config
 }
+const loopbackHost = (host:string) => ['127.0.0.1','localhost','[::1]'].includes(host)
+/** Local runners must use the Hermes on that machine. Satellite runners must use the server Hermes. */
+export function assertRunnerHermes(hermes:URL, satellite:boolean|undefined, allowInsecureLan:boolean|undefined) {
+  if(hermes.username||hermes.password||hermes.search||hermes.hash||!['http:','https:'].includes(hermes.protocol))throw new Error('Hermes 地址无效')
+  if(satellite){
+    if(loopbackHost(hermes.hostname))throw new Error('卫星执行节点不能连接本机 Hermes')
+    if(hermes.protocol==='http:'&&!allowInsecureLan)throw new Error('卫星执行节点连接服务器 Hermes 需要 HTTPS 或明确启用可信局域网 HTTP')
+    return
+  }
+  if(!['127.0.0.1','[::1]'].includes(hermes.hostname))throw new Error('Runner 必须连接本机 Hermes')
+}

+ 1 - 0
src/server/app.ts

@@ -240,6 +240,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   const workspaceNodes = new WorkspaceNodes(workspace, config, { url: config.upstream, client: upstream, session: upstreamSession }, pairings.nodeID)
   workspaceNodes.sourceAllowed = (owner, nodeId, profile) => auth.canUseSource(owner, nodeId, profile)
   const runners=new RunnerHub(workspace,auth,workspaceNodes.local)
+  runners.hermesUpstream=config.upstream.origin
   runners.composeDesktops.desktops.push(...(config.composeDesktops??[]))
   const sharedComputers=new SharedComputers(workspace,auth,workspaceNodes,runners)
   const computerControls=new ComputerControlService(workspace,auth,workspaceNodes,runners)

+ 3 - 1
src/server/runnerHub.ts

@@ -37,6 +37,8 @@ export class RunnerHub {
   private artifacts=new Map<string,{runnerId:string;connectionId:string;name:string;size:number;digest:string;chunks:Buffer[];bytes:number;next:number;result?:unknown}>()
   private leases = new Map<string,Lease>()
   private watchdog: ReturnType<typeof setInterval>
+  /** Origin of the server Hermes. Satellite configs must use this, never loopback. */
+  hermesUpstream=''
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly local:GatewayTarget) {
     for (const grant of store.list<{ owner: string; runnerId: string; expiresAt: number }>('_system', 'local-vm-grant'))
       if (grant.expiresAt > Date.now()) this.localVmActivity.set(`${grant.owner}:${grant.runnerId}`, { owner: grant.owner, runnerId: grant.runnerId })
@@ -224,7 +226,7 @@ export class RunnerHub {
   }
   adminRouter():Router {
     const router=new Router()
-    router.get('/api/app/admin/runners',ctx=>{const actor=this.auth.requireAdmin(ctx);ctx.body={protocol:RUNNER_PROTOCOL,fixedDesktops:this.composeDesktops.desktops.length>0,bundleAvailable:!!runnerBundle(),runners:this.records().map(r=>this.summary(r)),sources:[{id:'local',name:'本地来源'},...this.store.list<import('./workspaceGateway.js').WorkspaceNode>(actor.id,'node').filter(n=>n.transport!=='paired-web').map(n=>({id:n.id,name:n.name}))]}})
+    router.get('/api/app/admin/runners',ctx=>{const actor=this.auth.requireAdmin(ctx);ctx.body={protocol:RUNNER_PROTOCOL,hermesUpstream:this.hermesUpstream,fixedDesktops:this.composeDesktops.desktops.length>0,bundleAvailable:!!runnerBundle(),runners:this.records().map(r=>this.summary(r)),sources:[{id:'local',name:'本地来源'},...this.store.list<import('./workspaceGateway.js').WorkspaceNode>(actor.id,'node').filter(n=>n.transport!=='paired-web').map(n=>({id:n.id,name:n.name}))]}})
     router.get('/api/app/admin/runners/bundle',ctx=>{this.auth.requireAdmin(ctx);const path=runnerBundle();if(!path)throw new HttpError(404,'当前服务未打包执行节点程序,请使用配套 App 或从源码构建 Runner','runner_bundle_missing');ctx.set('Cache-Control','no-store');ctx.attachment('yaoyao-runner.tar.gz');ctx.type='application/gzip';ctx.body=createReadStream(path)})
     router.post('/api/app/admin/runners',ctx=>{const actor=this.auth.requireAdmin(ctx);ctx.body=this.enroll(actor.id,(ctx.request as any).body);ctx.status=201})
     router.delete('/api/app/admin/runners/:id',ctx=>{this.auth.requireAdmin(ctx);this.remove(ctx.params.id);ctx.body={ok:true}})

+ 2 - 0
src/shared/runner.ts

@@ -21,6 +21,8 @@ export interface RunnerConfiguration {
   runnerId: string
   token: string
   hermesURL: string
+  /** Satellite runners open sessions on the server Hermes, never 127.0.0.1. */
+  satellite?: boolean
   allowedProfiles: string[]
   artifactRoots: string[]
   allowInsecureLan?: boolean

+ 7 - 0
tests/server/runnerConfig.test.ts

@@ -35,3 +35,10 @@ it('resolves optional Hermes paths on the execution host while preserving explic
  expect(parseRunnerConfiguration({...config,computers:{...computers,hermesHome:'/custom/hermes',hermesSource:'/custom/source',python:''}}).computers?.python).toBe('/custom/source/venv/bin/python')
  expect(()=>parseRunnerConfiguration({...config,computers:{...computers,hermesHome:'relative'}})).toThrow()
 })
+
+it('satellite runner uses the server Hermes and refuses loopback',()=>{
+  expect(parseRunnerConfiguration({...config,satellite:true,hermesURL:'https://hermes.example',allowInsecureLan:true})).toMatchObject({satellite:true,hermesURL:'https://hermes.example'})
+  expect(parseRunnerConfiguration({...config,satellite:true,hermesURL:'http://192.168.1.8:9119',allowInsecureLan:true}).hermesURL).toBe('http://192.168.1.8:9119')
+  expect(()=>parseRunnerConfiguration({...config,satellite:true})).toThrow('卫星执行节点不能连接本机 Hermes')
+  expect(()=>parseRunnerConfiguration({...config,satellite:true,hermesURL:'http://192.168.1.8:9119'})).toThrow('可信局域网')
+})