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

+1
View File
@@ -30,6 +30,7 @@
"pg": "^8.23.1",
"pino": "^9.14.0",
"ssh2-sftp-client": "^12.1.1",
"undici": "^7.30.0",
"zod": "^3.25.76"
}
}
+15 -1
View File
@@ -27,10 +27,24 @@ const envSchema = z.object({
LOG_LEVEL: z.string().default('info'),
});
export interface ProxyConfig {
httpProxy: string | undefined;
httpsProxy: string | undefined;
noProxy: string | undefined;
}
export type Config = z.infer<typeof envSchema> & {
sftp: { host: string; port: number; username: string; password: string; root: string } | null;
/** Outbound HTTP(S) proxy from the usual environment variables (upper or lower case); null when unset. */
proxy: ProxyConfig | null;
};
function proxyFromEnv(env: Record<string, string | undefined>): ProxyConfig | null {
const pick = (name: string) => env[name] || env[name.toLowerCase()] || undefined;
const proxy = { httpProxy: pick('HTTP_PROXY'), httpsProxy: pick('HTTPS_PROXY'), noProxy: pick('NO_PROXY') };
return proxy.httpProxy || proxy.httpsProxy ? proxy : null;
}
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
const r = envSchema.safeParse(env);
if (!r.success) {
@@ -42,5 +56,5 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
c.SFTP_HOST && c.SFTP_USER && c.SFTP_PASSWORD
? { host: c.SFTP_HOST, port: c.SFTP_PORT, username: c.SFTP_USER, password: c.SFTP_PASSWORD, root: c.SFTP_ROOT }
: null;
return { ...c, sftp };
return { ...c, sftp, proxy: proxyFromEnv(env) };
}
+18
View File
@@ -0,0 +1,18 @@
import { EnvHttpProxyAgent, fetch as undiciFetch, type Dispatcher } from 'undici';
import type { ProxyConfig } from '../config.ts';
export type HttpFetch = (url: string, init?: { signal?: AbortSignal }) => Promise<Response>;
/**
* fetch for outbound internet requests. Node's built-in fetch ignores HTTP(S)_PROXY, so behind a forward
* proxy (e.g. Squid in the DMZ) requests go through undici's EnvHttpProxyAgent, which also honours NO_PROXY.
*/
export function createHttpFetch(proxy: ProxyConfig | null): HttpFetch {
if (!proxy) return (url, init) => fetch(url, init);
const dispatcher: Dispatcher = new EnvHttpProxyAgent({
httpProxy: proxy.httpProxy ?? proxy.httpsProxy,
httpsProxy: proxy.httpsProxy ?? proxy.httpProxy,
noProxy: proxy.noProxy,
});
return (url, init) => undiciFetch(url, { ...init, dispatcher }) as unknown as Promise<Response>;
}
+3 -2
View File
@@ -1,4 +1,5 @@
import type { FastifyInstance } from 'fastify';
import type { HttpFetch } from '../../net/http.ts';
import { UUID_RE } from './shared.ts';
const TTL_MS = 24 * 3600_000;
@@ -15,7 +16,7 @@ const PLACEHOLDER = Buffer.from(
* Player heads are fetched server-side and cached, so visitors' browsers never contact third parties
* (no visitor IPs leave the portal).
*/
export async function avatarRoutes(app: FastifyInstance, { source }: { source: string }) {
export async function avatarRoutes(app: FastifyInstance, { source, httpFetch }: { source: string; httpFetch: HttpFetch }) {
const cache = new Map<string, { png: Buffer; at: number }>();
app.get<{ Params: { uuid: string } }>('/api/avatar/:uuid', async (req, reply) => {
@@ -26,7 +27,7 @@ export async function avatarRoutes(app: FastifyInstance, { source }: { source: s
const hit = cache.get(uuid);
if (hit && Date.now() - hit.at < TTL_MS) return reply.send(hit.png);
try {
const res = await fetch(source.replace('{uuid}', uuid), { signal: AbortSignal.timeout(TIMEOUT_MS) });
const res = await httpFetch(source.replace('{uuid}', uuid), { signal: AbortSignal.timeout(TIMEOUT_MS) });
const type = res.headers.get('content-type') ?? '';
if (!res.ok || !type.startsWith('image/png')) throw new Error(`avatar ${res.status}`);
const png = Buffer.from(await res.arrayBuffer());
+2 -1
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import type { Config } from '../../config.ts';
import { createHttpFetch } from '../../net/http.ts';
import { avatarRoutes } from './avatar.ts';
import { leaderboardRoutes } from './leaderboards.ts';
import { overviewRoutes } from './overview.ts';
@@ -16,5 +17,5 @@ export async function publicRoutes(app: FastifyInstance, { pool, config }: { poo
await leaderboardRoutes(app, { pool });
await playerRoutes(app, { pool, timeZone: config.TIMEZONE });
await serverRoutes(app, { pool, config });
await avatarRoutes(app, { source: config.AVATAR_SOURCE });
await avatarRoutes(app, { source: config.AVATAR_SOURCE, httpFetch: createHttpFetch(config.proxy) });
}
+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();
});
});