websocket.test.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import { createServer, type Server } from 'node:http'
  2. import type { AddressInfo } from 'node:net'
  3. import WebSocket, { WebSocketServer } from 'ws'
  4. import { afterEach, describe, expect, it } from 'vitest'
  5. import { createApplication, createNodeServer, type NodeServerRuntime } from '../../src/server/app.js'
  6. import type { ServerConfig } from '../../src/server/config.js'
  7. import { mkdtempSync, rmSync } from 'node:fs'
  8. import { tmpdir } from 'node:os'
  9. import { join } from 'node:path'
  10. const closers: Array<() => Promise<void>> = []
  11. const homes: string[] = []
  12. afterEach(async () => {
  13. for (const close of closers.splice(0).reverse()) await close()
  14. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  15. })
  16. function listen(server: Server): Promise<number> {
  17. return new Promise((resolve, reject) => {
  18. server.once('error', reject)
  19. server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
  20. })
  21. }
  22. function closeServer(server: Server): Promise<void> {
  23. return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
  24. }
  25. function cookies(response: Response): string {
  26. return response.headers.getSetCookie().map((value) => value.split(';', 1)[0]).join('; ')
  27. }
  28. function wsOpen(url: string, origin: string, cookie: string): Promise<WebSocket> {
  29. return new Promise((resolve, reject) => {
  30. const socket = new WebSocket(url, { origin, headers: { Cookie: cookie } })
  31. socket.once('open', () => resolve(socket))
  32. socket.once('error', reject)
  33. })
  34. }
  35. function wsMessage(socket: WebSocket): Promise<Record<string, unknown>> {
  36. return new Promise((resolve, reject) => {
  37. socket.once('message', (data) => {
  38. try {
  39. resolve(JSON.parse(data.toString()) as Record<string, unknown>)
  40. } catch (error) {
  41. reject(error)
  42. }
  43. })
  44. socket.once('error', reject)
  45. })
  46. }
  47. function closeRuntime(runtime: NodeServerRuntime): () => Promise<void> {
  48. return () => runtime.close()
  49. }
  50. describe('WebSocket relay', () => {
  51. it('keeps the upstream ticket server-side and filters chat RPC methods', async () => {
  52. const received: Array<Record<string, unknown>> = []
  53. const upstream = createServer((request, response) => {
  54. response.setHeader('Content-Type', 'application/json')
  55. if (request.url === '/api/status') {
  56. response.setHeader('Set-Cookie', 'hermes_session_at=socket-user; Path=/; HttpOnly')
  57. response.end(JSON.stringify({ auth_required: true }))
  58. } else if (request.url === '/api/auth/me') {
  59. response.end(JSON.stringify({ user_id: 'socket-user', display_name: 'Socket User' }))
  60. } else if (request.url === '/api/profiles') {
  61. response.end(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }))
  62. } else if (request.url === '/api/auth/ws-ticket' && request.method === 'POST') {
  63. response.end(JSON.stringify({ ticket: 'one-time-upstream-secret' }))
  64. } else {
  65. response.statusCode = 404
  66. response.end(JSON.stringify({ error: 'not found' }))
  67. }
  68. })
  69. const upstreamSockets = new WebSocketServer({ noServer: true })
  70. upstream.on('upgrade', (request, socket, head) => {
  71. const url = new URL(request.url ?? '/', 'http://127.0.0.1')
  72. if (url.pathname !== '/api/ws' || url.searchParams.get('ticket') !== 'one-time-upstream-secret') {
  73. socket.destroy()
  74. return
  75. }
  76. upstreamSockets.handleUpgrade(request, socket, head, (client) => {
  77. client.send(JSON.stringify({ method: 'gateway.ready', params: {} }))
  78. client.on('message', (data) => {
  79. received.push(JSON.parse(data.toString()) as Record<string, unknown>)
  80. client.send(JSON.stringify({ id: 'ok', result: {} }))
  81. })
  82. })
  83. })
  84. const upstreamPort = await listen(upstream)
  85. closers.push(async () => {
  86. for (const client of upstreamSockets.clients) client.terminate()
  87. upstreamSockets.close()
  88. await closeServer(upstream)
  89. })
  90. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-ws-'))
  91. homes.push(home)
  92. const config: ServerConfig = {
  93. host: '127.0.0.1',
  94. port: 8800,
  95. upstream: new URL(`http://127.0.0.1:${upstreamPort}`),
  96. allowedHosts: new Set(),
  97. home,
  98. allowInsecureLan: false,
  99. insecureLan: false,
  100. production: false,
  101. }
  102. const application = createApplication({ config })
  103. const node = createNodeServer(application)
  104. await listen(node.server)
  105. closers.push(closeRuntime(node))
  106. const port = (node.server.address() as AddressInfo).port
  107. config.port = port
  108. const origin = `http://127.0.0.1:${port}`
  109. const bootstrap = await fetch(`${origin}/api/app/bootstrap`)
  110. expect(bootstrap.status).toBe(200)
  111. const bootstrapBody = await bootstrap.json() as { csrfToken: string }
  112. const cookie = cookies(bootstrap)
  113. const leaseResponse = await fetch(`${origin}/api/app/realtime-leases`, {
  114. method: 'POST',
  115. headers: {
  116. 'Content-Type': 'application/json',
  117. Cookie: cookie,
  118. Origin: origin,
  119. 'X-CSRF-Token': bootstrapBody.csrfToken,
  120. },
  121. body: JSON.stringify({ channel: 'chat' }),
  122. })
  123. expect(leaseResponse.status).toBe(201)
  124. const leaseBody = await leaseResponse.json() as { lease: string }
  125. expect(JSON.stringify(leaseBody)).not.toContain('upstream-secret')
  126. const rotatedCookie = [cookie, cookies(leaseResponse)].filter(Boolean).join('; ')
  127. const socket = await wsOpen(
  128. `ws://127.0.0.1:${port}/ws/chat?lease=${encodeURIComponent(leaseBody.lease)}`,
  129. origin,
  130. rotatedCookie,
  131. )
  132. expect(await wsMessage(socket)).toMatchObject({ method: 'gateway.ready' })
  133. socket.send(JSON.stringify({
  134. id: 'resume-1',
  135. method: 'session.resume',
  136. params: { session_id: 'stored-session', profile: 'default', source: 'ios' },
  137. }))
  138. await wsMessage(socket)
  139. expect(received[0]).toMatchObject({
  140. method: 'session.resume',
  141. params: { session_id: 'stored-session', profile: 'default', source: 'web' },
  142. })
  143. socket.send(JSON.stringify({
  144. id: 'clarify-1',
  145. method: 'clarify.respond',
  146. params: { request_id: 'question-1', answer: '继续', },
  147. }))
  148. await wsMessage(socket)
  149. expect(received[1]).toMatchObject({
  150. method: 'clarify.respond',
  151. params: { request_id: 'question-1', answer: '继续' },
  152. })
  153. socket.send(JSON.stringify({
  154. id: 'queue-1',
  155. method: 'prompt.submit',
  156. params: { session_id: 'stored-session', text: '下一条', queued: true, ignored: 'drop-me' },
  157. }))
  158. await wsMessage(socket)
  159. expect(received[2]).toEqual({
  160. id: 'queue-1',
  161. method: 'prompt.submit',
  162. params: { session_id: 'stored-session', text: '下一条', queued: true },
  163. })
  164. socket.send(JSON.stringify({
  165. id: 'forbidden',
  166. method: 'config.set',
  167. params: { session_id: 'stored-session', key: 'yolo', value: 'true' },
  168. }))
  169. const closeCode = await new Promise<number>((resolve) => socket.once('close', resolve))
  170. expect(closeCode).toBe(1008)
  171. })
  172. it('rejects unknown production Upgrade requests instead of leaking a hanging socket', async () => {
  173. const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-ws-'))
  174. homes.push(home)
  175. const config: ServerConfig = {
  176. host: '127.0.0.1',
  177. port: 8800,
  178. upstream: new URL('http://127.0.0.1:9119'),
  179. allowedHosts: new Set(),
  180. home,
  181. allowInsecureLan: false,
  182. insecureLan: false,
  183. production: true,
  184. }
  185. const application = createApplication({ config })
  186. const node = createNodeServer(application)
  187. await listen(node.server)
  188. closers.push(closeRuntime(node))
  189. const port = (node.server.address() as AddressInfo).port
  190. config.port = port
  191. const status = await new Promise<number>((resolve, reject) => {
  192. const socket = new WebSocket(`ws://127.0.0.1:${port}/vite-hmr`, {
  193. origin: `http://127.0.0.1:${port}`,
  194. })
  195. socket.once('unexpected-response', (_request, response) => {
  196. resolve(response.statusCode ?? 0)
  197. response.resume()
  198. })
  199. socket.once('error', reject)
  200. })
  201. expect(status).toBe(404)
  202. })
  203. })