From af1c1a8eb41b9e7646179580e666c1ffb45b8a6e Mon Sep 17 00:00:00 2001 From: lovefyf Date: Thu, 13 Aug 2026 05:04:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BD=91=E5=85=B3=E6=8C=89=E5=9F=9F?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E2=80=94=E2=80=94=E9=AA=8C=E7=AD=BE=E6=8B=A6?= =?UTF-8?q?=E9=97=A8=E3=80=81=E5=89=A5=E4=BC=AA=E9=80=A0=E5=86=85=E9=83=A8?= =?UTF-8?q?=E5=A4=B4=E3=80=81Authorization=20=E5=8E=9F=E6=A0=B7=E8=BD=AC?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/app.ts | 4 +- src/modules/proxy/forward.ts | 37 ++++++++++++++ src/modules/proxy/routes.ts | 22 +++++++++ test/integration/proxy.test.ts | 89 ++++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 src/modules/proxy/forward.ts create mode 100644 src/modules/proxy/routes.ts create mode 100644 test/integration/proxy.test.ts diff --git a/src/app/app.ts b/src/app/app.ts index e6a1874..d3adfbc 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,10 +1,12 @@ import { Hono } from 'hono'; import { healthRoute, registerHealthCheck } from './health'; import { checkNats } from '../shared/bus/nats'; +import { proxyRoutes } from '../modules/proxy/routes'; export function buildApp(): Hono { registerHealthCheck('nats', checkNats); const app = new Hono(); - app.route('/health', healthRoute()); + app.route('/health', healthRoute()); // 公开:不过 requireAuth + app.route('/api', proxyRoutes()); return app; } diff --git a/src/modules/proxy/forward.ts b/src/modules/proxy/forward.ts new file mode 100644 index 0000000..7e3bdbb --- /dev/null +++ b/src/modules/proxy/forward.ts @@ -0,0 +1,37 @@ +import type { Context } from 'hono'; + +const STRIP_HEADERS = ['x-user-id', 'x-user-roles', 'x-internal-token', 'host', 'content-length']; + +/** + * 回传时必须丢掉的下游响应头:undici 已按 content-encoding 解过压, + * body 是明文,再原样回传 content-encoding/content-length/transfer-encoding + * 就是自相矛盾的响应(客户端会二次解压失败或按错长度截断)。 + */ +const STRIP_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding']; + +/** 验签后转发:Authorization 原样带上(下游自己再验一遍——纵深防御,指南 5.6) */ +export async function forward(c: Context, target: string, stripPrefix: string): Promise { + const search = new URL(c.req.url).search; + const path = c.req.path.slice(stripPrefix.length) || '/'; + const headers = new Headers(c.req.raw.headers); + for (const h of STRIP_HEADERS) headers.delete(h); + const method = c.req.method; + const init: RequestInit & { duplex?: 'half' } = { method, headers }; + if (method !== 'GET' && method !== 'HEAD') { + init.body = c.req.raw.body; + init.duplex = 'half'; // Node fetch 流式请求体必需 + } + let res: Response; + try { + res = await fetch(new URL(path + search, target), init); + } catch { + // 下游没起/连不上:给统一错误形状的 502,并点名是哪个下游 + return c.json( + { error: 'bad_gateway', message: `下游服务 ${target} 不可达`, hint: '确认目标服务已启动' }, + 502, + ); + } + const outHeaders = new Headers(res.headers); + for (const h of STRIP_RESPONSE_HEADERS) outHeaders.delete(h); + return new Response(res.body, { status: res.status, headers: outHeaders }); +} diff --git a/src/modules/proxy/routes.ts b/src/modules/proxy/routes.ts new file mode 100644 index 0000000..b9213d2 --- /dev/null +++ b/src/modules/proxy/routes.ts @@ -0,0 +1,22 @@ +import { Hono } from 'hono'; +import { config } from '../../shared/config'; +import { requireAuth } from '../../shared/keycloak-auth'; +import { forward } from './forward'; + +/** 目标地址取自 config——env+缺省只在 config.ts 读一次,此处不再二次读 env */ +function target(name: 'TASK' | 'DATA' | 'AGENT'): string { + return { + TASK: config.taskServiceUrl, + DATA: config.dataServiceUrl, + AGENT: config.agentServiceUrl, + }[name]; +} + +export function proxyRoutes(): Hono { + const r = new Hono(); + r.use('*', requireAuth()); + r.all('/task/*', (c) => forward(c, target('TASK'), '/api/task')); + r.all('/data/*', (c) => forward(c, target('DATA'), '/api/data')); + r.all('/agent/*', (c) => forward(c, target('AGENT'), '/api/agent')); + return r; +} diff --git a/test/integration/proxy.test.ts b/test/integration/proxy.test.ts new file mode 100644 index 0000000..4638b9e --- /dev/null +++ b/test/integration/proxy.test.ts @@ -0,0 +1,89 @@ +import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; +import { gzipSync } from 'node:zlib'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { buildApp } from '../../src/app/app'; +import { makeToken, setupTestJwks } from '../support/token'; + +// C5:路由的目标地址取自 config,而 config 在模块求值时一次性读 env—— +// 注入必须早于 import(vi.hoisted 会被提升到所有 import 之前)。 +vi.hoisted(() => { + process.env.TASK_SERVICE_URL = 'http://127.0.0.1:18811'; + process.env.DATA_SERVICE_URL = 'http://127.0.0.1:1'; // 保证连不上:验 502 +}); + +let downstream: ReturnType; +let captured: { auth?: string; fake?: string; path?: string } = {}; + +describe('gateway proxy', () => { + let token = ''; + beforeAll(async () => { + await setupTestJwks(); + token = await makeToken({ roles: ['user'], aud: 'api-gateway' }); + const echo = new Hono(); + // 下游压缩响应:undici 会替网关解码 body,content-encoding 不能原样回传(D9) + echo.get('/gzip', () => { + const body = gzipSync(JSON.stringify({ from: 'downstream' })); + return new Response(body, { + headers: { + 'content-encoding': 'gzip', + 'content-type': 'application/json', + 'content-length': String(body.byteLength), + }, + }); + }); + echo.all('*', (c) => { + captured = { + auth: c.req.header('authorization') ?? '', + fake: c.req.header('x-user-id') ?? '', + path: c.req.path, + }; + return c.json({ from: 'downstream' }); + }); + // D11:等 listening 回调再往下走,否则首个用例可能打在还没起来的端口上 + downstream = await new Promise>((resolve) => { + const s = serve({ fetch: echo.fetch, port: 18811 }, () => resolve(s)); + }); + }); + afterAll(() => { + downstream.close(); + }); + + it('无 token → 401,不打扰下游', async () => { + captured = {}; + const res = await buildApp().request('/api/task/task/t-1/view'); + expect(res.status).toBe(401); + expect(captured.path).toBeUndefined(); + }); + + it('带 token 转发:前缀剥除、Authorization 原样、伪造内部头被剥', async () => { + const res = await buildApp().request('/api/task/task/t-1/view', { + headers: { authorization: `Bearer ${token}`, 'x-user-id': 'forged-user' }, + }); + expect(res.status).toBe(200); + expect(captured.path).toBe('/task/t-1/view'); + expect(captured.auth).toBe(`Bearer ${token}`); + expect(captured.fake).toBe(''); + }); + + it('下游不可达 → 502 统一错误形状,点名是哪个下游(C3)', async () => { + const res = await buildApp().request('/api/data/ledger/domains', { + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(502); + const body = (await res.json()) as { error: string; message: string; hint: string }; + expect(body.error).toBe('bad_gateway'); + expect(body.message).toContain('http://127.0.0.1:1'); + expect(body.hint).toBeTruthy(); + }); + + it('下游 gzip 响应:解码后的 body 不得再带 content-encoding(D9)', async () => { + const res = await buildApp().request('/api/task/gzip', { + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(200); + expect(res.headers.get('content-encoding')).toBeNull(); + expect(res.headers.get('content-length')).toBeNull(); + expect(await res.json()).toEqual({ from: 'downstream' }); + }); +});