feat: 网关按域路由——验签拦门、剥伪造内部头、Authorization 原样转发
This commit is contained in:
+3
-1
@@ -1,10 +1,12 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { healthRoute, registerHealthCheck } from './health';
|
import { healthRoute, registerHealthCheck } from './health';
|
||||||
import { checkNats } from '../shared/bus/nats';
|
import { checkNats } from '../shared/bus/nats';
|
||||||
|
import { proxyRoutes } from '../modules/proxy/routes';
|
||||||
|
|
||||||
export function buildApp(): Hono {
|
export function buildApp(): Hono {
|
||||||
registerHealthCheck('nats', checkNats);
|
registerHealthCheck('nats', checkNats);
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.route('/health', healthRoute());
|
app.route('/health', healthRoute()); // 公开:不过 requireAuth
|
||||||
|
app.route('/api', proxyRoutes());
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Response> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<typeof serve>;
|
||||||
|
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<ReturnType<typeof serve>>((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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user