diff --git a/docker/caddy/Dockerfile b/docker/caddy/Dockerfile index 2a03ff93..bc9949fb 100644 --- a/docker/caddy/Dockerfile +++ b/docker/caddy/Dockerfile @@ -49,4 +49,4 @@ ENV XDG_DATA_HOME=/data # Run as non-root user (fully rootless) USER caddy -CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] +CMD ["caddy", "run", "--resume", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/src/lib/caddy-monitor.ts b/src/lib/caddy-monitor.ts index 7ed60c07..ddcafa32 100644 --- a/src/lib/caddy-monitor.ts +++ b/src/lib/caddy-monitor.ts @@ -3,6 +3,8 @@ * Monitors Caddy for restarts/crashes and automatically reapplies configuration */ +import http from "node:http"; +import https from "node:https"; import { config } from "./config"; import { applyCaddyConfig } from "./caddy"; import { getSetting, setSetting } from "./settings"; @@ -34,24 +36,34 @@ let isMonitoring = false; */ async function getCaddyConfigId(): Promise { try { - const response = await fetch(`${config.caddyApiUrl}/config/`, { - method: "GET", - headers: { "Origin": config.caddyApiUrl }, - signal: AbortSignal.timeout(5000) + const response = await new Promise<{ status: number; text: string; etag: string | null }>((resolve, reject) => { + const parsed = new URL(`${config.caddyApiUrl}/config/`); + const lib = parsed.protocol === "https:" ? https : http; + const req = lib.request( + { hostname: parsed.hostname, port: parsed.port, path: parsed.pathname, method: "GET" }, + (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => resolve({ status: res.statusCode ?? 0, text: data, etag: res.headers.etag ?? null })); + } + ); + req.setTimeout(5000, () => { req.destroy(); reject(new Error("timeout")); }); + req.on("error", reject); + req.end(); }); - if (!response.ok) { + if (response.status < 200 || response.status >= 300) { return null; } // Use ETag or compute a simple hash from the response - const etag = response.headers.get("etag"); + const etag = response.etag; if (etag) { return etag; } // Fallback: use the config object's structure - const configData = await response.json(); + const configData = JSON.parse(response.text); // Check if config is essentially empty (default state after restart) const isEmpty = !configData.apps || Object.keys(configData.apps).length === 0; return isEmpty ? "empty" : "configured"; diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index 88d3ce8b..494b4f58 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -3,6 +3,8 @@ import { Resolver } from "node:dns/promises"; import { join } from "node:path"; import { isIP } from "node:net"; import crypto from "node:crypto"; +import http from "node:http"; +import https from "node:https"; import db, { nowIso } from "./db"; import { config } from "./config"; import { @@ -1591,6 +1593,37 @@ async function buildCaddyDocument() { }; } +/** + * Plain HTTP/HTTPS request to the Caddy admin API using node:http. + * Avoids browser-security headers (Sec-Fetch-*) that native fetch sends, + * which would trigger Caddy's CORS origin enforcement. + */ +function caddyRequest(url: string, method: string, body?: string): Promise<{ status: number; text: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const lib = parsed.protocol === "https:" ? https : http; + const req = lib.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname + parsed.search, + method, + headers: { + ...(body ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } : {}) + } + }, + (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => resolve({ status: res.statusCode ?? 0, text: data })); + } + ); + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); +} + export async function applyCaddyConfig() { const document = await buildCaddyDocument(); const payload = JSON.stringify(document); @@ -1598,18 +1631,10 @@ export async function applyCaddyConfig() { setSetting("caddy_config_hash", { hash, updated_at: nowIso() }); try { - const response = await fetch(`${config.caddyApiUrl}/load`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Origin": config.caddyApiUrl - }, - body: payload - }); + const response = await caddyRequest(`${config.caddyApiUrl}/load`, "POST", payload); - if (!response.ok) { - const text = await response.text(); - throw new Error(`Caddy config load failed: ${response.status} ${text}`); + if (response.status < 200 || response.status >= 300) { + throw new Error(`Caddy config load failed: ${response.status} ${response.text}`); } await syncInstances();