upstream.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import type Koa from 'koa'
  2. import { createHash } from 'node:crypto'
  3. import { readCacheContext, readPolicy, mutationTags, UpstreamReadCache } from './upstreamReadCache.js'
  4. import { isLocalAuthorizationTarget, LoopbackTransport } from './loopbackAuthorization.js'
  5. import { parse } from 'cookie'
  6. import { HttpError } from './errors.js'
  7. import { appendSetCookies, CSRF_COOKIE } from './security.js'
  8. const RESPONSE_HEADER_ALLOWLIST = [
  9. 'accept-ranges',
  10. 'cache-control',
  11. 'content-disposition',
  12. 'content-range',
  13. 'content-type',
  14. 'etag',
  15. 'last-modified',
  16. ] as const
  17. function headerSetCookies(headers: Headers): string[] {
  18. if (typeof headers.getSetCookie === 'function') return headers.getSetCookie()
  19. const combined = headers.get('set-cookie')
  20. if (!combined) return []
  21. // This fallback is only for fetch shims without getSetCookie. It avoids
  22. // splitting the comma in an Expires attribute.
  23. return combined.split(/,(?=\s*[^;,\s]+=)/g).map((value) => value.trim())
  24. }
  25. function cookiePair(value: string): [string, string] | null {
  26. const pair = value.split(';', 1)[0]?.trim()
  27. const separator = pair?.indexOf('=') ?? -1
  28. if (!pair || separator <= 0) return null
  29. const name = pair.slice(0, separator).trim()
  30. const cookieValue = pair.slice(separator + 1).trim()
  31. if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)) return null
  32. return [name, cookieValue]
  33. }
  34. function browserCookie(value: string, publicSecure: boolean): string | null {
  35. const pair = cookiePair(value)
  36. if (!pair || pair[0] === CSRF_COOKIE) return null
  37. const attributes = value.split(';').slice(1).map((part) => part.trim())
  38. const kept = attributes.filter((attribute) => {
  39. const name = attribute.split('=', 1)[0]?.toLowerCase()
  40. return name !== 'domain' && name !== 'path' && name !== 'secure'
  41. })
  42. return [`${pair[0]}=${pair[1]}`, 'Path=/', ...kept, ...(publicSecure ? ['Secure'] : [])].join('; ')
  43. }
  44. export class CookieJar {
  45. readonly #values = new Map<string, string>()
  46. readonly #browserCookies = new Map<string, string>()
  47. #sessionToken: string | undefined
  48. constructor(cookieHeader?: string) {
  49. for (const [name, value] of Object.entries(parse(cookieHeader ?? ''))) {
  50. if (name !== CSRF_COOKIE && value !== undefined) this.#values.set(name, value)
  51. }
  52. }
  53. absorb(headers: Headers, publicSecure = false): void {
  54. for (const raw of headerSetCookies(headers)) {
  55. const pair = cookiePair(raw)
  56. const sanitized = browserCookie(raw, publicSecure)
  57. if (!pair || !sanitized) continue
  58. const [name, value] = pair
  59. if (!value || /(?:^|;)\s*max-age=0(?:;|$)/i.test(raw)) this.#values.delete(name)
  60. else this.#values.set(name, value)
  61. this.#browserCookies.set(name, sanitized)
  62. }
  63. }
  64. get header(): string | undefined {
  65. if (this.#values.size === 0) return undefined
  66. return [...this.#values.entries()].map(([name, value]) => `${name}=${value}`).join('; ')
  67. }
  68. get sessionToken(): string | undefined { return this.#sessionToken }
  69. setSessionToken(value: string | undefined): void {
  70. this.#sessionToken = value
  71. }
  72. replace(other: CookieJar): void {
  73. this.#values.clear(); this.#browserCookies.clear()
  74. for (const [name, value] of other.#values) this.#values.set(name, value)
  75. this.#sessionToken = other.#sessionToken
  76. }
  77. get browserCookies(): string[] {
  78. return [...this.#browserCookies.values()]
  79. }
  80. }
  81. export interface UpstreamResponse {
  82. status: number
  83. headers: Headers
  84. body: Buffer
  85. }
  86. export class UpstreamHttpError extends HttpError {
  87. constructor(readonly response: UpstreamResponse) {
  88. super(response.status, `Hermes returned HTTP ${response.status}`, 'upstream_http_error')
  89. }
  90. }
  91. export interface UpstreamRequestOptions {
  92. cache?: 'reload'
  93. method?: string
  94. search?: URLSearchParams
  95. body?: unknown
  96. rawBody?: BodyInit
  97. headers?: Record<string, string>
  98. maxResponseBytes?: number
  99. clientAddress?: string
  100. }
  101. function requestTimeoutMessage(path: string, options: UpstreamRequestOptions, seconds: number): string | undefined {
  102. const stages: Record<string, string> = {
  103. '/api/config': '读取配置',
  104. '/api/plugins/yaoyao-bot-bridge/capabilities': '检查工具桥',
  105. '/api/plugins/yaoyao-bot-bridge/bind': '绑定工具桥',
  106. '/api/plugins/yaoyao-bot-bridge/unbind': '解除工具桥绑定',
  107. '/api/plugins/yaoyao-bot-bridge/memory-extract': '提炼记忆',
  108. '/api/plugins/yaoyao-bot-bridge/computer-file': '传输文件',
  109. }
  110. const stage = stages[path]
  111. if (!stage) return undefined
  112. const bodyProfile = options.body && typeof options.body === 'object' && 'profile' in options.body ? options.body.profile : undefined
  113. const profile = options.search?.get('profile') ?? bodyProfile
  114. // Only a validated Profile name may enter the error; never expose request
  115. // bodies, query strings, bridge tokens or upstream credentials.
  116. const scope = typeof profile === 'string' && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile) ? `Profile「${profile}」` : ''
  117. return `Hermes 请求超时(${seconds} 秒):${scope}${stage}。`
  118. }
  119. export class UpstreamClient {
  120. readonly readCache: UpstreamReadCache
  121. private loopback = new LoopbackTransport()
  122. private reauthentication = new WeakMap<CookieJar, () => Promise<void>>()
  123. constructor(
  124. readonly baseURL: URL,
  125. readonly fetchImpl: typeof fetch = fetch,
  126. readonly publicSecure = false,
  127. now: () => number = Date.now,
  128. ) { this.readCache = new UpstreamReadCache(now) }
  129. get directAgent() { return isLocalAuthorizationTarget(this.baseURL) ? this.loopback.agent(this.baseURL) : undefined }
  130. close(): void { this.readCache.close(); this.loopback.close() }
  131. withReadScope<T>(scope: string, fresh: boolean, run: () => T): T {
  132. return readCacheContext.run({ scope, fresh }, run)
  133. }
  134. invalidateReads(tags?: readonly string[]): void { this.readCache.invalidate(tags) }
  135. observeRealtime(change: { kind: 'command' | 'event' | 'group' | 'reset'; name: string; sessionId?: string; roomId?: string }): void {
  136. if (change.kind === 'reset') { this.invalidateReads(['sessions', 'groups', 'profiles', 'models']); return }
  137. if (change.kind === 'group') {
  138. if (change.name === 'group.heartbeat' || change.name === 'group.ready') return
  139. this.invalidateReads(change.roomId ? ['group-list', `group:${change.roomId}`] : ['groups'])
  140. return
  141. }
  142. if (/^(profiles\.(configure|set_asset|changed)|pet\.changed|models\.changed|model\.)/.test(change.name)) {
  143. this.invalidateReads(['profiles', 'models']); return
  144. }
  145. if (change.name === 'sessions.changed') { this.invalidateReads(['sessions']); return }
  146. if (change.kind === 'command' && !/^(prompt\.submit|session\.(create|resume|branch|close|steer|interrupt)|config\.set|approval\.respond|clarify\.respond)$/.test(change.name)) return
  147. if (change.kind === 'event' && !/^(message\.|session\.|config\.|compression\.|run\.|error$)/.test(change.name)) return
  148. const tags = change.sessionId ? [`session:${change.sessionId}`] : ['sessions']
  149. if (change.kind === 'command' || !/\.delta$/.test(change.name)) tags.push('session-list')
  150. if (change.name === 'config.set') tags.push('models', 'profiles')
  151. this.invalidateReads(tags)
  152. }
  153. setReauthenticationHandler(jar: CookieJar, renew: () => Promise<void>): void { this.reauthentication.set(jar, renew) }
  154. async request(path: string, jar: CookieJar, options: UpstreamRequestOptions = {}): Promise<UpstreamResponse> {
  155. const method = options.method ?? 'GET'
  156. const tags = ['GET', 'HEAD'].includes(method) ? undefined : mutationTags(path)
  157. if (tags) this.invalidateReads(tags)
  158. const context = readCacheContext.getStore()
  159. const forcedTags = method === 'GET' && (context?.fresh || options.cache === 'reload') ? readPolicy(path, options.search)?.tags : undefined
  160. if (forcedTags) this.invalidateReads(forcedTags)
  161. const load = async () => {
  162. const sentCredentials = JSON.stringify([jar.header, jar.sessionToken])
  163. let response = await this.fetchResponse(path, jar, options)
  164. if (response.status === 401 || response.status === 403) this.invalidateReads()
  165. const renew = this.reauthentication.get(jar)
  166. if (response.status === 401 && renew && !path.startsWith('/api/auth/') && !path.startsWith('/auth/')) {
  167. // A parallel request may already have rotated the service cookies. Never replay
  168. // a whole route handler (it may have performed several writes); repair this one 401 only.
  169. if (JSON.stringify([jar.header, jar.sessionToken]) === sentCredentials) await renew()
  170. response = await this.fetchResponse(path, jar, options)
  171. // This jar belongs to the Hermes service, not the signed-in Yaoyao user.
  172. // A failed renewal must not send the client back to its account login.
  173. if (response.status === 401) {
  174. throw new HttpError(502, 'Hermes 服务认证暂时不可用,请稍后重试', 'upstream_auth_unavailable')
  175. }
  176. }
  177. return response
  178. }
  179. try {
  180. const headers = new Headers(options.headers)
  181. const policy = method === 'GET' && context && !context.fresh && options.cache !== 'reload'
  182. && !['range', 'if-none-match', 'if-modified-since', 'if-match', 'if-unmodified-since'].some(name => headers.has(name))
  183. ? readPolicy(path, options.search) : undefined
  184. let response: UpstreamResponse
  185. if (policy && context) {
  186. const search = new URLSearchParams(options.search); search.sort()
  187. const key = createHash('sha256').update(JSON.stringify([
  188. context.scope, this.baseURL.href, path, search.toString(), jar.header ?? '', jar.sessionToken ?? '',
  189. [...headers.entries()].sort(([a], [b]) => a.localeCompare(b)), options.clientAddress ?? '', options.maxResponseBytes ?? null,
  190. ])).digest('hex')
  191. response = await this.readCache.read(key, policy, load)
  192. } else response = await load()
  193. // Snapshot-after-anchor ordering: no room snapshot fetched before this fresh
  194. // journal cursor may be reused to bootstrap a stream starting at that cursor.
  195. return response
  196. } finally {
  197. if (tags) this.invalidateReads(tags)
  198. if (forcedTags) this.invalidateReads(forcedTags)
  199. }
  200. }
  201. private async fetchResponse(
  202. path: string,
  203. jar: CookieJar,
  204. options: UpstreamRequestOptions = {},
  205. ): Promise<UpstreamResponse> {
  206. if (!path.startsWith('/') || path.startsWith('//') || path.includes('\\')) {
  207. throw new HttpError(500, 'Invalid internal upstream path')
  208. }
  209. const url = new URL(this.baseURL)
  210. const prefix = this.baseURL.pathname === '/' ? '' : this.baseURL.pathname.replace(/\/$/, '')
  211. const expectedPath = `${prefix}${path}`
  212. url.pathname = expectedPath
  213. if (url.pathname !== expectedPath) {
  214. throw new HttpError(500, 'Upstream path normalization escaped its allowlisted route')
  215. }
  216. url.search = options.search?.toString() ?? ''
  217. url.hash = ''
  218. const headers = new Headers({
  219. accept: 'application/json',
  220. origin: this.baseURL.origin,
  221. 'x-forwarded-host': this.baseURL.host,
  222. 'x-forwarded-proto': this.publicSecure ? 'https' : 'http',
  223. ...options.headers,
  224. })
  225. if (options.clientAddress) {
  226. const clientAddress = options.clientAddress.replace(/^::ffff:/, '')
  227. if (/^[0-9a-f:.]+$/i.test(clientAddress)) headers.set('x-forwarded-for', clientAddress)
  228. }
  229. if (jar.header) headers.set('cookie', jar.header)
  230. if (jar.sessionToken) {
  231. if (!isLocalAuthorizationTarget(this.baseURL)) throw new HttpError(403, 'Local authorization cannot be sent to a remote upstream', 'loopback_auth_required')
  232. headers.set('X-Hermes-Session-Token', jar.sessionToken)
  233. }
  234. let body: BodyInit | undefined
  235. if (options.rawBody !== undefined) {
  236. body = options.rawBody
  237. } else if (options.body !== undefined) {
  238. headers.set('content-type', 'application/json')
  239. body = JSON.stringify(options.body)
  240. }
  241. let response: Response
  242. let timeout: ReturnType<typeof setTimeout> | undefined
  243. let timedOut = false
  244. const isBridgeBind = path === '/api/plugins/yaoyao-bot-bridge/bind' && options.method === 'POST'
  245. const requestTimeout = isBridgeBind ? 60_000 : path === '/api/plugins/yaoyao-bot-bridge/memory-extract' && options.method === 'POST' ? 110_000 : 30_000
  246. const startedAt = Date.now()
  247. const controller = new AbortController()
  248. try {
  249. response = await Promise.race([
  250. (isLocalAuthorizationTarget(url) && this.fetchImpl === globalThis.fetch ? this.loopback.fetch.bind(this.loopback) : this.fetchImpl)(url, {
  251. method: options.method ?? 'GET',
  252. headers,
  253. body,
  254. redirect: 'manual',
  255. signal: controller.signal,
  256. }),
  257. new Promise<never>((_resolve, reject) => {
  258. timeout = setTimeout(() => { timedOut = true; controller.abort(); reject(new Error('request timed out')) }, requestTimeout)
  259. timeout.unref()
  260. }),
  261. ])
  262. } catch (error) {
  263. const message = error instanceof Error ? error.message : 'connection failed'
  264. throw new HttpError(502, (timedOut && requestTimeoutMessage(path, options, requestTimeout / 1000)) || `Unable to reach Hermes: ${message}`, 'upstream_unavailable')
  265. } finally {
  266. if (timeout) clearTimeout(timeout)
  267. }
  268. jar.absorb(response.headers, this.publicSecure)
  269. const declaredLength = Number(response.headers.get('content-length') ?? '0')
  270. const limit = options.maxResponseBytes ?? 64 * 1_024 * 1_024
  271. if (Number.isFinite(declaredLength) && declaredLength > limit) {
  272. await response.body?.cancel()
  273. throw new HttpError(502, 'Hermes response exceeded the configured limit', 'upstream_too_large')
  274. }
  275. // Enforce limits while reading, including chunked native-token pages without
  276. // Content-Length. Do not buffer an unbounded response before checking it.
  277. const chunks: Buffer[] = []
  278. let bytes = 0
  279. const reader = response.body?.getReader()
  280. if (reader) {
  281. let bodyTimeout: ReturnType<typeof setTimeout> | undefined
  282. // Binding shares one 60-second budget across headers and body reads.
  283. const bodyTimeoutMs = isBridgeBind ? Math.max(0, requestTimeout - (Date.now() - startedAt)) : 30_000
  284. try {
  285. const expired = new Promise<never>((_resolve, reject) => {
  286. bodyTimeout = setTimeout(() => {
  287. reject(new HttpError(502, requestTimeoutMessage(path, options, isBridgeBind ? requestTimeout / 1000 : 30) ?? 'Hermes response timed out', 'upstream_unavailable'))
  288. void reader.cancel().catch(() => {})
  289. }, bodyTimeoutMs)
  290. bodyTimeout.unref()
  291. })
  292. while (true) {
  293. const part = await Promise.race([reader.read(), expired])
  294. if (part.done) break
  295. bytes += part.value.byteLength
  296. if (bytes > limit) {
  297. void reader.cancel().catch(() => {})
  298. throw new HttpError(502, 'Hermes response exceeded the configured limit', 'upstream_too_large')
  299. }
  300. chunks.push(Buffer.from(part.value))
  301. }
  302. } finally { if (bodyTimeout) clearTimeout(bodyTimeout); reader.releaseLock() }
  303. }
  304. const bodyBuffer = Buffer.concat(chunks, bytes)
  305. return { status: response.status, headers: response.headers, body: bodyBuffer }
  306. }
  307. async json<T>(
  308. path: string,
  309. jar: CookieJar,
  310. options: UpstreamRequestOptions = {},
  311. ): Promise<T> {
  312. const response = await this.request(path, jar, options)
  313. if (response.status < 200 || response.status >= 300) throw new UpstreamHttpError(response)
  314. try {
  315. return JSON.parse(response.body.toString('utf8')) as T
  316. } catch {
  317. throw new HttpError(502, 'Hermes returned invalid JSON', 'invalid_upstream_json')
  318. }
  319. }
  320. }
  321. export function applyUpstreamCookies(ctx: Koa.Context, jar: CookieJar): void {
  322. appendSetCookies(ctx, jar.browserCookies)
  323. }
  324. export function sendUpstreamResponse(
  325. ctx: Koa.Context,
  326. response: UpstreamResponse,
  327. _jar: CookieJar,
  328. ): void {
  329. ctx.status = response.status
  330. for (const name of RESPONSE_HEADER_ALLOWLIST) {
  331. const value = response.headers.get(name)
  332. if (value) ctx.set(name, value)
  333. }
  334. ctx.body = response.status === 204 || ctx.method === 'HEAD' ? null : response.body
  335. }
  336. export function decodeUpstreamError(response: UpstreamResponse): { error: string; code: string } {
  337. try {
  338. const value = JSON.parse(response.body.toString('utf8')) as Record<string, unknown>
  339. const message = typeof value.error === 'string'
  340. ? value.error
  341. : typeof value.message === 'string' ? value.message : `Hermes returned HTTP ${response.status}`
  342. return { error: message, code: 'upstream_rejected' }
  343. } catch {
  344. return { error: `Hermes returned HTTP ${response.status}`, code: 'upstream_rejected' }
  345. }
  346. }