/** * IP echo simulator — the rig's stand-in for api.ipify.org. * * Reports the source address a request appears to come from, and nothing else. * celilo's `public_dns` check uses it for the ONE thing it cannot ask itself: * what address the fleet is currently reachable at. * * Why an independent service rather than the registrar's own answer: the DDNS * response carries the address the registrar says it published, so comparing * public DNS against it is self-agreement. Worse than useless, in fact — * Namecheap returns `ErrCount 0` with the requested address echoed back for * `www` updates it does not apply, so the response can be actively false. The * expectation has to come from somewhere that has no stake in the answer. * * This sits on `internet-external`, so a request from the customer fleet * arrives having been SNAT'd by the customer firewall — the source address it * sees IS the firewall's external address, which is the address the internet * must dial. That is the same mechanism DDNS source-IP detection relies on, * observed independently. * * Plain HTTP on purpose. Real ipify is HTTPS, but TLS here would mean an ACME * dance (Caddy + Pebble, as the isitup sim does) to test nothing this check * cares about: the property under test is "an off-fleet observer reports our * egress", not certificate validation. The path from the fleet to here still * crosses both firewalls exactly as a real request would. */ // Marks this file a module rather than a script. Without it the simulators // share one global scope at typecheck time, and `PORT`/`server` collide with // the identically-named top-level bindings in simulators/isitup/server.ts. export {}; interface RequestIpProvider { requestIP(req: Request): { address: string } | null; } const PORT = Number(process.env.ECHO_PORT) || 80; /** * Strip the IPv6-mapped-IPv4 prefix Bun returns for IPv4 connections over a * dual-stack socket (`::ffff:203.0.113.100` → `203.0.113.100`). Same fix as * the namecheap-ddns simulator: the mapped form is not a valid A-record value, * and here it would simply never compare equal to what DNS serves. */ function normalizeIp(address: string): string { const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); return mapped ? mapped[1] : address; } function sourceIp(req: Request, server: RequestIpProvider): string | null { const forwarded = req.headers.get('x-forwarded-for'); if (forwarded) return normalizeIp(forwarded.split(',')[0].trim()); const remote = server.requestIP(req); return remote?.address ? normalizeIp(remote.address) : null; } const server = Bun.serve({ port: PORT, fetch(req, server) { const url = new URL(req.url); if (url.pathname === '/health') return Response.json({ ok: true }); const ip = sourceIp(req, server); if (!ip) { // NEVER a sentinel. A prior version of the DDNS simulator returned // `0.0.0.0` when it could not determine the source, and that value went // straight into a public zone file. An unanswerable probe must read as // an error, which celilo counts as *undetermined* — never as a pass. return new Response('source address could not be determined', { status: 500 }); } return new Response(ip, { headers: { 'Content-Type': 'text/plain' } }); }, }); console.log(`[ip-echo] listening on port ${server.port}`);