/** * Namecheap Dynamic DNS Simulator * * Mimics the real Namecheap DDNS endpoint: * GET /update?host=&domain=&password=&ip= * * On success, updates the Knot zone file and reloads the zone. * Returns XML matching the real Namecheap response format. */ import type { DdnsRecord } from './types'; import { updateZone } from './zone-updater'; /** * Minimal structural type for the bits of Bun's server we use — avoids * depending on the generic `Server` shape (which requires a * type argument) just to read the remote address. */ interface RequestIpProvider { requestIP(req: Request): { address: string } | null; } // Real Namecheap DDNS uses a separate password per domain. The simulator // supports two modes: // 1. DDNS_PASSWORDS (JSON object mapping domain→password). Per-domain // lookup; password must match the entry for the requested domain. // 2. DDNS_PASSWORD (single string fallback). Accepted for any domain. // Default `test123` keeps single-domain tests working unchanged. // Mode 1 wins when set; the cross-domain e2e test exercises it via // docker-compose env to verify per-domain password routing. const PASSWORD_MAP: Record = (() => { const raw = process.env.DDNS_PASSWORDS; if (!raw) return {}; try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { const out: Record = {}; for (const [k, v] of Object.entries(parsed)) { if (typeof v === 'string') out[k] = v; } return out; } } catch { console.warn(`[ddns] Failed to parse DDNS_PASSWORDS env var: ${raw}`); } return {}; })(); const FALLBACK_PASSWORD = process.env.DDNS_PASSWORD || 'test123'; /** * The real Namecheap DDNS endpoint is `https://dynamicdns.park-your-domain.com`, * so that is what this serves: port 443, with a certificate for that name. * * It used to listen on plain HTTP at 8080 and the rig pointed the module at * `http://100.64.0.55:8080/update` through a `DDNS_ENDPOINT` environment * variable. That bypassed both the DNS chain the rig had already built (the * `park-your-domain.com` zone has always been there) and TLS, and it stopped * working when hooks became subprocesses with an allow-listed environment * (openspec/changes/hook-process-boundary, design D5). * * The certificate is PRE-INSTALLED rather than issued by Pebble, the same way * the cPanel host simulator's is, and for the same reason: celilo does not own * this service's TLS. A real third-party API has a certificate from a real CA * long before celilo talks to it, and there is no ACME exchange to model. */ const PORT = Number(process.env.DDNS_PORT) || 443; const TLS_DIR = process.env.DDNS_TLS_DIR || '/etc/ssl/namecheap-sim'; function expectedPasswordFor(domain: string): string { return PASSWORD_MAP[domain] ?? FALLBACK_PASSWORD; } // Track registered records for debugging const records: Map = new Map(); function xmlResponse(errCount: number, ip: string, errors: string[]): Response { const errorElements = errors.map((e, i) => `${e}`).join('\n '); const xml = ` SETDNSHOST eng ${ip} ${errCount} ${errorElements} 0 true `; return new Response(xml, { status: 200, headers: { 'Content-Type': 'application/xml' }, }); } /** * Resolve the request's source IP, mimicking real Namecheap DDNS which * auto-detects it when the caller omits `ip=`. Prefers an explicit * X-Forwarded-For, then Bun's `server.requestIP(req)` (the actual remote * address). Returns null when neither is available — callers MUST treat that * as an error and never substitute a sentinel: a prior version returned * `0.0.0.0` here, which got written straight into the public zone file (a * prohibited cert-skip sentinel masking real failures). */ function getSourceIp(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; } /** * Strip the IPv6-mapped-IPv4 prefix Bun's `requestIP()` returns for IPv4 * connections over a dual-stack socket (`::ffff:100.64.0.1` → `100.64.0.1`). * Real Namecheap stores a bare IPv4 in the A record; the mapped form is not a * valid A-record value, so leaving it in poisons the zone (knot rejects it and * the apex falls back to a bogus answer). Pass non-mapped addresses through. */ function normalizeIp(address: string): string { const mapped = address.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); return mapped ? mapped[1] : address; } const server = Bun.serve({ port: PORT, tls: { cert: Bun.file(`${TLS_DIR}/ddns-tls.crt`), key: Bun.file(`${TLS_DIR}/ddns-tls.key`), }, fetch(req, server) { const url = new URL(req.url); if (url.pathname === '/update') { const host = url.searchParams.get('host'); const domain = url.searchParams.get('domain'); const password = url.searchParams.get('password'); const ip = url.searchParams.get('ip') || getSourceIp(req, server); if (!host || !domain) { return xmlResponse(1, '', ['Missing required parameters: host, domain']); } // No explicit ip= and no detectable source IP: error, do NOT write a // sentinel into the zone (real Namecheap would have a source IP here). if (!ip) { return xmlResponse(1, '', [ 'No ip parameter supplied and source IP could not be determined', ]); } if (password !== expectedPasswordFor(domain)) { return xmlResponse(1, ip, ['Passwords do not match']); } try { updateZone(domain, host, ip); records.set(`${host}.${domain}`, { host, ip, updatedAt: new Date() }); console.log(`[ddns] Updated ${host}.${domain} -> ${ip}`); return xmlResponse(0, ip, []); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[ddns] Failed to update zone: ${msg}`); return xmlResponse(1, ip, [`Zone update failed: ${msg}`]); } } // Health check / status endpoint if (url.pathname === '/status') { const status = Object.fromEntries( [...records.entries()].map(([k, v]) => [k, { ip: v.ip, updatedAt: v.updatedAt }]), ); return Response.json({ ok: true, records: status }); } return new Response('Not Found', { status: 404 }); }, }); console.log(`[ddns] Namecheap DDNS simulator listening on port ${server.port}`);