/** * HTTP utilities - Fetch helpers and HTTP operations */ const http = require('http'); const https = require('https'); const { TIMEOUTS } = require('../utilities/constants'); // HTTPS agent that trusts internal CA certs (self-signed .sami TLD etc.) // Lazy-initialized singleton to avoid creating a new agent per request. let _internalHttpsAgent; function getInternalHttpsAgent() { if (!_internalHttpsAgent) { _internalHttpsAgent = new https.Agent({ rejectUnauthorized: false }); } return _internalHttpsAgent; } /** * Fetch with automatic timeout * Drop-in replacement for fetch() with AbortSignal timeout * * Handles two cases where native fetch() doesn't work: * 1. Caddy admin API (:2019) - rejects undici fetch * 2. HTTPS with self-signed certs (.sami TLD) - undici can't use Node's tls agent */ function fetchT(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) { // Caddy admin API rejects Node.js undici fetch - use raw http.request if (url.includes(':2019')) { return _httpFetch(url, opts, timeoutMs); } // HTTPS with self-signed certs: use raw https.request with rejectUnauthorized:false // Node.js native fetch() (undici) ignores the `agent` option and always validates certs. if (url.startsWith('https://')) { return _httpsFetch(url, opts, timeoutMs); } if (!opts.signal) { opts = { ...opts, signal: AbortSignal.timeout(timeoutMs) }; } // The `timeout` key in fetch() opts is silently ignored by undici. Callers // should use the third arg of fetchT() (timeoutMs) instead. If a caller // passes `timeout: N` here, it's almost certainly a bug — we used to silently // strip it, which masked the issue. Now we surface it in logs and strip it. if ('timeout' in opts) { process.stderr.write(`[fetchT] opts.timeout=${opts.timeout} is ignored — pass timeoutMs as the 3rd arg of fetchT() instead. Called from: ${new Error().stack.split('\n').slice(2, 4).join(' <- ')}\n`); const { timeout: _timeout, ...rest } = opts; opts = rest; } return fetch(url, opts); } /** * Raw https.request wrapper for self-signed cert support * Node.js native fetch() (undici) cannot be configured with rejectUnauthorized:false, * so we use the standard https module for internal HTTPS endpoints. */ function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) { return new Promise((resolve, reject) => { const parsed = new URL(url); const options = { hostname: parsed.hostname, port: parsed.port || 443, path: parsed.pathname + parsed.search, method: (opts.method || 'GET').toUpperCase(), headers: { ...opts.headers }, timeout: timeoutMs, agent: getInternalHttpsAgent(), }; if (opts.body && !options.headers['Content-Length']) { options.headers['Content-Length'] = Buffer.byteLength(opts.body); } const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB const req = https.request(options, (res) => { let data = ''; let size = 0; res.on('data', chunk => { size += chunk.length; if (size > MAX_RESPONSE_SIZE) { res.destroy(); reject(new Error(`Response from ${url} exceeded ${MAX_RESPONSE_SIZE} bytes`)); return; } data += chunk; }); res.on('end', () => { resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, statusText: res.statusMessage, json: () => Promise.resolve(JSON.parse(data)), text: () => Promise.resolve(data), headers: { get: (k) => res.headers[k.toLowerCase()], getSetCookie: () => { const sc = res.headers['set-cookie']; if (!sc) return []; return Array.isArray(sc) ? sc : [sc]; } }, }); }); }); req.on('timeout', () => { req.destroy(); reject(new Error(`Request to ${url} timed out after ${timeoutMs}ms`)); }); req.on('error', reject); if (opts.body) req.write(opts.body); req.end(); }); } /** * Raw http.request wrapper for Caddy admin API * * Auto-injects `Origin: http://:` because Caddy's admin API on a * non-loopback bind (e.g. `admin 0.0.0.0:2019` so the DashCaddy docker * container can probe it from 172.17.0.1) enables `enforce_origin` and * rejects every request whose Origin isn't in the admin's `origins` allowlist * OR is empty. Node's undici fetch sets `Sec-Fetch-Mode: cors` which triggers * the check; raw http.request sets no Origin at all, which fails the empty * check. Setting Origin to the admin endpoint's own origin satisfies * gorilla/csrf same-origin and is the documented override. * (See: https://caddyserver.com/docs/caddyfile/options — `origins` directive.) * * Caller-provided `Origin` header (via opts.headers) wins so tests / future * proxies can override; default matches the parsed admin URL. * * IMPORTANT — IPv6 path (DC-069): on Linux, `dns.lookup('localhost')` returns * `::1` FIRST (per RFC 3484, because /etc/hosts has `::1 localhost`). When the * caller passes `http://localhost:2019/...`, `parsed.hostname` is `::1` AND * the auto-injected Origin is `http://[::1]:2019` — which means the Caddy * `origins` allowlist MUST contain `http://[::1]:2019` (and ideally * `http://ip6-localhost:2019` for the glibc alias), otherwise every on-host * Node probe via `localhost` gets a 403 with empty-Origin-looking error. * The corresponding `origins` entries live in `/etc/caddy/Caddyfile` on DNS2 * (committed via `caddy-apply`) and are documented in * `dashcaddy-installer/templates/Caddyfile.template`. */ function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) { return new Promise((resolve, reject) => { const parsed = new URL(url); const defaultOrigin = `${parsed.protocol}//${parsed.hostname}:${parsed.port || 2019}`; // Node 22's WHATWG URL parser preserves the brackets around IPv6 // literals in `parsed.hostname` (e.g. '[::1]'), but `http.request({hostname})` // expects the BRACKETLESS form for actual connection — passing '[::1]' // triggers `getaddrinfo ENOTFOUND [::1]` and the request fails before // any Origin matching happens. Caddy's `enforce_origin` allowlist // matches by exact Origin string (which DOES include the brackets), // so we keep `defaultOrigin` bracket-form for the header but strip them // for the transport-layer hostname. (DC-069 — IPv6 admin probe path.) const transportHostname = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']') ? parsed.hostname.slice(1, -1) : parsed.hostname; const options = { hostname: transportHostname, port: parsed.port || 2019, path: parsed.pathname + parsed.search, method: (opts.method || 'GET').toUpperCase(), headers: { Origin: defaultOrigin, ...opts.headers, }, timeout: timeoutMs, }; if (opts.body) { options.headers['Content-Length'] = Buffer.byteLength(opts.body); } const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB const req = http.request(options, (res) => { let data = ''; let size = 0; res.on('data', chunk => { size += chunk.length; if (size > MAX_RESPONSE_SIZE) { res.destroy(); reject(new Error(`Response from ${url} exceeded ${MAX_RESPONSE_SIZE} bytes`)); return; } data += chunk; }); res.on('end', () => { resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, statusText: res.statusMessage, json: () => Promise.resolve(JSON.parse(data)), text: () => Promise.resolve(data), headers: { get: (k) => res.headers[k.toLowerCase()], getSetCookie: () => { const sc = res.headers['set-cookie']; if (!sc) return []; return Array.isArray(sc) ? sc : [sc]; } }, }); }); }); req.on('timeout', () => { req.destroy(); reject(new Error(`Request to ${url} timed out after ${timeoutMs}ms`)); }); req.on('error', reject); if (opts.body) req.write(opts.body); req.end(); }); } module.exports = { fetchT };