import crypto from "node:crypto";
import dns from "node:dns/promises";
import { execFile } from "node:child_process";
import fs from "node:fs";
import fsp from "node:fs/promises";
import http from "node:http";
import net from "node:net";
import dgram from "node:dgram";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import AdmZip from "adm-zip";
import express from "express";
import multer from "multer";
import { LOCAL_INSTANCE_ID, openStorage } from "./storage.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageMetadata = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
const appVersion = process.env.APP_VERSION || packageMetadata.version;
const publicDir = path.join(__dirname, "public");
const dataDir = path.resolve(process.env.DATA_DIR || "/data");
const sitesDir = path.join(dataDir, "sites");
const uploadDir = path.join(dataDir, ".uploads");
const caddyDir = path.join(dataDir, "caddy");
const iconsDir = path.join(dataDir, "icons");
const logsDir = path.join(dataDir, "logs");
const backupsDir = path.join(dataDir, "backups");
const defaultSiteDir = path.join(dataDir, "default-site");
const certificatesRoot = path.join(dataDir, "certificates");
const customCertificatesDir = path.join(certificatesRoot, "custom");
const managedCertificatesDir = path.join(certificatesRoot, "managed");
const certificateExportsDir = path.join(certificatesRoot, "exports");
const accessLogPath = path.join(logsDir, "access.json");
const activityLogPath = path.join(logsDir, "activity.jsonl");
const certificateDir = path.join(managedCertificatesDir, "certificates");
const iconCatalogPath = path.join(iconsDir, "catalog.json");
const caddyfilePath = path.join(caddyDir, "Caddyfile");
const execFileAsync = promisify(execFile);
const scryptAsync = promisify(crypto.scrypt);
const adminPort = numberEnv("ADMIN_PORT", 8080);
const minPort = numberEnv("SITE_PORT_MIN", 9000);
const maxPort = numberEnv("SITE_PORT_MAX", 9099);
const adminUser = process.env.ADMIN_USERNAME || "admin";
const adminPassword = process.env.ADMIN_PASSWORD || "change-this-password";
const sessionSecret = process.env.SESSION_SECRET || crypto.createHash("sha256").update(`${adminUser}:${adminPassword}`).digest("hex");
const scheduledBackupPassword = process.env.BACKUP_PASSWORD || "";
const activeServers = new Map();
const activeStreams = new Map();
let sites = [];
let proxies = [];
let users = [];
let redirects = [];
let streams = [];
let accessLists = [];
let groups = [];
let settings = {};
let gatewayError = null;
let lastGatewayReload = null;
let caddyVersion = "Unknown";
const recentActivity = [];
const upstreamHealth = new Map();
const certificateStatusCache = new Map();
const loginAttempts = new Map();
let currentAuditActor = null;
const probeFailures = { gateway: 0, http: 0, https: 0 };
let iconCatalog = null;
let storage;
function recordActivity(message, status = "ok") {
const entry = { message, status, at: new Date().toISOString() };
recentActivity.unshift(entry);
recentActivity.splice(20);
try { storage?.recordActivity(message, status); } catch (error) { console.warn("Could not record SQLite activity event:", error.message); }
fsp.appendFile(activityLogPath, `${JSON.stringify(entry)}\n`).catch(() => {});
try { storage?.recordAudit(message, status, null, currentAuditActor); } catch (error) { console.warn("Could not record SQLite audit event:", error.message); }
}
async function directorySize(directory) {
let total = 0;
const entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error));
for (const entry of entries) {
const itemPath = path.join(directory, entry.name);
if (entry.isDirectory()) total += await directorySize(itemPath);
else if (entry.isFile()) total += (await fsp.stat(itemPath)).size;
}
return total;
}
function numberEnv(name, fallback) {
const value = Number.parseInt(process.env[name] || "", 10);
return Number.isInteger(value) ? value : fallback;
}
function safeEqual(a, b) {
const left = Buffer.from(String(a));
const right = Buffer.from(String(b));
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
async function passwordRecord(password) {
const salt = crypto.randomBytes(16).toString("hex");
const hash = await scryptAsync(String(password), salt, 64);
return { algorithm: "scrypt", salt, hash: hash.toString("hex") };
}
async function passwordMatches(password, record) {
if (!record?.salt || !record?.hash) return false;
const hash = await scryptAsync(String(password), record.salt, 64);
return safeEqual(hash.toString("hex"), record.hash);
}
function publicUser(user) {
const { password, sessionVersion, ...safe } = user;
return safe;
}
function activeAdministrators() {
return users.filter(user => user.role === "administrator" && user.status === "active");
}
function slugify(value) {
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48);
}
function sign(value) {
return crypto.createHmac("sha256", sessionSecret).update(value).digest("hex");
}
function cookieMap(header = "") {
return Object.fromEntries(header.split(";").map(v => v.trim().split("=").map(decodeURIComponent)).filter(v => v.length === 2));
}
function sessionUser(req) {
const token = cookieMap(req.headers.cookie).webserver_session;
if (!token) return null;
const [userId, expires, sessionVersion, signature] = token.split(".");
const user = users.find(item => item.id === userId && item.status === "active");
if (!user || !expires || !sessionVersion || Number(expires) <= Date.now() || sessionVersion !== user.sessionVersion || !safeEqual(signature || "", sign(`${userId}.${expires}.${sessionVersion}`))) return null;
return user;
}
const saveSites = async () => storage.saveCollection("sites", sites);
const saveProxies = async () => storage.saveCollection("proxies", proxies);
const saveUsers = async () => storage.saveCollection("users", users);
const saveGroups = async () => storage.saveCollection("groups", groups);
const saveRedirects = async () => storage.saveCollection("redirects", redirects);
const saveStreams = async () => storage.saveCollection("streams", streams);
const saveAccessLists = async () => storage.saveCollection("access_lists", accessLists);
const saveSettings = async () => storage.saveSettings(settings);
async function clearDirectoryContents(directory) {
await fsp.mkdir(directory, { recursive: true });
let lastError = null;
for (let attempt = 0; attempt < 4; attempt++) {
lastError = null;
for (const entry of await fsp.readdir(directory, { withFileTypes: true })) {
try { await fsp.rm(path.join(directory, entry.name), { recursive: true, force: true, maxRetries: 2, retryDelay: 100 }); }
catch (error) { lastError = error; }
}
if (!(await fsp.readdir(directory)).length) return;
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
}
if (lastError) throw lastError;
throw new Error(`Could not clear ${directory}: directory is not empty.`);
}
async function loadSites() {
await Promise.all([fsp.mkdir(sitesDir, { recursive: true }), fsp.mkdir(uploadDir, { recursive: true }), fsp.mkdir(caddyDir, { recursive: true }), fsp.mkdir(iconsDir, { recursive: true }), fsp.mkdir(logsDir, { recursive: true }), fsp.mkdir(backupsDir, { recursive: true }), fsp.mkdir(defaultSiteDir, { recursive: true }), fsp.mkdir(customCertificatesDir, { recursive: true }), fsp.mkdir(managedCertificatesDir, { recursive: true }), fsp.mkdir(certificateExportsDir, { recursive: true })]);
if (!storage) storage = await openStorage(dataDir, backupsDir);
storage.humanizeGatewayErrors?.();
if (storage.snapshot) { recordActivity(`Legacy JSON migrated to SQLite. Safety backup: ${storage.snapshot.filename}.`); storage.snapshot = null; }
sites = storage.loadCollection("sites").map(item => ({ ...item, healthEnabled: !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false") }));
proxies = storage.loadCollection("proxies").map(item => ({ ...item, healthEnabled: !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false") }));
try { const legacyAccess = await readAccessLogs(5000); storage.recordAccessEvents(legacyAccess.map((entry, index) => ({ ...entry, source: `legacy-${entry.at || "unknown"}-${index}` }))); } catch (error) { console.warn("Could not import access logs into SQLite:", error.message); }
try {
const storedActivity = storage.listActivity(20);
if (storedActivity.length) recentActivity.push(...storedActivity);
else {
const lines = (await fsp.readFile(activityLogPath, "utf8")).trim().split("\n").slice(-20).reverse();
const legacy = lines.filter(Boolean).map(line => JSON.parse(line));
recentActivity.push(...legacy);
for (const entry of legacy.reverse()) storage.recordActivity(entry.message, entry.status);
}
} catch { /* Activity history starts empty on a new installation. */ }
users = storage.loadCollection("users");
if (!users.length) {
const now = new Date().toISOString();
users = [{ id: crypto.randomUUID(), username: adminUser.toLowerCase(), displayName: "Administrator", role: "administrator", status: "active", password: await passwordRecord(adminPassword), source: "bootstrap", setupRequired: true, sessionVersion: crypto.randomBytes(16).toString("hex"), createdAt: now, updatedAt: now, lastLoginAt: null }];
await saveUsers();
}
let usersChanged = false;
for (const user of users) {
if (user.setupRequired === undefined) { user.setupRequired = false; usersChanged = true; }
if (!user.sessionVersion) { user.sessionVersion = crypto.randomBytes(16).toString("hex"); usersChanged = true; }
}
if (usersChanged) await saveUsers();
redirects = storage.loadCollection("redirects");
streams = storage.loadCollection("streams").map(item => ({ ...item, healthEnabled: !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false") }));
accessLists = storage.loadCollection("access_lists");
groups = storage.loadCollection("groups");
const defaultSettings = {
defaultSite: { mode: "themed404", redirectUrl: "", redirectCode: 302, preservePath: true, title: "Route not found", message: "The gateway is responding, but this address has not been configured.", customHtml: "" },
backups: { enabled: false, frequency: "daily", hour: 2, retention: 7, type: "configuration", includeLogs: false, encrypt: false, lastRunAt: null, lastStatus: null },
certificateHealth: { warningDays: 30, criticalDays: 7, staleMinutes: 10 },
logsRetention: { accessDays: 30, activityDays: 90, auditDays: 365, certificateDays: 365, securityDays: 365, pruningEnabled: false }
};
const storedSettings = storage.loadSettings() || defaultSettings;
settings = { ...defaultSettings, ...storedSettings, defaultSite: { ...defaultSettings.defaultSite, ...(storedSettings.defaultSite || {}) }, backups: { ...defaultSettings.backups, ...(storedSettings.backups || {}) }, certificateHealth: { ...defaultSettings.certificateHealth, ...(storedSettings.certificateHealth || {}) }, logsRetention: { ...defaultSettings.logsRetention, ...(storedSettings.logsRetention || {}) } };
await saveSettings();
}
function normalizeDomain(value) {
return String(value || "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/$/, "");
}
function normalizeDomains(primary, aliases = []) {
return [...new Set([primary, ...(Array.isArray(aliases) ? aliases : String(aliases || "").split(/[\n,]+/))].map(normalizeDomain).filter(Boolean))];
}
function validateDomains(domains, exceptId) {
for (const domain of domains) { const error = validateDomain(domain, exceptId); if (error) return error; }
return null;
}
function validateDomain(domain, exceptId) {
if (!domain) return null;
if (domain.length > 253 || !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain)) return "Enter a valid public domain such as app.example.com.";
if ([...sites, ...proxies, ...redirects].some(item => normalizeDomains(item.domain, item.domains).includes(domain) && item.id !== exceptId)) return "That domain is already assigned.";
return null;
}
function validateTarget(value) {
try {
const target = new URL(String(value || ""));
if (!["http:", "https:"].includes(target.protocol) || !target.hostname || (target.pathname && target.pathname !== "/") || target.search || target.hash) throw new Error();
return target.toString().replace(/\/$/, "");
} catch {
throw Object.assign(new Error("Target must be an HTTP or HTTPS address such as http://192.168.1.20:3000."), { status: 400 });
}
}
function validateStreamPort(value) {
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw Object.assign(new Error("Incoming port must be between 1 and 65535."), { status: 400 });
return port;
}
function validateStreamHostPort(value) {
const raw = String(value || "").trim();
const match = raw.match(/^\[?([^\s\]]+)\]?:(\d{1,5})$/);
if (!match) throw Object.assign(new Error("Forward to must be host:port, such as 192.168.1.20:22."), { status: 400 });
const port = Number(match[2]);
if (!match[1] || port < 1 || port > 65535) throw Object.assign(new Error("Forward to must be host:port, such as 192.168.1.20:22."), { status: 400 });
return `${match[1]}:${port}`;
}
function streamPortConflict(port, exceptId) {
if (port === adminPort || port === 80 || port === 443 || (port >= minPort && port <= maxPort)) return "That port is already reserved by the gateway.";
if (streams.some(item => item.port === port && item.id !== exceptId)) return "That port is already used by another streaming host.";
return null;
}
function cleanHeaders(value) {
if (!Array.isArray(value)) return [];
return value.slice(0, 30).map(item => ({ name: String(item.name || "").trim(), value: String(item.value || "").trim() }))
.filter(item => /^[A-Za-z0-9-]{1,80}$/.test(item.name) && item.value.length <= 500);
}
function cleanLocations(value) {
if (!Array.isArray(value)) return [];
return value.slice(0, 20).map(item => {
const location = { path: String(item.path || "").trim(), target: validateTarget(item.target), stripPrefix: Boolean(item.stripPrefix), requestHeaders: cleanHeaders(item.requestHeaders), upstreamTlsServerName: String(item.upstreamTlsServerName || "").trim().slice(0, 253), upstreamTlsInsecure: Boolean(item.upstreamTlsInsecure) };
if (!/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*\*?$/.test(location.path)) throw Object.assign(new Error("Custom Location paths must start with / and may end with *."), { status: 400 });
return location;
});
}
function cleanCustomConfig(value) {
const config = String(value || "").trim();
if (config.length > 20000) throw Object.assign(new Error("Custom Caddy configuration must be 20 KB or less."), { status: 400 });
if (/(^|\n)\s*(?:\{|admin\b|storage\b|import\b|persist_config\b)/i.test(config)) throw Object.assign(new Error("Global blocks, imports, and Caddy administration settings are not allowed here."), { status: 400 });
return config;
}
function applyAdvancedSettings(item, body) {
if (body.upstreams !== undefined) {
if (!Array.isArray(body.upstreams) || body.upstreams.length > 10) throw Object.assign(new Error("Add up to 10 upstream targets."), { status: 400 });
item.upstreams = body.upstreams.map(validateTarget);
}
if (body.accessListId !== undefined) item.accessListId = String(body.accessListId || "");
if (body.compression !== undefined) item.compression = ["off", "gzip", "automatic"].includes(body.compression) ? body.compression : "automatic";
if (body.hstsSubdomains !== undefined) item.hstsSubdomains = Boolean(body.hstsSubdomains);
if (body.requestHeaders !== undefined) item.requestHeaders = cleanHeaders(body.requestHeaders);
if (body.responseHeaders !== undefined) item.responseHeaders = cleanHeaders(body.responseHeaders);
if (body.upstreamTlsServerName !== undefined) item.upstreamTlsServerName = String(body.upstreamTlsServerName || "").trim().slice(0, 253);
if (body.upstreamTlsInsecure !== undefined) item.upstreamTlsInsecure = Boolean(body.upstreamTlsInsecure);
if (body.healthEnabled !== undefined) item.healthEnabled = body.healthEnabled === true || (typeof body.healthEnabled === "string" && body.healthEnabled.toLowerCase() === "true");
if (body.healthPath !== undefined) item.healthPath = /^\//.test(body.healthPath || "") ? String(body.healthPath).slice(0, 500) : "/";
if (body.healthMethod !== undefined) item.healthMethod = ["GET", "HEAD"].includes(body.healthMethod) ? body.healthMethod : "GET";
if (body.healthExpected !== undefined) {
const expected = String(body.healthExpected || "200-499").trim().slice(0, 80);
if (!/^\d{3}(?:\s*-\s*\d{3})?(?:\s*,\s*\d{3}(?:\s*-\s*\d{3})?)*$/.test(expected)) throw Object.assign(new Error("Expected status must contain HTTP codes or ranges, such as 200,204 or 200-399."), { status: 400 });
item.healthExpected = expected;
}
if (body.healthTimeoutSeconds !== undefined) item.healthTimeoutSeconds = Math.min(Math.max(Number(body.healthTimeoutSeconds) || 4, 1), 60);
if (body.healthRetries !== undefined) item.healthRetries = Math.min(Math.max(Number(body.healthRetries) || 0, 0), 3);
if (body.customConfig !== undefined) item.customConfig = cleanCustomConfig(body.customConfig);
if (body.locations !== undefined) item.locations = cleanLocations(body.locations);
}
function expectedStatusMatches(status, specification = "200-499") {
return String(specification).split(",").some(part => {
const value = part.trim();
if (/^\d{3}$/.test(value)) return status === Number(value);
const match = value.match(/^(\d{3})\s*-\s*(\d{3})$/);
return match ? status >= Number(match[1]) && status <= Number(match[2]) : false;
});
}
function caddySiteAddress(item) {
const domains = normalizeDomains(item.domain, item.domains);
return (item.tls === "http" ? domains.map(domain => `http://${domain}`) : domains).join(" ");
}
function caddyQuote(value) {
return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", " ")}"`;
}
function accessDirectives(accessListId) {
const list = accessLists.find(item => item.id === accessListId && item.enabled !== false);
if (!list) return [];
const output = [];
if (list.deniedNetworks?.length) output.push(` @blocked-${list.id} remote_ip ${list.deniedNetworks.join(" ")}`, ` abort @blocked-${list.id}`);
if (list.networks?.length) {
output.push(` @outside-${list.id} not remote_ip ${list.networks.join(" ")}`, ` abort @outside-${list.id}`);
}
if (list.credentials?.length || list.groups?.length) {
output.push(` @protected-${list.id} not path /_site-gateway/*`, ` forward_auth @protected-${list.id} 127.0.0.1:${adminPort} {`, ` uri /api/access-check?list=${list.id}`, " }", ` handle /_site-gateway/* {`, ` reverse_proxy 127.0.0.1:${adminPort}`, " }");
}
return output;
}
function commonHostDirectives(item) {
const output = [...accessDirectives(item.accessListId)];
if (item.compression !== "off") output.push(item.compression === "gzip" ? " encode gzip" : " encode zstd gzip");
for (const header of item.responseHeaders || []) output.push(` header ${header.name} ${caddyQuote(header.value)}`);
if (item.hsts && item.tls !== "http") output.push(` header Strict-Transport-Security ${caddyQuote(`max-age=31536000${item.hstsSubdomains ? "; includeSubDomains" : ""}`)}`);
if (item.tls === "internal") output.push(" tls internal");
if (item.tls === "custom" && item.certificatePath && item.keyPath) output.push(` tls ${caddyQuote(item.certificatePath)} ${caddyQuote(item.keyPath)}`);
return output;
}
function proxyBlock(target, item, indent = " ") {
const targets = Array.isArray(item.upstreams) && item.upstreams.length ? item.upstreams : [target];
const output = [`${indent}reverse_proxy ${targets.join(" ")} {`];
const timeout = Math.min(Math.max(Number(item.healthTimeoutSeconds) || 4, 1), 60);
const httpsUpstream = targets.length > 0 && targets.every(value => /^https:\/\//i.test(String(value).trim()));
if (httpsUpstream && (item.upstreamTlsServerName || item.upstreamTlsInsecure)) output.push(`${indent} transport http {`, ...(item.upstreamTlsServerName ? [`${indent} tls_server_name ${item.upstreamTlsServerName}`] : []), ...(item.upstreamTlsInsecure ? [`${indent} tls_insecure_skip_verify`] : []), `${indent} response_header_timeout ${timeout}s`, `${indent} }`);
for (const header of item.requestHeaders || []) output.push(`${indent} header_up ${header.name} ${caddyQuote(header.value)}`);
output.push(`${indent}}`);
return output;
}
async function writeDefaultSitePage() {
const selected = settings.defaultSite || {};
const title = String(selected.title || (selected.mode === "welcome" ? "Gateway ready" : "Route not found")).replace(/[<>]/g, "");
const message = String(selected.message || "The gateway is responding, but this address has not been configured.").replace(/[<>]/g, "");
const html = selected.mode === "custom" && selected.customHtml
? String(selected.customHtml)
: `
${title}SG
Site Gateway
${title}
${message}
`;
await fsp.writeFile(path.join(defaultSiteDir, "index.html"), html);
}
function renderCaddyfile() {
const email = String(process.env.ACME_EMAIL || "").trim();
const lines = ["{", " admin localhost:2019", " persist_config off", ` storage file_system ${managedCertificatesDir}`];
if (email) lines.push(` email ${email}`);
const logging = [" log {", ` output file ${accessLogPath} {`, " roll_size 10mb", " roll_keep 5", " roll_keep_for 168h", " roll_uncompressed", " }", " format json", " }"];
lines.push("}", "", ":80 {", ...logging);
const defaultSite = settings.defaultSite || {};
if (defaultSite.mode === "abort") lines.push(" abort");
else if (defaultSite.mode === "redirect" && defaultSite.redirectUrl) lines.push(` redir ${caddyQuote(`${defaultSite.redirectUrl}${defaultSite.preservePath ? "{uri}" : ""}`)} ${[301, 302, 307, 308].includes(Number(defaultSite.redirectCode)) ? Number(defaultSite.redirectCode) : 302}`);
else lines.push(` root * ${defaultSiteDir}`, " rewrite * /index.html", ` file_server {`, ` status ${defaultSite.mode === "welcome" ? 200 : 404}`, " }");
lines.push("}");
for (const site of sites.filter(item => item.enabled && item.domain)) {
lines.push("", `${caddySiteAddress(site)} {`, ...logging, ...commonHostDirectives(site), ` root * ${path.join(sitesDir, site.id)}`, " file_server");
lines.push("}");
}
for (const proxy of proxies.filter(item => item.enabled && item.domain)) {
lines.push("", `${caddySiteAddress(proxy)} {`, ...logging, ...commonHostDirectives(proxy));
for (const location of proxy.locations || []) {
lines.push(` ${location.stripPrefix ? "handle_path" : "handle"} ${location.path} {`, ...proxyBlock(location.target, location, " "), " }");
}
if ((proxy.locations || []).length) lines.push(" handle {", ...proxyBlock(proxy.target, proxy, " "), " }");
else lines.push(...proxyBlock(proxy.target, proxy));
if (proxy.customConfig) lines.push(" # Administrator-provided custom configuration", ...String(proxy.customConfig).split("\n").map(line => ` ${line}`));
lines.push("}");
}
for (const redirect of redirects.filter(item => item.enabled && item.domain)) {
const target = `${redirect.target}${redirect.preservePath ? "{uri}" : ""}`;
lines.push("", `${caddySiteAddress(redirect)} {`, ...logging, ...commonHostDirectives(redirect), ` redir ${caddyQuote(target)} ${redirect.code || 302}`, "}");
}
return `${lines.join("\n")}\n`;
}
async function syncCaddy() {
const nextPath = `${caddyfilePath}.next`;
const previous = await fsp.readFile(caddyfilePath, "utf8").catch(() => null);
const previousDefaultPage = await fsp.readFile(path.join(defaultSiteDir, "index.html")).catch(() => null);
await writeDefaultSitePage();
await fsp.writeFile(nextPath, renderCaddyfile());
try {
await execFileAsync("caddy", ["fmt", "--overwrite", nextPath]);
await execFileAsync("caddy", ["validate", "--config", nextPath, "--adapter", "caddyfile"]);
await fsp.rename(nextPath, caddyfilePath);
await execFileAsync("caddy", ["reload", "--config", caddyfilePath, "--adapter", "caddyfile"]);
gatewayError = null;
lastGatewayReload = new Date().toISOString();
} catch (error) {
const rejectedReason = error.stderr || error.message;
let rollbackSucceeded = false;
await fsp.rm(nextPath, { force: true });
if (previous !== null) {
await fsp.writeFile(caddyfilePath, previous);
rollbackSucceeded = await execFileAsync("caddy", ["reload", "--config", caddyfilePath, "--adapter", "caddyfile"]).then(() => true).catch(() => false);
}
if (previousDefaultPage !== null) await fsp.writeFile(path.join(defaultSiteDir, "index.html"), previousDefaultPage);
try {
sites = storage.loadCollection("sites"); proxies = storage.loadCollection("proxies"); redirects = storage.loadCollection("redirects"); streams = storage.loadCollection("streams"); accessLists = storage.loadCollection("access_lists"); settings = storage.loadSettings() || settings;
} catch { /* Startup may not have completed database initialization yet. */ }
gatewayError = rollbackSucceeded ? null : rejectedReason;
const friendly = /upstream address scheme is HTTP but transport is configured for HTTP\+TLS/i.test(rejectedReason) ? "This host forwards to HTTP, but Ignore upstream TLS certificate errors is enabled. Turn that option off or change the upstream to HTTPS." : /upstream address scheme is HTTPS but transport is configured for plain HTTP/i.test(rejectedReason) ? "This host forwards to HTTPS, but its upstream transport is configured for plain HTTP. Use HTTPS transport settings or change the upstream to HTTP." : /duplicate.*address|already.*site address/i.test(rejectedReason) ? "This hostname or address is already used by another host. Choose a unique hostname and port." : /dial tcp|no such host|lookup .* no such host|upstream.*(invalid|malformed)/i.test(rejectedReason) ? "The upstream address could not be reached or is invalid. Check the hostname, IP address, and port." : /invalid hostname|host name.*invalid|malformed.*host/i.test(rejectedReason) ? "The hostname is not valid. Use a valid domain name without a protocol or path." : /unrecognized directive|unknown directive|parsing caddyfile tokens/i.test(rejectedReason) ? "The gateway configuration contains an unsupported or malformed directive. Check the selected host settings." : /certificate|tls.*(config|handshake)|no certificate/i.test(rejectedReason) ? "The TLS certificate configuration is invalid or unavailable. Check the certificate, key, and HTTPS settings." : "The gateway rejected this configuration. Check the host, upstream address, and TLS settings.";
const detail = `${friendly}${rollbackSucceeded ? " The previous working configuration remains active." : ""}\nDetails: ${rejectedReason}`;
throw Object.assign(new Error(detail), { status: 400 });
}
}
function siteStatus(site) {
if (!site.enabled) return "disabled";
if (site.domain && gatewayError) return "error";
return activeServers.has(site.id) ? "running" : "error";
}
function publicSite(site) {
return { ...site, domains: normalizeDomains(site.domain, site.domains), status: siteStatus(site), url: `http://${site.host || "localhost"}:${site.port}`, upstream: upstreamHealth.get(site.id) || null };
}
function publicProxy(proxy, includeAdvanced = false) {
const { certificatePath, keyPath, ...safe } = proxy;
if (!includeAdvanced) { delete safe.customConfig; delete safe.requestHeaders; }
return { ...safe, domains: normalizeDomains(proxy.domain, proxy.domains), certificatePath: certificatePath ? "installed" : null, hasCustomCertificate: Boolean(certificatePath && keyPath), status: proxy.enabled ? (gatewayError ? "error" : "running") : "disabled", upstream: upstreamHealth.get(proxy.id) || null };
}
function publicStream(stream) {
return { ...stream, status: stream.enabled === false ? "disabled" : activeStreams.has(stream.id) ? "running" : "error", upstream: upstreamHealth.get(stream.id) || null };
}
async function walkFiles(directory) {
const output = [];
for (const entry of await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error))) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) output.push(...await walkFiles(fullPath));
else if (entry.isFile()) output.push(fullPath);
}
return output;
}
function certificateNames(certificate) {
const names = [];
for (const part of String(certificate.subjectAltName || "").split(/,\s*/)) if (part.startsWith("DNS:")) names.push(part.slice(4).toLowerCase());
return names;
}
async function certificateInventory() {
const configured = [...sites.map(item => ({ ...item, kind: "Hosted site" })), ...proxies.map(item => ({ ...item, kind: "Proxy host" })), ...redirects.map(item => ({ ...item, kind: "Redirect host" }))]
.filter(item => item.enabled && item.domain && item.tls !== "http");
const configuredDomains = configured.flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain })));
const parsed = [];
const certificateFiles = [...await walkFiles(certificateDir), ...await walkFiles(customCertificatesDir)];
for (const filename of certificateFiles.filter(file => /\.(?:crt|pem)$/i.test(file))) {
try {
const certificate = new crypto.X509Certificate(await fsp.readFile(filename));
const stat = await fsp.stat(filename);
parsed.push({ certificate, names: certificateNames(certificate), updatedAt: stat.mtime.toISOString(), filename, source: filename.startsWith(customCertificatesDir) ? "Custom upload" : "Caddy / ACME" });
} catch { /* Ignore non-certificate PEM files and unreadable entries. */ }
}
const certificates = configuredDomains.map(item => {
const found = parsed.find(entry => entry.names.some(name => name === item.domain || (name.startsWith("*.") && item.domain.endsWith(name.slice(1)))));
if (!found) {
const customForRoute = item.tls === "custom" ? parsed.find(entry => entry.source === "Custom upload" && entry.filename.includes(item.id)) : null;
return { domain: item.domain, name: item.name, kind: item.kind, status: customForRoute ? "mismatch" : "pending", daysRemaining: null, expiresAt: null, issuer: null, updatedAt: customForRoute?.updatedAt || null, source: item.tls === "internal" ? "Caddy internal CA" : item.tls === "custom" ? "Custom upload" : "Caddy / ACME", mismatch: Boolean(customForRoute), coveredNames: customForRoute?.names || [] };
}
const expiresAt = new Date(found.certificate.validTo);
const daysRemaining = Math.ceil((expiresAt.getTime() - Date.now()) / 86400000);
const warningDays = settings.certificateHealth?.warningDays || 30, criticalDays = settings.certificateHealth?.criticalDays || 7;
const status = daysRemaining <= 0 ? "expired" : daysRemaining <= criticalDays ? "critical" : daysRemaining <= warningDays ? "warning" : "healthy";
return { domain: item.domain, name: item.name, kind: item.kind, status, daysRemaining, validFrom: new Date(found.certificate.validFrom).toISOString(), expiresAt: expiresAt.toISOString(), issuer: found.certificate.issuer, subject: found.certificate.subject, serialNumber: found.certificate.serialNumber, updatedAt: found.updatedAt, fingerprint: found.certificate.fingerprint256, coveredNames: found.names, source: item.tls === "internal" ? "Caddy internal CA" : found.source, mismatch: false };
});
for (const certificate of certificates) { const previous = certificateStatusCache.get(certificate.domain); if (previous && previous !== certificate.status) recordActivity(`Certificate status changed for ${certificate.domain}: ${previous} → ${certificate.status}.`, certificate.status === "healthy" ? "ok" : "error"); certificateStatusCache.set(certificate.domain, certificate.status); }
const latestError = recentActivity.find(item => item.status === "error" && /cert|tls|acme|caddy|gateway/i.test(item.message)) || null;
return { checkedAt: new Date().toISOString(), thresholds: settings.certificateHealth, latestError, summary: { total: certificates.length, healthy: certificates.filter(item => item.status === "healthy").length, within30Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 30 && item.daysRemaining > 0).length, within7Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 7 && item.daysRemaining > 0).length, warning: certificates.filter(item => item.status === "warning").length, critical: certificates.filter(item => item.status === "critical").length, expired: certificates.filter(item => item.status === "expired").length, pending: certificates.filter(item => item.status === "pending").length, mismatch: certificates.filter(item => item.status === "mismatch").length }, certificates };
}
async function pruneOrphanedCertificates(candidateDomains) {
const domains = [...new Set((candidateDomains || []).filter(Boolean).map(domain => String(domain).toLowerCase()))];
if (!domains.length) return;
const stillInUse = new Set([...sites, ...proxies, ...redirects].filter(item => item.enabled).flatMap(item => normalizeDomains(item.domain, item.domains)).map(domain => domain.toLowerCase()));
const orphaned = domains.filter(domain => !stillInUse.has(domain));
if (!orphaned.length) return;
const files = await walkFiles(certificateDir).catch(() => []);
const removed = new Set();
for (const file of files) {
const directory = path.dirname(file);
if (orphaned.includes(path.basename(directory).toLowerCase()) && !removed.has(directory)) {
await fsp.rm(directory, { recursive: true, force: true }).catch(() => {});
removed.add(directory);
}
}
if (removed.size) recordActivity(`Removed stored certificate data for ${orphaned.join(", ")} (no longer in use).`);
}
async function domainReadiness() {
const routes = [...sites.map(item => ({ ...item, kind: "Hosted site" })), ...proxies.map(item => ({ ...item, kind: "Proxy host" })), ...redirects.map(item => ({ ...item, kind: "Redirect host" }))].filter(item => item.enabled && item.domain).flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain })));
const certs = await certificateInventory();
const [httpResponding, httpsResponding] = await Promise.all([tcpProbe(80), tcpProbe(443)]);
return Promise.all(routes.map(async item => {
let addresses = [], dnsError = null;
try { addresses = [...new Set((await dns.lookup(item.domain, { all: true })).map(value => value.address))]; } catch (error) { dnsError = error.code || error.message; }
const certificate = certs.certificates.find(cert => cert.domain === item.domain) || null;
const upstream = item.kind === "Proxy host" ? upstreamHealth.get(item.id) || null : null;
return { id: item.id, domain: item.domain, name: item.name, kind: item.kind, dns: { healthy: addresses.length > 0, addresses, error: dnsError }, ports: { http: httpResponding, https: item.tls === "http" ? null : httpsResponding }, tls: item.tls === "http" ? { status: "not-configured" } : { status: certificate?.status || "pending" }, upstream };
}));
}
async function checkProxy(proxy) {
if (!proxy.enabled) { const result = { status: "disabled", checkedAt: new Date().toISOString(), history: [] }; upstreamHealth.set(proxy.id, result); return result; }
if (proxy.healthEnabled === false) { const result = { status: "unmonitored", checkedAt: null, history: [] }; upstreamHealth.set(proxy.id, result); return result; }
const started = performance.now();
const attempts = Math.min(Math.max(Number(proxy.healthRetries) || 0, 0), 3) + 1;
let result;
for (let attempt = 0; attempt < attempts; attempt++) try {
const target = new URL(proxy.healthPath || "/", `${proxy.target}/`).toString();
const response = await fetch(target, { method: proxy.healthMethod || "GET", redirect: "manual", signal: AbortSignal.timeout((proxy.healthTimeoutSeconds || 4) * 1000), headers: { "user-agent": "Site-Gateway-Health/1.0" } });
await response.body?.cancel();
const responseMs = Math.round(performance.now() - started);
const accepted = expectedStatusMatches(response.status, proxy.healthExpected);
result = { status: accepted ? "healthy" : "unhealthy", httpStatus: response.status, responseMs, attempts: attempt + 1, checkedAt: new Date().toISOString(), error: accepted ? null : `Expected ${proxy.healthExpected || "200-499"}; received HTTP ${response.status}` };
if (accepted) break;
} catch (error) {
result = { status: "unhealthy", httpStatus: null, responseMs: Math.round(performance.now() - started), attempts: attempt + 1, checkedAt: new Date().toISOString(), error: error.name === "TimeoutError" ? `Timed out after ${proxy.healthTimeoutSeconds || 4} seconds` : error.message };
}
const previous = upstreamHealth.get(proxy.id);
result.history = [{ status: result.status, responseMs: result.responseMs, httpStatus: result.httpStatus, checkedAt: result.checkedAt }, ...(previous?.history || [])].slice(0, 20);
upstreamHealth.set(proxy.id, result);
return result;
}
async function checkAllProxies() {
await Promise.all([...proxies.map(checkProxy), ...sites.map(site => checkProxy({ ...site, target: `http://127.0.0.1:${site.port}`, healthPath: site.healthPath || "/", healthMethod: site.healthMethod || "GET", healthExpected: site.healthExpected || "200-499", healthTimeoutSeconds: site.healthTimeoutSeconds || 4, healthRetries: site.healthRetries || 0, healthEnabled: site.healthEnabled })), ...streams.map(checkStream)]);
return proxies.map(publicProxy);
}
const SENSITIVE_QUERY_PARAM_PATTERNS = [/token/i, /secret/i, /password/i, /passwd/i, /auth/i, /session/i, /api[-_]?key/i, /credential/i];
function redactUri(uri) {
const str = String(uri || "");
const queryIndex = str.indexOf("?");
if (queryIndex === -1) return str;
const pathPart = str.slice(0, queryIndex);
let params;
try { params = new URLSearchParams(str.slice(queryIndex + 1)); } catch { return `${pathPart}?REDACTED`; }
let redactedAny = false;
for (const name of [...params.keys()]) {
if (SENSITIVE_QUERY_PARAM_PATTERNS.some(pattern => pattern.test(name))) { params.set(name, "REDACTED"); redactedAny = true; }
}
return redactedAny ? `${pathPart}?${params.toString()}` : str;
}
async function readAccessLogs(limit = 100, host = "") {
const files = (await fsp.readdir(logsDir).catch(() => [])).filter(name => name === "access.json" || name.startsWith("access.json.")).sort().reverse();
const entries = [];
for (const name of files) {
const content = await fsp.readFile(path.join(logsDir, name), "utf8").catch(() => "");
for (const line of content.trim().split("\n").reverse()) {
try {
const raw = JSON.parse(line); const request = raw.request || {}; const requestHost = String(request.host || "").split(":")[0];
if (host && requestHost !== host) continue;
entries.push({ at: raw.ts ? new Date(raw.ts * 1000).toISOString() : null, host: requestHost, method: request.method, uri: redactUri(request.uri), status: raw.status, size: raw.size, durationMs: Number.isFinite(raw.duration) ? Math.round(raw.duration * 1000) : null, remoteIp: request.remote_ip || null });
if (entries.length >= limit) return entries;
} catch { /* Skip incomplete lines while Caddy writes. */ }
}
}
return entries;
}
async function importAccessLogsToSqlite() {
if (!storage?.recordAccessEvents) return;
try {
const entries = await readAccessLogs(5000);
const events = entries.map(entry => ({ ...entry, source: crypto.createHash("sha1").update(JSON.stringify([entry.at, entry.host, entry.method, entry.uri, entry.status, entry.size, entry.durationMs, entry.remoteIp])).digest("hex") }));
storage.recordAccessEvents(events);
} catch (error) { console.warn("Could not import access logs into SQLite:", error.message); }
}
function tcpProbe(port, timeoutMs = 1000) {
return new Promise(resolve => {
const socket = net.createConnection({ host: "127.0.0.1", port });
const finish = result => { socket.destroy(); resolve(result); };
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}
function tcpProbeHost(host, port, timeoutMs = 4000) {
return new Promise(resolve => {
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) return resolve(false);
const socket = net.createConnection({ host, port });
const finish = result => { socket.destroy(); resolve(result); };
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}
function stableProbe(name, responding) {
if (responding) { probeFailures[name] = 0; return { status: "ready", healthy: true, responding: true }; }
probeFailures[name] += 1;
return probeFailures[name] < 2
? { status: "checking", healthy: true, responding: false }
: { status: "error", healthy: false, responding: false };
}
async function loadIconCatalog() {
if (iconCatalog) return iconCatalog;
try {
const response = await fetch("https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/metadata.json", { signal: AbortSignal.timeout(5000) });
if (!response.ok) throw new Error(`Icon catalogue returned ${response.status}.`);
const text = await response.text();
if (text.length > 8 * 1024 * 1024) throw new Error("Icon catalogue is unexpectedly large.");
iconCatalog = JSON.parse(text);
await fsp.writeFile(iconCatalogPath, text);
} catch (error) {
try { iconCatalog = JSON.parse(await fsp.readFile(iconCatalogPath, "utf8")); }
catch { throw Object.assign(new Error("The icon catalogue is temporarily unavailable."), { status: 503 }); }
}
return iconCatalog;
}
function iconLabel(slug) {
return slug.split("-").map(word => word ? word[0].toUpperCase() + word.slice(1) : "").join(" ");
}
async function cacheIcon(slug) {
if (!/^[a-z0-9][a-z0-9-]{0,100}$/.test(slug)) throw Object.assign(new Error("Invalid icon selection."), { status: 400 });
const catalog = await loadIconCatalog();
const metadata = catalog[slug];
if (!metadata) throw Object.assign(new Error("Icon not found."), { status: 404 });
const response = await fetch(`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${slug}.svg`, { signal: AbortSignal.timeout(7000) });
if (!response.ok) throw Object.assign(new Error("The selected icon could not be downloaded."), { status: 502 });
const svg = await response.text();
if (svg.length > 512 * 1024 || !/