feat(deploy): ausgehender HTTP(S)-Proxy für Build und Laufzeit

- Compose reicht HTTP_PROXY/HTTPS_PROXY/NO_PROXY als Build-Argumente an beide Images weiter
  (npm ci, Google Fonts) und zur Laufzeit an api und worker; api, db, web stehen immer in NO_PROXY
- Spielerköpfe werden über undici EnvHttpProxyAgent geladen (Node-fetch ignoriert Proxy-Variablen)
- Doku: Proxy-Abschnitt mit Whitelist und Fehlerbildern
- geprüft mit lokalem Squid: Build nur über registry.npmjs.org, fonts.googleapis.com, fonts.gstatic.com;
  zur Laufzeit nur mc-heads.net

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lucashahmannandClaude Opus 5.5 committed 2026-10-13 10:35:46 +02:00
1 parent e66f89f49e
commit d45d1821df
10 files changed
+179 -4

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
import { createServer, type Server } from 'node:http';
import { connect, type AddressInfo, type Socket } from 'node:net';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../src/app.ts';
import { loadConfig } from '../src/config.ts';
import { getTestPool } from './helpers/db.ts';
import { TEST_SECRET } from './helpers/sign.ts';
const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex');
const UUID = '308a693c-2bd8-4d6b-8790-670aef5be601';
let upstream: Server;
let proxy: Server;
const proxied: string[] = [];
const port = (s: Server) => (s.address() as AddressInfo).port;
const listen = (s: Server) => new Promise<void>((r) => s.listen(0, '127.0.0.1', () => r()));
beforeAll(async () => {
upstream = createServer((_req, res) => res.writeHead(200, { 'content-type': 'image/png' }).end(PNG));
// Minimal forward proxy like Squid: undici tunnels every request with CONNECT host:port.
// "upstream.test" only exists for the proxy, which relays to the local upstream server.
proxy = createServer((_req, res) => res.writeHead(405).end());
proxy.on('connect', (req, client: Socket, head) => {
proxied.push(req.url ?? '');
const upstreamSocket = connect(Number(new URL(`http://${req.url}`).port), '127.0.0.1', () => {
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
upstreamSocket.write(head);
upstreamSocket.pipe(client);
client.pipe(upstreamSocket);
});
});
await listen(upstream);
await listen(proxy);
});
afterAll(() => {
upstream.close();
proxy.close();
});
async function avatarVia(env: Record<string, string>) {
const config = loadConfig({
NODE_ENV: 'test',
DATABASE_URL: process.env.TEST_DATABASE_URL,
INGEST_HMAC_SECRET: TEST_SECRET,
LOG_LEVEL: 'silent',
AVATAR_SOURCE: `http://upstream.test:${port(upstream)}/avatar/{uuid}`,
...env,
});
const app = await buildApp({ pool: await getTestPool(), config });
try {
return await app.inject({ method: 'GET', url: `/api/avatar/${UUID}` });
} finally {
await app.close();
}
}
describe('outbound proxy', () => {
it('fetches player heads through HTTP_PROXY', async () => {
proxied.length = 0;
const res = await avatarVia({ HTTP_PROXY: `http://127.0.0.1:${port(proxy)}` });
expect(res.statusCode).toBe(200);
expect(res.rawPayload.equals(PNG)).toBe(true);
expect(proxied).toEqual([`upstream.test:${port(upstream)}`]);
});
it('accepts lower-case proxy variables', () => {
const c = loadConfig({ DATABASE_URL: 'postgres://x', INGEST_HMAC_SECRET: 'x'.repeat(32), https_proxy: 'http://p:3128', no_proxy: '.local' });
expect(c.proxy).toEqual({ httpProxy: undefined, httpsProxy: 'http://p:3128', noProxy: '.local' });
});
it('uses no proxy when none is configured', () => {
const c = loadConfig({ DATABASE_URL: 'postgres://x', INGEST_HMAC_SECRET: 'x'.repeat(32) });
expect(c.proxy).toBeNull();
});
});