workspaceGateway.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import WebSocket from 'ws'
  2. import { randomUUID, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'
  3. import { readFileSync, writeFileSync } from 'node:fs'
  4. import { join } from 'node:path'
  5. import { UpstreamClient } from './upstream.js'
  6. import { UpstreamServiceSession } from './localAuth.js'
  7. import { WorkspaceStore } from './workspaceStore.js'
  8. import { HttpError } from './errors.js'
  9. import type { ServerConfig } from './config.js'
  10. import type { WorkspaceSource } from '../shared/workspace.js'
  11. export interface WorkspaceNode {
  12. id: string
  13. name: string
  14. url: string
  15. secret: string
  16. }
  17. export interface GatewayTarget {
  18. url: URL
  19. client: UpstreamClient
  20. session: UpstreamServiceSession
  21. }
  22. export interface GatewayFrame {
  23. type: string
  24. session_id?: string
  25. payload?: Record<string, any>
  26. }
  27. export class WorkspaceNodes {
  28. private readonly key: Buffer
  29. private targets = new Map<string, GatewayTarget>()
  30. constructor(
  31. readonly store: WorkspaceStore,
  32. readonly config: ServerConfig,
  33. readonly local: GatewayTarget,
  34. ) {
  35. const path = join(config.home, 'workspace-key.bin')
  36. try {
  37. this.key = readFileSync(path)
  38. } catch (e) {
  39. if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e
  40. this.key = randomBytes(32)
  41. writeFileSync(path, this.key, { mode: 0o600, flag: 'wx' })
  42. }
  43. if (this.key.length !== 32) throw new Error('Invalid workspace encryption key')
  44. }
  45. seal(value: unknown): string {
  46. const nonce = randomBytes(12),
  47. cipher = createCipheriv('aes-256-gcm', this.key, nonce)
  48. const data = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final()])
  49. return Buffer.concat([nonce, cipher.getAuthTag(), data]).toString('base64')
  50. }
  51. open<T>(value: string): T {
  52. const b = Buffer.from(value, 'base64'),
  53. cipher = createDecipheriv('aes-256-gcm', this.key, b.subarray(0, 12))
  54. cipher.setAuthTag(b.subarray(12, 28))
  55. return JSON.parse(
  56. Buffer.concat([cipher.update(b.subarray(28)), cipher.final()]).toString(),
  57. ) as T
  58. }
  59. target(owner: string, id: string): GatewayTarget {
  60. if (id === 'local') return this.local
  61. const node = this.store.require<WorkspaceNode>(owner, 'node', id),
  62. key = `${owner}:${id}`
  63. let target = this.targets.get(key)
  64. if (!target) {
  65. const credentials = this.open<{ username: string; password: string }>(node.secret)
  66. const url = new URL(node.url),
  67. client = new UpstreamClient(url)
  68. target = { url, client, session: new UpstreamServiceSession(client, () => credentials) }
  69. this.targets.set(key, target)
  70. }
  71. return target
  72. }
  73. async add(
  74. owner: string,
  75. input: { name: string; url: string; username: string; password: string },
  76. ): Promise<void> {
  77. const url = new URL(input.url)
  78. if (
  79. !['http:', 'https:'].includes(url.protocol) ||
  80. url.username ||
  81. url.password ||
  82. url.search ||
  83. url.hash
  84. )
  85. throw new HttpError(400, '节点地址无效', 'invalid_node')
  86. const client = new UpstreamClient(url),
  87. session = new UpstreamServiceSession(client, () => ({
  88. username: input.username,
  89. password: input.password,
  90. }))
  91. try {
  92. const status = await session.request('/api/status')
  93. if (
  94. status.status === 200 &&
  95. JSON.parse(status.body.toString()).server_kind === 'yaoyao-web'
  96. ) {
  97. throw new HttpError(
  98. 400,
  99. '请填写远端 Hermes 的 9119 地址,手机仍连接当前 Web 服务',
  100. 'native_gateway_required',
  101. )
  102. }
  103. const response = await session.request('/api/profiles')
  104. if (response.status !== 200)
  105. throw new HttpError(400, '无法读取节点 Agent,请检查地址和凭据', 'node_unavailable')
  106. const id = randomUUID()
  107. this.store.put(owner, 'node', id, {
  108. id,
  109. name: input.name,
  110. url: url.href,
  111. secret: this.seal({ username: input.username, password: input.password }),
  112. })
  113. this.store.event(owner, 'nodes.changed', {})
  114. } finally {
  115. client.close()
  116. }
  117. }
  118. remove(owner: string, id: string): void {
  119. this.store.require(owner, 'node', id)
  120. this.store.remove(owner, 'node', id)
  121. this.targets.get(`${owner}:${id}`)?.client.close()
  122. this.targets.delete(`${owner}:${id}`)
  123. this.store.event(owner, 'nodes.changed', {})
  124. }
  125. async sources(owner: string): Promise<{ sources: WorkspaceSource[]; errors: string[] }> {
  126. const sources: WorkspaceSource[] = [],
  127. errors: string[] = []
  128. await Promise.all(
  129. ['local', ...this.store.list<WorkspaceNode>(owner, 'node').map((n) => n.id)].map(
  130. async (nodeId) => {
  131. try {
  132. const response = await this.target(owner, nodeId).session.request('/api/profiles')
  133. if (response.status !== 200) throw new Error('节点不可用')
  134. const payload = JSON.parse(response.body.toString())
  135. for (const p of Array.isArray(payload.profiles) ? payload.profiles : []) {
  136. const profile = typeof p === 'string' ? p : p.name
  137. if (typeof profile === 'string' && profile)
  138. sources.push({
  139. nodeId,
  140. profile,
  141. name: p.ui_meta?.['hermes-bots']?.title || p.display_name || p.label || profile,
  142. })
  143. }
  144. } catch {
  145. errors.push(nodeId)
  146. }
  147. },
  148. ),
  149. )
  150. return { sources, errors }
  151. }
  152. close(): void {
  153. for (const t of this.targets.values()) t.client.close()
  154. this.targets.clear()
  155. }
  156. }
  157. /** One server-owned transport per execution binding, never tied to a browser. */
  158. export class WorkspaceGateway {
  159. private socket?: WebSocket
  160. private pending = new Map<
  161. string,
  162. { resolve(value: any): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }
  163. >()
  164. onEvent: (frame: GatewayFrame) => void = () => {}
  165. onDisconnect: () => void = () => {}
  166. constructor(readonly target: GatewayTarget) {}
  167. async connect(): Promise<void> {
  168. const credential = await this.target.session.webSocketCredential(),
  169. url = new URL(this.target.url)
  170. url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
  171. url.pathname = `${url.pathname.replace(/\/$/, '')}/api/ws`
  172. url.search = ''
  173. url.searchParams.set(credential.name, credential.value)
  174. const socket = new WebSocket(url, {
  175. agent: this.target.client.directAgent,
  176. headers: { Origin: this.target.url.origin },
  177. maxPayload: 36 * 1024 * 1024,
  178. })
  179. this.socket = socket
  180. let ready!: () => void
  181. let failed!: (e: Error) => void
  182. const handshake = new Promise<void>((resolve, reject) => {
  183. ready = resolve
  184. failed = reject
  185. })
  186. const handshakeTimer = setTimeout(() => {
  187. socket.terminate()
  188. failed(new Error('Hermes gateway readiness timed out'))
  189. }, 20_000)
  190. socket.on('message', (raw) => {
  191. try {
  192. const frame = JSON.parse(raw.toString()),
  193. waiter = this.pending.get(String(frame.id))
  194. if (waiter) {
  195. clearTimeout(waiter.timer)
  196. this.pending.delete(String(frame.id))
  197. if (frame.error)
  198. waiter.reject(
  199. new HttpError(502, frame.error.message || 'Hermes 请求失败', 'gateway_rejected'),
  200. )
  201. else waiter.resolve(frame.result)
  202. } else if (frame.method === 'event' && frame.params) {
  203. if (frame.params.type === 'gateway.ready') {
  204. clearTimeout(handshakeTimer)
  205. ready()
  206. }
  207. this.onEvent(frame.params)
  208. }
  209. } catch {
  210. /* Malformed frames cannot mutate an execution. */
  211. }
  212. })
  213. socket.on('error', (error) => {
  214. clearTimeout(handshakeTimer)
  215. failed(error)
  216. })
  217. socket.on('close', () => {
  218. for (const waiter of this.pending.values()) {
  219. clearTimeout(waiter.timer)
  220. waiter.reject(new Error('Hermes connection closed'))
  221. }
  222. this.pending.clear()
  223. this.onDisconnect()
  224. clearTimeout(handshakeTimer)
  225. failed(new Error('Hermes connection closed'))
  226. })
  227. await handshake
  228. }
  229. rpc(method: string, params: Record<string, unknown>): Promise<any> {
  230. if (this.socket?.readyState !== WebSocket.OPEN)
  231. return Promise.reject(new Error('Hermes connection unavailable'))
  232. const id = randomUUID()
  233. return new Promise((resolve, reject) => {
  234. const timer = setTimeout(() => {
  235. this.pending.delete(id)
  236. reject(new Error('Hermes response timed out'))
  237. }, 30_000)
  238. this.pending.set(id, { resolve, reject, timer })
  239. this.socket!.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }), (error) => {
  240. if (error) {
  241. clearTimeout(timer)
  242. this.pending.delete(id)
  243. reject(error)
  244. }
  245. })
  246. })
  247. }
  248. close(): void {
  249. this.onDisconnect = () => {}
  250. this.socket?.close()
  251. }
  252. }