/** * 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. */ 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}`; const options = { hostname: parsed.hostname, 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 };