diff --git a/server/src/app.ts b/server/src/app.ts index 74325ca..73cf701 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -16,6 +16,7 @@ import { adminRouter } from './routes/admin.js' import { statsRouter } from './routes/stats.js' import { arcadeRouter } from './routes/arcade.js' import { rateLimit } from './middleware/rate-limit.js' +import { arenaProxy } from './middleware/arena-proxy.js' import { existsSync, readFileSync } from 'fs' import { join, dirname } from 'path' @@ -92,6 +93,10 @@ app.use('/api/docs/*', async (c, next) => { if (c.req.method === 'GET') c.header('Cache-Control', 'public, max-age=3600') }) +// When ARENA_UPSTREAM_URL is set, forward every /api/* request to the +// canonical arena instead of the local routers (BOT-03). No-ops otherwise. +app.use('/api/*', arenaProxy) + app.get('/api/health', (c) => c.json({ status: 'ok', name: 'botfights' })) app.route('/api/auth', authRouter) diff --git a/server/src/middleware/arena-proxy.test.ts b/server/src/middleware/arena-proxy.test.ts new file mode 100644 index 0000000..1c880b3 --- /dev/null +++ b/server/src/middleware/arena-proxy.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { Hono } from 'hono' +import { serve, type ServerType } from '@hono/node-server' +import type { AddressInfo } from 'node:net' +import { gzipSync } from 'node:zlib' +import { arenaProxy } from './arena-proxy.js' + +// --- Real upstream "arena" server: a second, independent Hono app -------- +const registeredBots: { id: string; secret: string; name: string }[] = [] + +const upstream = new Hono() + +upstream.post('/api/bots', async (c) => { + const body = await c.req.json().catch(() => ({})) + const bot = { id: `bot-${registeredBots.length + 1}`, secret: 'shh', name: body.name ?? 'unnamed' } + registeredBots.push(bot) + return c.json(bot) +}) + +upstream.get('/api/bots', (c) => c.json(registeredBots)) + +upstream.get('/api/echo', (c) => { + return c.json({ + method: c.req.method, + path: new URL(c.req.url).pathname, + query: new URL(c.req.url).search, + host: c.req.header('host') ?? null, + }) +}) + +upstream.post('/api/echo', async (c) => { + const body = await c.req.json().catch(() => null) + return c.json({ + method: c.req.method, + path: new URL(c.req.url).pathname, + query: new URL(c.req.url).search, + body, + host: c.req.header('host') ?? null, + xff: c.req.header('x-forwarded-for') ?? null, + }) +}) + +upstream.get('/api/gzip-lie', (c) => { + // Upstream actually gzip-compresses the body and declares content-encoding + // for the COMPRESSED bytes. undici transparently decompresses on the + // proxy's fetch() before this code ever sees the response, so by the time + // the proxy builds its own Response, the stale content-encoding/ + // content-length (describing the compressed representation) would corrupt + // what the caller receives if copied through verbatim — the proxy must + // strip them, not forward them. + const compressed = gzipSync(Buffer.from(JSON.stringify({ ok: true }))) + return new Response(compressed, { + status: 200, + // content-length deliberately omitted — the underlying Node HTTP server + // computes and sends the real one automatically. + headers: { + 'content-type': 'application/json', + 'content-encoding': 'gzip', + }, + }) +}) + +let upstreamServer: ServerType +let upstreamUrl: string + +beforeAll(async () => { + await new Promise((resolve) => { + upstreamServer = serve({ fetch: upstream.fetch, port: 0 }, (info) => { + upstreamUrl = `http://127.0.0.1:${(info as AddressInfo).port}` + resolve() + }) + }) +}) + +afterAll(async () => { + await new Promise((resolve) => upstreamServer.close(() => resolve())) +}) + +// --- Proxying app under test ---------------------------------------------- +function buildProxyingApp() { + const app = new Hono() + app.use('/api/*', arenaProxy) + app.get('/api/health', (c) => c.json({ status: 'ok', name: 'botfights' })) + app.get('/api/sentinel', (c) => c.json({ sentinel: true })) + return app +} + +describe('arenaProxy', () => { + const originalEnv = process.env.ARENA_UPSTREAM_URL + + afterEach(() => { + if (originalEnv === undefined) delete process.env.ARENA_UPSTREAM_URL + else process.env.ARENA_UPSTREAM_URL = originalEnv + }) + + it('registers a bot upstream and reads it back through the proxy', async () => { + process.env.ARENA_UPSTREAM_URL = upstreamUrl + const app = buildProxyingApp() + + const postRes = await app.request('/api/bots', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'cross-node-bot' }), + }) + expect(postRes.status).toBe(200) + const posted = await postRes.json() as { name: string } + expect(posted.name).toBe('cross-node-bot') + + const getRes = await app.request('/api/bots') + expect(getRes.status).toBe(200) + const list = await getRes.json() as { name: string }[] + expect(list.some((b) => b.name === 'cross-node-bot')).toBe(true) + }) + + it('falls through to local routers when ARENA_UPSTREAM_URL is unset', async () => { + delete process.env.ARENA_UPSTREAM_URL + const app = buildProxyingApp() + + const res = await app.request('/api/sentinel') + expect(res.status).toBe(200) + const body = await res.json() as { sentinel: boolean } + expect(body.sentinel).toBe(true) + }) + + it('answers /api/health locally even in proxy mode', async () => { + // Point at a port with nothing listening. + process.env.ARENA_UPSTREAM_URL = 'http://127.0.0.1:1' + const app = buildProxyingApp() + + const res = await app.request('/api/health') + expect(res.status).toBe(200) + const body = await res.json() as { status: string } + expect(body.status).toBe('ok') + }) + + it('forwards method, query string and JSON body unchanged', async () => { + process.env.ARENA_UPSTREAM_URL = upstreamUrl + const app = buildProxyingApp() + + const res = await app.request('/api/echo?a=1&b=2', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hello: 'world' }), + }) + expect(res.status).toBe(200) + const body = await res.json() as { method: string; path: string; query: string; body: unknown } + expect(body.method).toBe('POST') + expect(body.path).toBe('/api/echo') + expect(body.query).toBe('?a=1&b=2') + expect(body.body).toEqual({ hello: 'world' }) + }) + + it('does not forward the inbound Host header', async () => { + process.env.ARENA_UPSTREAM_URL = upstreamUrl + const app = buildProxyingApp() + + const res = await app.request('/api/echo', { + headers: { Host: 'caller-node.example.com' }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { host: string | null } + expect(body.host).not.toBe('caller-node.example.com') + expect(body.host).toBe(new URL(upstreamUrl).host) + }) + + it('strips response content-encoding and content-length', async () => { + process.env.ARENA_UPSTREAM_URL = upstreamUrl + const app = buildProxyingApp() + + const res = await app.request('/api/gzip-lie') + expect(res.status).toBe(200) + expect(res.headers.get('content-encoding')).toBeNull() + expect(res.headers.get('content-length')).toBeNull() + const body = await res.json() as { ok: boolean } + expect(body.ok).toBe(true) + }) +}) diff --git a/server/src/middleware/arena-proxy.ts b/server/src/middleware/arena-proxy.ts new file mode 100644 index 0000000..a7492a9 --- /dev/null +++ b/server/src/middleware/arena-proxy.ts @@ -0,0 +1,82 @@ +import type { Context, Next } from 'hono' + +// Requests answered locally even when ARENA_UPSTREAM_URL is set — the manifest +// health check must never depend on the canonical arena being reachable, or a +// perfectly healthy node container gets marked unhealthy and restart-looped. +const LOCAL_BYPASS_PATHS = new Set(['/api/health']) + +// Headers that must never be copied verbatim between hops (either because +// they're connection-scoped, or because copying a stale value corrupts the +// forwarded/returned message — e.g. content-length after undici recomputes +// the body, or content-encoding after undici already decoded it). +const HOP_BY_HOP = new Set([ + 'host', + 'connection', + 'keep-alive', + 'transfer-encoding', + 'upgrade', + 'proxy-authorization', + 'proxy-connection', + 'te', + 'trailer', + 'content-length', +]) + +function buildTargetUrl(upstream: string, path: string, search: string): URL { + // Build from the base + path + the ORIGINAL query string. Do not round-trip + // through URLSearchParams — that reorders and re-escapes repeated keys. + return new URL(path + search, upstream) +} + +function copyForwardHeaders(c: Context): Headers { + const headers = new Headers() + for (const [key, value] of c.req.raw.headers) { + if (HOP_BY_HOP.has(key.toLowerCase())) continue + headers.append(key, value) + } + // Ask the upstream for an uncompressed body — Node's fetch already handles + // decoding for us, and forwarding compression bookkeeping is unnecessary. + headers.set('accept-encoding', 'identity') + return headers +} + +function copyResponseHeaders(upstreamHeaders: Headers): Headers { + const headers = new Headers() + for (const [key, value] of upstreamHeaders) { + if (HOP_BY_HOP.has(key.toLowerCase())) continue + if (key.toLowerCase() === 'content-encoding') continue + headers.append(key, value) + } + return headers +} + +export async function arenaProxy(c: Context, next: Next) { + // Read the env var on every call — a module-level constant would be + // captured at import time and could never be toggled by tests or by a + // container restart-free config change. + const upstream = process.env.ARENA_UPSTREAM_URL + if (!upstream) return next() // standalone mode — today's code path, untouched + + const path = c.req.path + if (LOCAL_BYPASS_PATHS.has(path)) return next() + + const search = new URL(c.req.url).search + const target = buildTargetUrl(upstream, path, search) + const headers = copyForwardHeaders(c) + const method = c.req.method + + const upstreamRes = await fetch(target, { + method, + headers, + body: method === 'GET' || method === 'HEAD' ? undefined : c.req.raw.body, + redirect: 'manual', + // Node's undici fetch requires `duplex` whenever a streamed body is sent. + // `@types/node` 22.13.14 already includes `duplex` on RequestInit. + duplex: 'half', + }) + + return new Response(upstreamRes.body, { + status: upstreamRes.status, + headers: copyResponseHeaders(upstreamRes.headers), + }) +}