/** * Pure utility functions extracted from caddy.ts. * No DB, network, or filesystem dependencies — safe to unit-test directly. */ import { isIP } from "node:net"; // --------------------------------------------------------------------------- // Private range expansion // --------------------------------------------------------------------------- export const PRIVATE_RANGES_CIDRS = [ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "fd00::/8", "::1/128", ]; export function expandPrivateRanges(proxies: string[]): string[] { if (!proxies.includes("private_ranges")) return proxies; return proxies.flatMap((p) => (p === "private_ranges" ? PRIVATE_RANGES_CIDRS : [p])); } // --------------------------------------------------------------------------- // Type helpers // --------------------------------------------------------------------------- export function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } // --------------------------------------------------------------------------- // Deep merge (prototype-pollution safe) // --------------------------------------------------------------------------- export function mergeDeep( target: Record, source: Record ) { for (const [key, value] of Object.entries(source)) { if (key === "__proto__" || key === "constructor" || key === "prototype") { continue; } const existing = target[key]; if (isPlainObject(existing) && isPlainObject(value)) { mergeDeep(existing, value); } else { target[key] = value; } } } // --------------------------------------------------------------------------- // JSON helpers // --------------------------------------------------------------------------- export function parseJson(value: string | null, fallback: T): T { if (!value) return fallback; try { return JSON.parse(value) as T; } catch (error) { console.warn("Failed to parse JSON value", value, error); return fallback; } } export function parseOptionalJson(value: string | null | undefined) { if (!value) return null; try { return JSON.parse(value); } catch (error) { console.warn("Failed to parse custom JSON", error); return null; } } export function parseCustomHandlers( value: string | null | undefined ): Record[] { const parsed = parseOptionalJson(value); if (!parsed) return []; const list = Array.isArray(parsed) ? parsed : [parsed]; const handlers: Record[] = []; for (const item of list) { if (isPlainObject(item)) { handlers.push(item); } else { console.warn("Ignoring custom handler entry that is not an object", item); } } return handlers; } // --------------------------------------------------------------------------- // Address / upstream parsing // --------------------------------------------------------------------------- export function formatDialAddress(host: string, port: string) { return isIP(host) === 6 ? `[${host}]:${port}` : `${host}:${port}`; } export function parseHostPort( value: string ): { host: string; port: string } | null { const trimmed = value.trim(); if (!trimmed) return null; if (trimmed.startsWith("[")) { const closeIndex = trimmed.indexOf("]"); if (closeIndex <= 1) return null; const host = trimmed.slice(1, closeIndex); const remainder = trimmed.slice(closeIndex + 1); if (!remainder.startsWith(":")) return null; const port = remainder.slice(1).trim(); if (!port) return null; return { host, port }; } const firstColon = trimmed.indexOf(":"); const lastColon = trimmed.lastIndexOf(":"); if (firstColon === -1 || firstColon !== lastColon) return null; const host = trimmed.slice(0, lastColon).trim(); const port = trimmed.slice(lastColon + 1).trim(); if (!host || !port) return null; return { host, port }; } export type ParsedUpstreamTarget = { original: string; dial: string; scheme: "http" | "https" | null; host: string | null; port: string | null; }; export function parseUpstreamTarget(upstream: string): ParsedUpstreamTarget { const trimmed = upstream.trim(); if (!trimmed) { return { original: upstream, dial: upstream, scheme: null, host: null, port: null }; } try { const url = new URL(trimmed); if (url.protocol === "http:" || url.protocol === "https:") { const scheme = url.protocol === "https:" ? "https" : "http"; const port = url.port || (scheme === "https" ? "443" : "80"); const host = url.hostname; return { original: trimmed, dial: formatDialAddress(host, port), scheme, host, port }; } } catch { // fall through } const parsed = parseHostPort(trimmed); if (!parsed) { return { original: trimmed, dial: trimmed, scheme: null, host: null, port: null }; } return { original: trimmed, dial: formatDialAddress(parsed.host, parsed.port), scheme: null, host: parsed.host, port: parsed.port, }; } // --------------------------------------------------------------------------- // Duration parsing // --------------------------------------------------------------------------- export function toDurationMs(value: string | null | undefined): number | null { if (!value) return null; const trimmed = value.trim(); if (!trimmed) return null; const regex = /(\d+(?:\.\d+)?)(ms|s|m|h)/g; let total = 0; let matched = false; let consumed = 0; while (true) { const match = regex.exec(trimmed); if (!match) break; matched = true; consumed += match[0].length; const valueNum = Number.parseFloat(match[1]); if (!Number.isFinite(valueNum)) return null; const unit = match[2]; if (unit === "ms") total += valueNum; else if (unit === "s") total += valueNum * 1000; else if (unit === "m") total += valueNum * 60_000; else if (unit === "h") total += valueNum * 3_600_000; } if (!matched || consumed !== trimmed.length) return null; const rounded = Math.round(total); return rounded > 0 ? rounded : null; }