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:
1 parent
e66f89f49e
commit
d45d1821df
10 files changed
+179
-4
No files matched your search
+15
-1
@@ -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) };
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
Reference in new issue
Block a user