mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +00:00
fix(backend): restore remote proxy mount order before local routers (#747)
The index.ts refactor inverted the proxy mount order. The pre-refactor
monolith mounted `app.use('/api/', remoteNodeProxy)` before any inline
route, so remote-nodeId requests short-circuited into the proxy. After
the refactor the proxy was registered after every per-group router, so
Express matched local routers first and remote-nodeId requests were
silently handled with the control instance's local state (e.g.
GET /api/stacks with x-node-id=<remote> returned local stacks rather
than the remote's).
Fix moves createRemoteProxyMiddleware() between enforceApiTokenScope
and the first per-group router, matching middleware-order.md step 13
and restoring pre-refactor behavior. PROXY_EXEMPT_PREFIXES continues to
cover gateway-level paths (auth, nodes, license, fleet, webhooks, meta)
that must stay local even when x-node-id targets a remote.
Add four regression guards that would have caught this:
- json-parser-bypass.test.ts: asserts conditionalJsonParser leaves the
request stream intact on proxy-eligible paths so http-proxy can pipe
the raw body to the upstream; spins up a local echo server and
verifies the bytes arrive.
- proxy-mount-order.test.ts: asserts a remote-nodeId GET short-circuits
into the proxy (502 from unreachable upstream) instead of matching a
local router (200 from local state).
- upgrade-order.test.ts: pins WebSocket dispatch order by observing
handler-specific side effects for notifications, remote forwarder,
logs, and pilot tunnel.
- remote-console-session.test.ts: asserts the HTTP console-token route
mints a JWT with the same claim shape as the shared mintConsoleSession
helper, so gateway and WS forwarder tokens remain interchangeable.
Full suite: 73 files, 1,358 tests, all passing.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Regression guard for the `conditionalJsonParser` remote-proxy bypass.
|
||||
*
|
||||
* When a request targets a remote node via `x-node-id` and the path is NOT in
|
||||
* `PROXY_EXEMPT_PREFIXES`, the JSON parser must leave the request stream
|
||||
* untouched so `http-proxy` can pipe the raw body to the upstream Sencho
|
||||
* instance. If the parser runs, `req.pipe(proxyReq)` errors with
|
||||
* `ERR_HTTP_STREAM_WRITE_AFTER_END` and the remote never sees the body.
|
||||
*
|
||||
* This test spins up a tiny HTTP echo server, seeds a remote node pointing at
|
||||
* it, and POSTs a JSON body through the proxy. The echo server asserts the
|
||||
* bytes arrived intact. A second case confirms that exempt paths are handled
|
||||
* locally (upstream receives nothing).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import http from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
describe('conditionalJsonParser remote-proxy bypass', () => {
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let upstream: http.Server;
|
||||
let upstreamUrl: string;
|
||||
let lastUpstreamBody: Buffer | null = null;
|
||||
let lastUpstreamAuth: string | null = null;
|
||||
let authHeader: string;
|
||||
let remoteNodeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
|
||||
upstream = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c: Buffer) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
lastUpstreamBody = Buffer.concat(chunks);
|
||||
lastUpstreamAuth = (req.headers['authorization'] as string | undefined) ?? null;
|
||||
res.statusCode = 200;
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end('{"ok":true}');
|
||||
});
|
||||
req.on('error', () => {
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
||||
const addr = upstream.address() as AddressInfo;
|
||||
upstreamUrl = `http://127.0.0.1:${addr.port}`;
|
||||
|
||||
({ app } = await import('../index'));
|
||||
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
remoteNodeId = DatabaseService.getInstance().addNode({
|
||||
name: 'bypass-test-remote',
|
||||
type: 'remote',
|
||||
compose_dir: '/tmp',
|
||||
is_default: false,
|
||||
api_url: upstreamUrl,
|
||||
api_token: 'bypass-test-token',
|
||||
});
|
||||
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
authHeader = `Bearer ${token}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
it('forwards the raw request body to the remote for proxy-eligible paths', async () => {
|
||||
lastUpstreamBody = null;
|
||||
lastUpstreamAuth = null;
|
||||
|
||||
const payload = { name: 'parser-bypass-stack', content: 'services:\n web:\n image: nginx' };
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/stacks')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId))
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(payload);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(lastUpstreamBody).not.toBeNull();
|
||||
expect(lastUpstreamBody!.length).toBeGreaterThan(0);
|
||||
const parsed = JSON.parse(lastUpstreamBody!.toString('utf-8'));
|
||||
expect(parsed).toEqual(payload);
|
||||
expect(lastUpstreamAuth).toBe('Bearer bypass-test-token');
|
||||
});
|
||||
|
||||
it('handles proxy-exempt paths locally (upstream receives nothing)', async () => {
|
||||
lastUpstreamBody = null;
|
||||
lastUpstreamAuth = null;
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/nodes/${remoteNodeId}`)
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId));
|
||||
|
||||
expect(lastUpstreamBody).toBeNull();
|
||||
expect(lastUpstreamAuth).toBeNull();
|
||||
expect([200, 404]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user