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 QRCode from "qrcode"; import { LOCAL_INSTANCE_ID, openStorage } from "./storage.js"; import { generateTotpSecret, verifyTotp, otpauthUri, generateRecoveryCodes } from "./totp.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 publicIpState = { address: null, checkedAt: null, error: null }; 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; // --- Small utility helpers (activity log, dir sizing, env parsing, passwords) ----------- 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, mfaSecret, mfaPendingSecret, mfaRecoveryCodes, ...safe } = user; return { ...safe, mfaEnabled: Boolean(user.mfaEnabled) }; } function activeAdministrators() { return users.filter(user => user.role === "administrator" && user.status === "active"); } // --- Sessions & auth cookies -------------------------------------------------------------- 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); // --- Data loading (hosted sites) and shared validation helpers ----------------------------- 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 (user.mfaEnabled === undefined) { user.mfaEnabled = false; usersChanged = true; } if (!Array.isArray(user.mfaRecoveryCodes)) { user.mfaRecoveryCodes = []; 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: "complete", 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(); } // --- Domain / target / stream-port validation ----------------------------------------------- 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; } // --- Header / location / custom-config sanitizing for Proxy & Hosted advanced options ------- 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.lbPolicy !== undefined) item.lbPolicy = ["random", "round_robin", "least_conn", "ip_hash"].includes(body.lbPolicy) ? body.lbPolicy : "random"; 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.blockCommonExploits !== undefined) item.blockCommonExploits = Boolean(body.blockCommonExploits); 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); } // --- Caddyfile generation: turns hosted sites/proxies/redirects/streams/access lists // into the actual Caddy configuration and reloads Caddy with it ------------------------- 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) { // Caddy's Caddyfile lexer only special-cases \" inside a quoted string — it does NOT // collapse \\ into a single backslash (confirmed in caddyconfig/caddyfile/lexer.go: "all is // literal in quoted area, so only escape quotes"). Doubling backslashes here, as this used to, // corrupts any value that legitimately contains one (e.g. a regex like eval\( becomes eval\\(, // which Caddy then reads as an escaped backslash followed by an unclosed real group). return `"${String(value).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; } // Static, general-purpose ruleset for the "Block common exploits" toggle — not a full WAF. Rejects // requests whose path matches common exploit-probe patterns before they reach the upstream: directory // traversal, WordPress/PHP admin and scanner paths, dotfile exposure attempts, and SQL-injection-style // query strings. One named matcher + one respond directive per host, so it's cheap to add or remove. const COMMON_EXPLOIT_PATTERN = String.raw`(?i)(\.\./|\.\.\\|/etc/passwd|/wp-login\.php|/wp-admin(?:/|$)|/xmlrpc\.php|/\.env(?:$|\?)|/\.git/|/\.aws/|/vendor/phpunit|/phpunit(?:/|$)|eval\(|base64_decode\(|union(?:\s|%20|\+)+select| 1) { const policy = ["round_robin", "least_conn", "ip_hash"].includes(item.lbPolicy) ? item.lbPolicy : "random"; output.push(`${indent} lb_policy ${policy}`); } 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; } // Writes the themed default ("no route configured") static HTML page to disk. Keep // this HTML in sync with the client-side preview in features.js's // defaultSiteThemedHtml() -- see the comment there. 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}
Site Gateway

${title}

${message}

Host. Proxy. Secure.
`; await fsp.writeFile(path.join(defaultSiteDir, "index.html"), html); } // renderCaddyfile -- builds the full Caddy JSON/Caddyfile config from current state // (sites, proxies, redirects, streams, access lists, default site settings). 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 && normalizeDomains(item.domain, item.domains).length)) { 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`; } // syncCaddy -- applies the generated config to the running Caddy instance and // records success/failure (gatewayError, lastGatewayReload) for the dashboard. 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 }); } } // --- Status helpers & public (client-facing, secret-stripped) view builders ------------------ 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 }; } // --- Certificate inventory & domain readiness diagnostics -------------------------------------- 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 }; })); } // --- Upstream (proxy target) health checks ------------------------------------------------------ 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]; // --- Access log ingestion (tailing Caddy's access log into SQLite) ------------------------------ 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); } } // --- Raw TCP probing, used for streaming-host health checks -------------------------------------- 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 }; } // --- Icon catalog (searchable dashboard-icons list) & icon caching ------------------------------- 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 || !/]/i.test(svg) || /<(?:script|foreignObject)\b|\son\w+\s*=|(?:href|xlink:href)\s*=\s*["'](?:https?:|\/\/)/i.test(svg)) { throw Object.assign(new Error("The selected icon did not pass safety validation."), { status: 400 }); } const filename = `${slug}.svg`; await fsp.writeFile(path.join(iconsDir, filename), svg); return `/site-icons/${filename}`; } // --- Dashboard snapshot: aggregates health/status across every subsystem for the // Overview page and the /api/dashboard endpoint -------------------------------------------- async function dashboardSnapshot() { const hosted = sites.map(publicSite); const proxyHosts = proxies.map(publicProxy); const enabledStreams = streams.filter(item => item.enabled !== false); const streamingPorts = { total: enabledStreams.length, listening: enabledStreams.filter(item => activeStreams.has(item.id)).length }; const certificates = await certificateInventory(); const tlsDomains = [...sites, ...proxies].filter(item => item.enabled && item.domain && item.tls !== "http").length; const [storageWritable, gatewayResponding, httpResponding, httpsResponding] = await Promise.all([ fsp.access(dataDir, fs.constants.R_OK | fs.constants.W_OK).then(() => true).catch(() => false), tcpProbe(2019), tcpProbe(80), tlsDomains ? tcpProbe(443) : Promise.resolve(false) ]); let gatewayProbe = stableProbe("gateway", gatewayResponding); if (gatewayError) gatewayProbe = { status: "error", healthy: false, responding: gatewayResponding }; const httpProbe = stableProbe("http", httpResponding); const httpsProbe = tlsDomains ? stableProbe("https", httpsResponding) : { status: "unconfigured", healthy: true, responding: false }; const attention = []; if (gatewayError) attention.push({ kind: "gateway", name: "Gateway configuration", message: "Caddy rejected the current configuration." }); if (gatewayProbe.status === "error" && !gatewayResponding) attention.push({ kind: "gateway", name: "Caddy gateway", message: "The Caddy administration endpoint is not responding." }); if (httpProbe.status === "error") attention.push({ kind: "http", name: "HTTP · Port 80", message: "Port 80 is not accepting connections inside the container." }); if (httpsProbe.status === "error") attention.push({ kind: "https", name: "HTTPS · Port 443", message: "TLS domains are enabled but port 443 is not accepting connections." }); if (!storageWritable) attention.push({ kind: "storage", name: "Persistent storage", message: "The data directory is not readable and writable." }); for (const site of hosted.filter(item => item.status === "error")) attention.push({ kind: "hosted", name: site.name, message: `Hosted site is not responding on port ${site.port}.` }); for (const proxy of proxyHosts.filter(item => item.status === "error")) attention.push({ kind: "proxy", name: proxy.name, message: "Proxy route needs attention." }); for (const proxy of proxyHosts.filter(item => item.enabled && item.upstream?.status === "unhealthy")) attention.push({ kind: "upstream", name: proxy.name, message: `Upstream is unavailable${proxy.upstream.error ? ` · ${proxy.upstream.error}` : ""}.` }); for (const certificate of certificates.certificates.filter(item => ["warning", "critical", "expired", "mismatch"].includes(item.status))) attention.push({ kind: "certificate", target: "certificates", name: certificate.domain, message: certificate.status === "expired" ? "Certificate has expired." : certificate.status === "mismatch" ? "The uploaded certificate does not cover this domain." : `Certificate expires in ${certificate.daysRemaining} day${certificate.daysRemaining === 1 ? "" : "s"}.` }); const disk = await fsp.statfs(dataDir).catch(() => null); const databaseIntegrity = storage.integrity(); return { checkedAt: new Date().toISOString(), gateway: { ...gatewayProbe, lastReload: lastGatewayReload }, services: { http: { ...httpProbe, port: 80 }, https: { ...httpsProbe, port: 443, activeDomains: tlsDomains }, storage: { status: storageWritable ? "ready" : "error", healthy: storageWritable, path: dataDir } }, hosted: { total: hosted.length, running: hosted.filter(item => item.status === "running").length, disabled: hosted.filter(item => item.status === "disabled").length, errors: hosted.filter(item => item.status === "error").length }, proxies: { total: proxyHosts.length, running: proxyHosts.filter(item => item.status === "running").length, disabled: proxyHosts.filter(item => item.status === "disabled").length, errors: proxyHosts.filter(item => item.status === "error").length }, tlsDomains, certificates: certificates.summary, upstreams: { total: proxyHosts.filter(item => item.enabled).length, healthy: proxyHosts.filter(item => item.upstream?.status === "healthy").length, unhealthy: proxyHosts.filter(item => item.upstream?.status === "unhealthy").length }, streamingPorts, throughput: { liveRequests: storage.performanceLiveCount(60) }, attention, system: { uptimeSeconds: Math.floor(process.uptime()), memoryBytes: process.memoryUsage().rss, dataBytes: await directorySize(dataDir), diskFreeBytes: disk ? disk.bavail * disk.bsize : null, diskTotalBytes: disk ? disk.blocks * disk.bsize : null, appVersion, caddyVersion, nodeVersion: process.version, databaseEngine: "SQLite", databaseStatus: databaseIntegrity.length === 1 && databaseIntegrity[0] === "ok" ? "Healthy" : "Needs attention", databaseBytes: (await fsp.stat(storage.databasePath).catch(() => null))?.size || 0, publicIp: publicIpState.address, publicIpCheckedAt: publicIpState.checkedAt, publicIpError: publicIpState.error, jobs: [{ name: "Upstream checks", enabled: true, schedule: "60s" }, { name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off" }, { name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off" }, { name: "Access-log import", enabled: true, schedule: "30s" }, { name: "Public IP check", enabled: true, schedule: "60m" }] }, activity: recentActivity }; } // --- Hosted site process/lifecycle control -------------------------------------------------------- async function startSite(site) { if (!site.enabled || activeServers.has(site.id)) return; const root = path.join(sitesDir, site.id); const app = express(); app.disable("x-powered-by"); app.use(express.static(root, { extensions: ["html"], index: "index.html", fallthrough: true })); app.use((req, res) => res.status(404).sendFile(path.join(publicDir, "site-404.html"))); const server = http.createServer(app); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(site.port, "0.0.0.0", resolve); }); activeServers.set(site.id, server); console.log(`Serving ${site.name} on port ${site.port}`); } async function stopSite(id) { const server = activeServers.get(id); if (!server) return; await new Promise(resolve => server.close(resolve)); activeServers.delete(id); } async function restartSite(site) { await stopSite(site.id); if (site.enabled) await startSite(site); } // Streaming hosts relay raw TCP/UDP on a specific port straight to a host:port target — no domain, no HTTP, // no Caddy involvement. This is the same pattern as startSite()/stopSite() above: a dedicated listener Site // Gateway owns directly, just for a plain socket instead of an HTTP server. // --- Streaming host process/lifecycle control ------------------------------------------------------- async function startStream(stream) { if (stream.enabled === false || activeStreams.has(stream.id)) return; const [targetHost, targetPortRaw] = String(stream.target || "").split(":"); const targetPort = Number(targetPortRaw); const handle = { tcpServer: null, udpSocket: null, udpSessions: new Map() }; try { if (stream.tcp !== false) { const tcpServer = net.createServer(socket => { const upstream = net.createConnection({ host: targetHost, port: targetPort }); const destroyBoth = () => { socket.destroy(); upstream.destroy(); }; socket.on("error", destroyBoth); upstream.on("error", destroyBoth); socket.on("close", () => upstream.destroy()); upstream.on("close", () => socket.destroy()); socket.pipe(upstream); upstream.pipe(socket); }); await new Promise((resolve, reject) => { tcpServer.once("error", reject); tcpServer.listen(stream.port, "0.0.0.0", resolve); }); tcpServer.on("error", error => console.warn(`Streaming host “${stream.name}” TCP error:`, error.message)); handle.tcpServer = tcpServer; } if (stream.udp) { const udpSocket = dgram.createSocket("udp4"); udpSocket.on("message", (message, rinfo) => { const key = `${rinfo.address}:${rinfo.port}`; let session = handle.udpSessions.get(key); if (!session) { const outbound = dgram.createSocket("udp4"); session = { outbound, timer: null, connected: false, pending: [] }; outbound.on("message", reply => { try { udpSocket.send(reply, rinfo.port, rinfo.address); } catch { /* client socket may already be gone */ } }); outbound.on("error", () => {}); // connect() is asynchronous — sending before it completes silently drops the datagram, which would // lose the first packet of every new UDP session. Queue until the callback confirms it's connected. outbound.connect(targetPort, targetHost, () => { session.connected = true; for (const buffered of session.pending.splice(0)) { try { outbound.send(buffered); } catch { /* upstream may be unreachable */ } } }); handle.udpSessions.set(key, session); } clearTimeout(session.timer); session.timer = setTimeout(() => { session.outbound.close(); handle.udpSessions.delete(key); }, 60000).unref(); if (session.connected) { try { session.outbound.send(message); } catch { /* upstream may be unreachable; drop this datagram */ } } else session.pending.push(message); }); await new Promise((resolve, reject) => { udpSocket.once("error", reject); udpSocket.bind(stream.port, "0.0.0.0", resolve); }); udpSocket.on("error", error => console.warn(`Streaming host “${stream.name}” UDP error:`, error.message)); handle.udpSocket = udpSocket; } } catch (error) { if (handle.tcpServer) await new Promise(resolve => handle.tcpServer.close(resolve)); if (handle.udpSocket) handle.udpSocket.close(); throw error; } activeStreams.set(stream.id, handle); console.log(`Streaming “${stream.name}” on port ${stream.port}`); } async function stopStream(id) { const handle = activeStreams.get(id); if (!handle) return; if (handle.tcpServer) await new Promise(resolve => handle.tcpServer.close(resolve)); if (handle.udpSocket) { for (const session of handle.udpSessions.values()) { clearTimeout(session.timer); session.outbound.close(); } handle.udpSocket.close(); } activeStreams.delete(id); } async function restartStream(stream) { await stopStream(stream.id); if (stream.enabled !== false) await startStream(stream); } async function checkStream(stream) { if (stream.enabled === false) { const result = { status: "disabled", checkedAt: new Date().toISOString(), history: [] }; upstreamHealth.set(stream.id, result); return result; } if (stream.healthEnabled === false) { const result = { status: "unmonitored", checkedAt: null, history: [] }; upstreamHealth.set(stream.id, result); return result; } const started = performance.now(); const [targetHost, targetPortRaw] = String(stream.target || "").split(":"); const healthy = await tcpProbeHost(targetHost, Number(targetPortRaw), 4000); const responseMs = Math.round(performance.now() - started); const result = { status: healthy ? "healthy" : "unhealthy", responseMs, checkedAt: new Date().toISOString(), error: healthy ? null : `Could not open a TCP connection to ${stream.target}` }; const previous = upstreamHealth.get(stream.id); result.history = [{ status: result.status, responseMs, checkedAt: result.checkedAt }, ...(previous?.history || [])].slice(0, 7); upstreamHealth.set(stream.id, result); return result; } // --- Upload handling (hosted site ZIP install) ------------------------------------------------------ function validatePort(port, exceptId) { if (!Number.isInteger(port) || port < minPort || port > maxPort) return `Port must be between ${minPort} and ${maxPort}.`; if (sites.some(site => site.port === port && site.id !== exceptId)) return "That port is already assigned."; return null; } async function installUpload(site, file) { const destination = path.join(sitesDir, site.id); const staging = `${destination}.staging-${Date.now()}`; await fsp.mkdir(staging, { recursive: true }); try { if (file.originalname.toLowerCase().endsWith(".zip")) { const zip = new AdmZip(file.path); for (const entry of zip.getEntries()) { const normalized = path.normalize(entry.entryName).replace(/^(\.\.(\/|\\|$))+/, ""); const target = path.resolve(staging, normalized); if (!target.startsWith(`${path.resolve(staging)}${path.sep}`) && target !== path.resolve(staging)) throw new Error("Unsafe path in ZIP file."); if (entry.isDirectory) await fsp.mkdir(target, { recursive: true }); else { await fsp.mkdir(path.dirname(target), { recursive: true }); await fsp.writeFile(target, entry.getData()); } } const children = await fsp.readdir(staging, { withFileTypes: true }); if (children.length === 1 && children[0].isDirectory()) { const nested = path.join(staging, children[0].name); const nestedChildren = await fsp.readdir(nested); for (const child of nestedChildren) await fsp.rename(path.join(nested, child), path.join(staging, child)); await fsp.rmdir(nested); } } else { await fsp.copyFile(file.path, path.join(staging, "index.html")); } await fsp.access(path.join(staging, "index.html")); await fsp.rm(destination, { recursive: true, force: true }); await fsp.rename(staging, destination); } finally { await fsp.rm(file.path, { force: true }); await fsp.rm(staging, { recursive: true, force: true }); } } const portableCollections = { "sites.json": () => sites, "proxies.json": () => proxies, "redirects.json": () => redirects, "streams.json": () => streams, "access-lists.json": () => accessLists, "users.json": () => users, "groups.json": () => groups, "settings.json": () => settings }; // --- Backups: create / open / list / restore, including encryption ----------------------------------- async function protectBackup(buffer, password) { if (!password) return buffer; const salt = crypto.randomBytes(16), iv = crypto.randomBytes(12), key = await scryptAsync(password, salt, 32), cipher = crypto.createCipheriv("aes-256-gcm", key, iv), encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); return Buffer.concat([Buffer.from("SGBK1"), salt, iv, cipher.getAuthTag(), encrypted]); } async function openBackup(filename, password = "") { let buffer = await fsp.readFile(filename), encrypted = false; if (buffer.subarray(0, 5).toString() === "SGBK1") { encrypted = true; if (!password) throw Object.assign(new Error("This backup is encrypted. Enter its password."), { status: 400 }); try { const salt = buffer.subarray(5, 21), iv = buffer.subarray(21, 33), tag = buffer.subarray(33, 49), key = await scryptAsync(password, salt, 32), decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); decipher.setAuthTag(tag); buffer = Buffer.concat([decipher.update(buffer.subarray(49)), decipher.final()]); } catch { throw Object.assign(new Error("The backup password is incorrect or the file is damaged."), { status: 400 }); } } return { zip: new AdmZip(buffer), encrypted }; } async function createBackup(type = "configuration", includeLogs = false, prefix = "site-gateway-backup", password = "") { const safeType = type === "complete" ? "complete" : "configuration"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const filename = `${prefix}-${stamp}.sgbackup`; const destination = path.join(backupsDir, filename); const zip = new AdmZip(); const manifest = { format: 2, product: "Site Gateway", appVersion, database: "sqlite", schemaVersion: 1, instanceId: LOCAL_INSTANCE_ID, createdAt: new Date().toISOString(), type: safeType, includeLogs: Boolean(includeLogs), encrypted: Boolean(password), files: [] }; const databaseSnapshot = path.join(uploadDir, `database-${crypto.randomUUID()}.sqlite`); storage.backupTo(databaseSnapshot); zip.addLocalFile(databaseSnapshot, "database", "site-gateway.sqlite"); await fsp.rm(databaseSnapshot, { force: true }); for (const [name, getter] of Object.entries(portableCollections)) zip.addFile(`portable-json/${name}`, Buffer.from(JSON.stringify(getter(), null, 2))); if (safeType === "complete") { for (const [directory, archivePath] of [[sitesDir, "sites"], [iconsDir, "icons"], [defaultSiteDir, "default-site"], [certificatesRoot, "certificates"]]) { if (fs.existsSync(directory)) zip.addLocalFolder(directory, archivePath); } } if (includeLogs && fs.existsSync(logsDir)) zip.addLocalFolder(logsDir, "logs"); manifest.files = zip.getEntries().filter(entry => !entry.isDirectory).map(entry => entry.entryName); manifest.checksums = Object.fromEntries(zip.getEntries().filter(entry => !entry.isDirectory).map(entry => [entry.entryName, crypto.createHash("sha256").update(entry.getData()).digest("hex")])); zip.addFile("manifest.json", Buffer.from(JSON.stringify(manifest, null, 2))); await fsp.writeFile(destination, await protectBackup(zip.toBuffer(), password)); recordActivity(`${safeType === "complete" ? "Complete" : "Configuration"} backup created.`); return { filename, path: destination, ...manifest, size: (await fsp.stat(destination)).size }; } async function listBackups() { const names = (await fsp.readdir(backupsDir)).filter(name => name.endsWith(".sgbackup")); return Promise.all(names.map(async filename => { const stat = await fsp.stat(path.join(backupsDir, filename)); let manifest = {}; const header = Buffer.alloc(5); const handle = await fsp.open(path.join(backupsDir, filename), "r"); await handle.read(header, 0, 5, 0); await handle.close(); const encrypted = header.toString() === "SGBK1"; if (!encrypted) try { manifest = JSON.parse(new AdmZip(path.join(backupsDir, filename)).readAsText("manifest.json")); } catch { /* Report unreadable archive in UI. */ } return { filename, size: stat.size, createdAt: manifest.createdAt || stat.mtime.toISOString(), type: encrypted ? "encrypted" : manifest.type || "unknown", appVersion: encrypted ? "protected" : manifest.appVersion || "unknown", valid: encrypted || Boolean(manifest.format), encrypted }; })).then(items => items.sort((a, b) => b.createdAt.localeCompare(a.createdAt))); } async function restoreBackup(filename, password = "", createSafetyBackup = true) { const source = path.resolve(backupsDir, filename); if (!source.startsWith(`${backupsDir}${path.sep}`) || !filename.endsWith(".sgbackup")) throw Object.assign(new Error("Invalid backup selection."), { status: 400 }); const { zip } = await openBackup(source, password); const manifest = JSON.parse(zip.readAsText("manifest.json") || "null"); if (!manifest || manifest.product !== "Site Gateway" || ![1,2].includes(manifest.format)) throw Object.assign(new Error("This is not a supported Site Gateway backup."), { status: 400 }); for (const [name, expected] of Object.entries(manifest.checksums || {})) { const entry = zip.getEntry(name); if (!entry || crypto.createHash("sha256").update(entry.getData()).digest("hex") !== expected) throw Object.assign(new Error(`Backup integrity check failed for ${name}.`), { status: 400 }); } const safetyBackup = createSafetyBackup ? await createBackup("complete", true, "pre-restore") : null; const staging = path.join(uploadDir, `restore-${crypto.randomUUID()}`); await fsp.mkdir(staging, { recursive: true }); try { for (const entry of zip.getEntries()) { if (entry.entryName === "manifest.json") continue; const target = path.resolve(staging, entry.entryName); if (!target.startsWith(`${staging}${path.sep}`)) throw Object.assign(new Error("Unsafe path in backup."), { status: 400 }); if (entry.isDirectory) await fsp.mkdir(target, { recursive: true }); else { await fsp.mkdir(path.dirname(target), { recursive: true }); await fsp.writeFile(target, entry.getData()); } } const restoredDatabase = path.join(staging, "database", "site-gateway.sqlite"); if (fs.existsSync(restoredDatabase)) { const candidate = new (await import("node:sqlite")).DatabaseSync(restoredDatabase, { readOnly: true }); const check = candidate.prepare("PRAGMA integrity_check").get(); candidate.close(); if (Object.values(check)[0] !== "ok") throw Object.assign(new Error("The restored SQLite database failed its integrity check."), { status: 400 }); const activeDatabasePath = storage.databasePath; storage.close(); await Promise.all([fsp.rm(`${activeDatabasePath}-wal`, { force: true }), fsp.rm(`${activeDatabasePath}-shm`, { force: true })]); await fsp.copyFile(restoredDatabase, activeDatabasePath); storage = await openStorage(dataDir, backupsDir); } else { const legacyRoot = fs.existsSync(path.join(staging, "portable-json")) ? path.join(staging, "portable-json") : fs.existsSync(path.join(staging, "legacy-json")) ? path.join(staging, "legacy-json") : path.join(staging, "config"); storage.saveCollection("sites", []); storage.saveCollection("proxies", []); storage.saveCollection("redirects", []); storage.saveCollection("streams", []); for (const [name, kind] of Object.entries({ "access-lists.json":"access_lists", "sites.json":"sites", "proxies.json":"proxies", "redirects.json":"redirects", "users.json":"users" })) { const candidate = path.join(legacyRoot, name); if (fs.existsSync(candidate)) storage.saveCollection(kind, JSON.parse(await fsp.readFile(candidate, "utf8"))); } const settingsCandidate = path.join(legacyRoot, "settings.json"); if (fs.existsSync(settingsCandidate)) storage.saveSettings(JSON.parse(await fsp.readFile(settingsCandidate, "utf8"))); } if (manifest.type === "complete") for (const name of ["sites", "icons", "default-site", "certificates"]) { const candidate = path.join(staging, name); if (!fs.existsSync(candidate)) continue; const destination = path.join(dataDir, name); await fsp.rm(destination, { recursive: true, force: true }); await fsp.cp(candidate, destination, { recursive: true }); } if (manifest.type === "complete" && fs.existsSync(path.join(staging, "custom-certificates"))) { await fsp.mkdir(customCertificatesDir, { recursive: true }); await fsp.cp(path.join(staging, "custom-certificates"), customCertificatesDir, { recursive: true }); } await Promise.all([...activeServers.keys()].map(stopSite)); await Promise.all([...activeStreams.keys()].map(stopStream)); sites = []; proxies = []; users = []; redirects = []; streams = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites(); if (manifest.type === "complete") for (const site of sites) { const contentRoot = path.join(sitesDir, site.id); if (!fs.existsSync(path.join(contentRoot, "index.html"))) throw new Error(`Restored hosted site “${site.name || site.id}” is missing index.html.`); } for (const site of sites.filter(item => item.enabled)) await startSite(site); for (const stream of streams.filter(item => item.enabled !== false)) { try { await startStream(stream); } catch (error) { console.error(`Could not start streaming host “${stream.name}”:`, error.message); } } await syncCaddy(); recordActivity(`Backup ${filename} restored.`); } catch (error) { if (safetyBackup) { try { await restoreBackup(safetyBackup.filename, "", false); recordActivity(`Restore of ${filename} failed; the pre-restore state was recovered.`, "error"); } catch (rollbackError) { error.message = `${error.message} Automatic rollback also failed: ${rollbackError.message}`; } } throw error; } finally { await fsp.rm(staging, { recursive: true, force: true }); } return manifest; } await loadSites(); try { const result = await execFileAsync("caddy", ["version"]); caddyVersion = result.stdout.trim().split(/\s+/)[0] || "Unknown"; } catch (error) { console.warn("Could not detect Caddy version:", error.message); } for (const site of sites.filter(item => item.enabled)) { try { await startSite(site); } catch (error) { console.error(`Could not start ${site.name}:`, error.message); } } for (const stream of streams.filter(item => item.enabled !== false)) { try { await startStream(stream); } catch (error) { console.error(`Could not start streaming host “${stream.name}”:`, error.message); } } for (let attempt = 0; attempt < 10; attempt++) { try { await syncCaddy(); break; } catch (error) { if (attempt === 9) console.error(error.message); else await new Promise(resolve => setTimeout(resolve, 500)); } } const app = express(); const upload = multer({ dest: uploadDir, limits: { fileSize: 250 * 1024 * 1024, files: 1 } }); const certificateUpload = multer({ dest: uploadDir, limits: { fileSize: 5 * 1024 * 1024, files: 2 } }); const iconUpload = multer({ dest: uploadDir, limits: { fileSize: 2 * 1024 * 1024, files: 1 } }); app.disable("x-powered-by"); // ============================================================================================ // HTTP layer: Express app setup, auth middleware, and every /api/* route. // Routes below are grouped by area; see the section comments for each group. // ============================================================================================ app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.get(["/", "/index.html"], (req, res) => { const html = fs.readFileSync(path.join(publicDir, "index.html"), "utf8") .replace(/\/(app|features)\.js\?v=[^"']+/g, `/$1.js?v=${appVersion}`) .replace(/\/styles\.css\?v=[^"']+/g, `/styles.css?v=${appVersion}`); res.type("html").send(html); }); app.use(express.static(publicDir)); app.use("/site-icons", express.static(iconsDir, { immutable: true, maxAge: "30d", setHeaders: res => res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") })); // --- Session / login / MFA login / logout ------------------------------------------------------------ app.get("/api/session", (req, res) => { const user = sessionUser(req); res.json({ authenticated: Boolean(user), setupRequired: Boolean(user?.setupRequired), installationSetupPending: users.some(item => item.setupRequired), user: user ? publicUser(user) : null, username: user?.username || null }); }); function checkLoginRateLimit(key) { const attempt = loginAttempts.get(key) || { count: 0, resetAt: Date.now() + 15 * 60 * 1000 }; if (attempt.resetAt <= Date.now()) { attempt.count = 0; attempt.resetAt = Date.now() + 15 * 60 * 1000; } return attempt; } function issueSessionCookie(res, user) { if (!user.sessionVersion) user.sessionVersion = crypto.randomBytes(16).toString("hex"); const expires = String(Date.now() + 12 * 60 * 60 * 1000); const value = `${user.id}.${expires}.${user.sessionVersion}`; res.setHeader("Set-Cookie", [`webserver_session=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`, "pending_mfa=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"]); } function issuePendingMfaCookie(res, user) { const expires = String(Date.now() + 5 * 60 * 1000); const value = `${user.id}.${expires}.mfa`; res.setHeader("Set-Cookie", `pending_mfa=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=300`); } function pendingMfaUser(req) { const token = cookieMap(req.headers.cookie).pending_mfa; if (!token) return null; const [userId, expires, marker, signature] = token.split("."); const user = users.find(item => item.id === userId && item.status === "active"); if (!user || marker !== "mfa" || !expires || Number(expires) <= Date.now() || !safeEqual(signature || "", sign(`${userId}.${expires}.${marker}`))) return null; return user; } app.post("/api/login", async (req, res, next) => { try { const key = req.ip || req.socket.remoteAddress || "unknown"; const attempt = checkLoginRateLimit(key); if (attempt.count >= 8) { recordActivity(`Security: sign-in rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in attempts. Try again in 15 minutes." }); } const username = String(req.body.username || "").trim().toLowerCase(); const user = users.find(item => item.username === username); if (!user || user.status !== "active" || !await passwordMatches(req.body.password || "", user.password)) { attempt.count += 1; loginAttempts.set(key, attempt); recordActivity(`Security: failed sign-in attempt for ${username || "unknown user"}.`, "error"); return res.status(401).json({ error: "Incorrect username or password." }); } loginAttempts.delete(key); if (user.mfaEnabled) { issuePendingMfaCookie(res, user); return res.json({ mfaRequired: true }); } user.lastLoginAt = new Date().toISOString(); user.updatedAt = user.lastLoginAt; await saveUsers(); issueSessionCookie(res, user); res.json({ ok: true, user: publicUser(user) }); } catch (error) { next(error); } }); app.post("/api/login/mfa", async (req, res, next) => { try { const key = req.ip || req.socket.remoteAddress || "unknown"; const attempt = checkLoginRateLimit(key); if (attempt.count >= 8) { recordActivity(`Security: sign-in rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in attempts. Try again in 15 minutes." }); } const user = pendingMfaUser(req); if (!user || !user.mfaEnabled) { attempt.count += 1; loginAttempts.set(key, attempt); return res.status(401).json({ error: "Your sign-in session expired. Please sign in again." }); } const code = String(req.body.code || "").trim(); let matchedRecoveryCode = null; const isValidTotp = verifyTotp(user.mfaSecret, code); if (!isValidTotp) { for (const entry of user.mfaRecoveryCodes || []) { if (entry.usedAt) continue; if (await passwordMatches(code, entry.hash)) { matchedRecoveryCode = entry; break; } } } if (!isValidTotp && !matchedRecoveryCode) { attempt.count += 1; loginAttempts.set(key, attempt); recordActivity(`Security: failed two-factor code for “${user.username}”.`, "error"); return res.status(401).json({ error: "That code didn't match. Try again." }); } loginAttempts.delete(key); if (matchedRecoveryCode) { matchedRecoveryCode.usedAt = new Date().toISOString(); recordActivity(`User “${user.username}” signed in using a two-factor recovery code.`); } user.lastLoginAt = new Date().toISOString(); user.updatedAt = user.lastLoginAt; await saveUsers(); issueSessionCookie(res, user); res.json({ ok: true, user: publicUser(user) }); } catch (error) { next(error); } }); app.post("/api/logout", (req, res) => { res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); res.json({ ok: true }); }); // --- Public access-check endpoint used by Caddy's forward_auth for Access Lists ----------------------- function accessSession(req, listId) { const token = cookieMap(req.headers.cookie).site_gateway_access; if (!token) return null; const [storedList, username, expires, signature] = token.split("."); if (storedList !== listId || Number(expires) <= Date.now() || !safeEqual(signature || "", sign(`${storedList}.${username}.${expires}`))) return null; return username; } function accessUserAllowed(list, username) { if (list.credentials?.some(item => item.username === username)) return true; return (list.groups || []).some(groupId => { const group = groups.find(item => item.id === groupId && item.enabled !== false); return Boolean(group?.members?.some(userId => users.some(user => user.id === userId && user.status === "active" && user.username === username))); }); } app.get("/api/access-check", (req, res) => { const listId = String(req.query.list || ""), list = accessLists.find(item => item.id === listId && item.enabled !== false); if (!list || !list.credentials?.length) return res.status(204).end(); const username = accessSession(req, listId); if (username && accessUserAllowed(list, username)) { res.setHeader("X-Site-Gateway-User", username); return res.status(204).end(); } const original = String(req.headers["x-forwarded-uri"] || "/"); const safeReturn = original.startsWith("/") && !original.startsWith("//") ? original : "/"; res.redirect(302, `/_site-gateway/login?list=${encodeURIComponent(listId)}&return=${encodeURIComponent(safeReturn)}`); }); // --- Themed login page served for Access-List-protected routes ------------------------------------------ app.get("/_site-gateway/login", (req, res) => { const listId = String(req.query.list || ""), list = accessLists.find(item => item.id === listId && item.enabled !== false); if (!list) return res.status(404).send("Access policy not found."); const safeReturn = String(req.query.return || "/").startsWith("/") ? String(req.query.return || "/") : "/"; res.type("html").send(`Sign in · Site Gateway
SG
Protected by Site Gateway

Sign in to continue

This service uses the ${String(list.name).replace(/[<>]/g, "")} access policy.

${req.query.error ? '

That username or password was not accepted.

' : ""}
`); }); app.post("/_site-gateway/login", async (req, res, next) => { try { const listId = String(req.body.list || ""), list = accessLists.find(item => item.id === listId && item.enabled !== false), username = String(req.body.username || "").trim(); const credential = list?.credentials?.find(item => item.username === username) || ((list && accessUserAllowed(list, username)) ? users.find(user => user.username === username && user.status === "active") : null); const safeReturn = String(req.body.return || "/").startsWith("/") && !String(req.body.return).startsWith("//") ? String(req.body.return) : "/"; if (!credential?.password || !await passwordMatches(req.body.password || "", credential.password)) return res.redirect(303, `/_site-gateway/login?list=${encodeURIComponent(listId)}&return=${encodeURIComponent(safeReturn)}&error=1`); const expires = String(Date.now() + 12 * 60 * 60 * 1000), value = `${listId}.${username}.${expires}`; const secure = String(req.headers["x-forwarded-proto"] || "").includes("https") ? "; Secure" : ""; res.setHeader("Set-Cookie", `site_gateway_access=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200${secure}`); res.redirect(303, safeReturn); } catch (error) { next(error); } }); // --- First-run admin setup --------------------------------------------------------------------------------- app.use("/api", (req, res, next) => { const user = sessionUser(req); if (!user) return res.status(401).json({ error: "Please sign in." }); req.user = user; next(); }); app.post("/api/setup/admin", async (req, res, next) => { try { if (!req.user.setupRequired || req.user.source !== "bootstrap" || req.user.role !== "administrator") return res.status(409).json({ error: "Initial administrator setup has already been completed." }); const username = String(req.body.username || "").trim().toLowerCase(); const displayName = String(req.body.displayName || "").trim(); const password = String(req.body.password || ""); const confirmation = String(req.body.confirmPassword || ""); if (!/^[a-z0-9][a-z0-9._-]{2,63}$/.test(username)) return res.status(400).json({ error: "Username must be 3–64 characters using letters, numbers, periods, hyphens, or underscores." }); if (users.some(user => user.id !== req.user.id && user.username === username)) return res.status(409).json({ error: "That username already exists." }); if (!displayName || displayName.length > 80) return res.status(400).json({ error: "Display name is required and must be 80 characters or fewer." }); if (password.length < 8) return res.status(400).json({ error: "Password must contain at least 8 characters." }); if (!safeEqual(password, confirmation)) return res.status(400).json({ error: "The passwords do not match." }); req.user.username = username; req.user.displayName = displayName; req.user.password = await passwordRecord(password); req.user.source = "local"; req.user.setupRequired = false; req.user.sessionVersion = crypto.randomBytes(16).toString("hex"); req.user.updatedAt = new Date().toISOString(); await saveUsers(); recordActivity(`Initial administrator setup completed for “${username}”.`); res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); res.json({ ok: true }); } catch (error) { next(error); } }); // --- Everything below requires an authenticated session (auth middleware applied above) -------------------- app.use("/api", (req, res, next) => { currentAuditActor = req.user?.id || null; return req.user.setupRequired ? res.status(428).json({ error: "Complete the initial administrator setup before continuing." }) : next(); }); app.use("/api", (req, res, next) => { if (req.path.startsWith("/account/")) return next(); if (req.method === "GET" || req.user.role === "administrator") return next(); const operational = /^\/(sites|proxies|redirects|streams|access-lists)(\/|$)/.test(req.path); if (req.user.role === "standard" && operational) return next(); return res.status(403).json({ error: "Administrator access is required for this action." }); }); // --- Account: password change & MFA setup/confirm/disable/recovery-codes ----------------------------------- app.post("/api/account/password", async (req, res, next) => { try { const currentPassword = String(req.body.currentPassword || ""); const newPassword = String(req.body.newPassword || ""); if (!await passwordMatches(currentPassword, req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." }); if (newPassword.length < 8) return res.status(400).json({ error: "New password must contain at least 8 characters." }); req.user.password = await passwordRecord(newPassword); req.user.updatedAt = new Date().toISOString(); await saveUsers(); recordActivity(`User “${req.user.username}” changed their password.`); issueSessionCookie(res, req.user); res.json({ ok: true }); } catch (error) { next(error); } }); app.post("/api/account/mfa/setup", async (req, res, next) => { try { if (req.user.mfaEnabled) return res.status(409).json({ error: "Two-factor authentication is already enabled. Disable it first to start over." }); const secret = generateTotpSecret(); req.user.mfaPendingSecret = secret; await saveUsers(); const uri = otpauthUri({ secret, username: req.user.username }); const qrSvg = await QRCode.toString(uri, { type: "svg", margin: 1, width: 220 }); res.json({ secret, otpauthUri: uri, qrSvg }); } catch (error) { next(error); } }); app.post("/api/account/mfa/confirm", async (req, res, next) => { try { if (!req.user.mfaPendingSecret) return res.status(400).json({ error: "Start two-factor setup before confirming a code." }); if (!verifyTotp(req.user.mfaPendingSecret, req.body.code)) return res.status(400).json({ error: "That code didn't match. Try again." }); req.user.mfaSecret = req.user.mfaPendingSecret; req.user.mfaPendingSecret = null; req.user.mfaEnabled = true; const codes = generateRecoveryCodes(10); req.user.mfaRecoveryCodes = await Promise.all(codes.map(async code => ({ hash: await passwordRecord(code), usedAt: null }))); req.user.updatedAt = new Date().toISOString(); await saveUsers(); recordActivity(`User “${req.user.username}” enabled two-factor authentication.`); res.json({ ok: true, recoveryCodes: codes }); } catch (error) { next(error); } }); app.post("/api/account/mfa/disable", async (req, res, next) => { try { if (!await passwordMatches(req.body.password || "", req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." }); req.user.mfaEnabled = false; req.user.mfaSecret = null; req.user.mfaPendingSecret = null; req.user.mfaRecoveryCodes = []; req.user.updatedAt = new Date().toISOString(); await saveUsers(); recordActivity(`User “${req.user.username}” disabled two-factor authentication.`, "warning"); res.json({ ok: true }); } catch (error) { next(error); } }); app.post("/api/account/mfa/recovery-codes", async (req, res, next) => { try { if (!req.user.mfaEnabled) return res.status(400).json({ error: "Two-factor authentication isn't enabled." }); if (!await passwordMatches(req.body.password || "", req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." }); const codes = generateRecoveryCodes(10); req.user.mfaRecoveryCodes = await Promise.all(codes.map(async code => ({ hash: await passwordRecord(code), usedAt: null }))); req.user.updatedAt = new Date().toISOString(); await saveUsers(); recordActivity(`User “${req.user.username}” regenerated two-factor recovery codes.`); res.json({ ok: true, recoveryCodes: codes }); } catch (error) { next(error); } }); // --- Config, Users, Audit log, Groups, Access List <-> Group assignment -------------------------------------- app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError } })); app.get("/api/users", (req, res) => req.user.role === "administrator" ? res.json(users.map(publicUser)) : res.status(403).json({ error: "Administrator access is required." })); app.get("/api/audit", (req, res) => req.user.role === "administrator" ? res.json(storage.listAudit({ user: req.query.user, action: req.query.action, status: req.query.status }).map(item => ({ ...item, actor: users.find(user => user.id === item.actor_id)?.username || "System" }))) : res.status(403).json({ error: "Administrator access is required." })); app.post("/api/users", async (req, res, next) => { try { const username = String(req.body.username || "").trim().toLowerCase(); const displayName = String(req.body.displayName || "").trim(); const password = String(req.body.password || ""); const role = ["administrator", "standard", "viewer"].includes(req.body.role) ? req.body.role : "standard"; if (!/^[a-z0-9][a-z0-9._-]{2,63}$/.test(username)) return res.status(400).json({ error: "Username must be 3–64 characters using letters, numbers, periods, hyphens, or underscores." }); if (users.some(user => user.username === username)) return res.status(409).json({ error: "That username already exists." }); if (!displayName || displayName.length > 80) return res.status(400).json({ error: "Display name is required and must be 80 characters or fewer." }); if (password.length < 8) return res.status(400).json({ error: "Password must contain at least 8 characters." }); const now = new Date().toISOString(); const user = { id: crypto.randomUUID(), username, displayName, role, status: "active", password: await passwordRecord(password), source: "local", createdAt: now, updatedAt: now, lastLoginAt: null }; users.push(user); await saveUsers(); recordActivity(`User “${user.username}” created as ${role === "administrator" ? "Administrator" : role === "viewer" ? "Viewer" : "Standard User"}.`); res.status(201).json(publicUser(user)); } catch (error) { next(error); } }); app.patch("/api/users/:id", async (req, res, next) => { try { const user = users.find(item => item.id === req.params.id); if (!user) return res.status(404).json({ error: "User not found." }); const nextRole = req.body.role === undefined ? user.role : ["administrator", "standard", "viewer"].includes(req.body.role) ? req.body.role : null; if (!nextRole) return res.status(400).json({ error: "Invalid user role." }); const nextStatus = req.body.status === undefined ? user.status : ["active", "disabled", "archived"].includes(req.body.status) ? req.body.status : null; if (!nextStatus) return res.status(400).json({ error: "Invalid user status." }); const removesActiveAdmin = user.role === "administrator" && user.status === "active" && (nextRole !== "administrator" || nextStatus !== "active"); if (removesActiveAdmin && activeAdministrators().length === 1) return res.status(400).json({ error: "At least one active Administrator is required." }); if (user.id === req.user.id && nextStatus !== "active") return res.status(400).json({ error: "You cannot disable or archive your own account." }); if (user.id === req.user.id && nextRole !== user.role) return res.status(400).json({ error: "Another Administrator must change your role." }); user.role = nextRole; user.status = nextStatus; if (req.body.displayName !== undefined) { const displayName = String(req.body.displayName).trim(); if (!displayName || displayName.length > 80) return res.status(400).json({ error: "Display name is required and must be 80 characters or fewer." }); user.displayName = displayName; } if (req.body.password !== undefined) { const password = String(req.body.password); if (password.length < 8) return res.status(400).json({ error: "Password must contain at least 8 characters." }); user.password = await passwordRecord(password); } user.updatedAt = new Date().toISOString(); await saveUsers(); recordActivity(`User “${user.username}” updated · ${user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User"} · ${user.status}.`); res.json(publicUser(user)); } catch (error) { next(error); } }); app.delete("/api/users/:id", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (req.params.id === req.user.id) return res.status(400).json({ error: "You cannot delete your own account." }); const index = users.findIndex(user => user.id === req.params.id); if (index < 0) return res.status(404).json({ error: "User not found." }); const [removed] = users.splice(index, 1); groups.forEach(group => { group.members = (group.members || []).filter(id => id !== removed.id); }); await Promise.all([saveUsers(), saveGroups()]); recordActivity(`User “${removed.username}” permanently deleted.`); res.status(204).end(); } catch (error) { next(error); } }); app.post("/api/users/:id/mfa/disable", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const user = users.find(item => item.id === req.params.id); if (!user) return res.status(404).json({ error: "User not found." }); if (!user.mfaEnabled) return res.status(400).json({ error: "Two-factor authentication isn\u2019t enabled for this user." }); user.mfaEnabled = false; user.mfaSecret = null; user.mfaPendingSecret = null; user.mfaRecoveryCodes = []; user.updatedAt = new Date().toISOString(); await saveUsers(); recordActivity(`Administrator “${req.user.username}” disabled two-factor authentication for “${user.username}”.`, "warning"); res.json({ ok: true }); } catch (error) { next(error); } }); app.get("/api/sites", (req, res) => res.json(sites.map(publicSite))); app.get("/api/proxies", (req, res) => res.json(proxies.map(proxy => publicProxy(proxy, req.user.role === "administrator")))); app.get("/api/redirects", (req, res) => res.json(redirects)); app.get("/api/access-lists", (req, res) => res.json(accessLists.map(({ credentials, ...item }) => ({ ...item, credentials: (credentials || []).map(({ username }) => ({ username })), groups: item.groups || [] })))); app.get("/api/groups", (req, res) => req.user.role === "administrator" ? res.json(groups.map(group => ({ ...group, memberIds: [...(group.members || [])], members: (group.members || []).map(id => users.find(user => user.id === id)?.username).filter(Boolean) }))) : res.status(403).json({ error: "Administrator access is required." })); app.post("/api/access-lists/:id/groups", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const list = accessLists.find(value => value.id === req.params.id); if (!list) return res.status(404).json({ error: "Access List not found." }); list.groups = Array.isArray(req.body.groups) ? [...new Set(req.body.groups)].filter(id => groups.some(group => group.id === id && group.enabled !== false)) : []; await saveAccessLists(); recordActivity("Groups updated for Access List “" + list.name + "”."); res.json({ groups: list.groups }); } catch (error) { next(error); } }); app.post("/api/groups", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const name = String(req.body.name || "").trim().slice(0, 80); if (!name) return res.status(400).json({ error: "Group name is required." }); if (groups.some(group => group.name.toLowerCase() === name.toLowerCase())) return res.status(409).json({ error: "That group already exists." }); const group = { id: "group-" + crypto.randomBytes(4).toString("hex"), name, enabled: true, members: [], createdAt: new Date().toISOString() }; groups.push(group); await saveGroups(); recordActivity("Group “" + name + "” created."); res.status(201).json(group); } catch (error) { next(error); } }); app.patch("/api/groups/:id", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const group = groups.find(value => value.id === req.params.id); if (!group) return res.status(404).json({ error: "Group not found." }); if (req.body.name !== undefined) { const name = String(req.body.name || "").trim().slice(0, 80); if (!name) return res.status(400).json({ error: "Group name is required." }); group.name = name; } if (req.body.enabled !== undefined) group.enabled = Boolean(req.body.enabled); if (Array.isArray(req.body.members)) group.members = [...new Set(req.body.members)].filter(id => users.some(user => user.id === id)); await saveGroups(); recordActivity("Group “" + group.name + "” updated."); res.json(group); } catch (error) { next(error); } }); app.delete("/api/groups/:id", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const index = groups.findIndex(value => value.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Group not found." }); const [group] = groups.splice(index, 1); await saveGroups(); recordActivity("Group “" + group.name + "” deleted."); res.status(204).end(); } catch (error) { next(error); } }); // --- Settings, dashboard, certificates, health checks, domain readiness --------------------------------------- app.get("/api/settings", (req, res) => req.user.role === "administrator" ? res.json({ ...settings, backupDirectory: backupsDir }) : res.status(403).json({ error: "Administrator access is required." })); app.post("/api/settings/verify-admin", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(422).json({ error:"Administrator username or password is incorrect." }); res.json({ ok:true }); } catch (error) { next(error); } }); app.post("/api/settings/verify-username", (req, res) => { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); const username = String(req.body.username || "").trim().toLowerCase(); res.json({ valid: Boolean(username && username === String(req.user.username || "").toLowerCase()) }); }); app.get("/api/dashboard", async (req, res, next) => { try { res.json(await dashboardSnapshot()); } catch (error) { next(error); } }); app.get("/api/certificates", async (req, res, next) => { try { res.json(await certificateInventory()); } catch (error) { next(error); } }); app.post("/api/health/check", async (req, res, next) => { try { await checkAllProxies(); res.json({ dashboard: await dashboardSnapshot(), certificates: await certificateInventory(), readiness: await domainReadiness() }); } catch (error) { next(error); } }); app.get("/api/readiness", async (req, res, next) => { try { res.json({ checkedAt: new Date().toISOString(), routes: await domainReadiness() }); } catch (error) { next(error); } }); // GET /api/support-report -- generates the downloadable diagnostics report (gateway // health, storage integrity, every route's config, certificate status, domain // readiness, and recent activity) used for troubleshooting. app.get("/api/support-report", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const certificateReport = await certificateInventory(); certificateReport.latestError = certificateReport.latestError ? { present:true, at:certificateReport.latestError.at } : null; const report = { product: "Site Gateway", generatedAt: new Date().toISOString(), version: appVersion, caddyVersion, nodeVersion: process.version, storage: { engine: "SQLite", integrity: storage.integrity() }, gateway: { healthy: !gatewayError, lastReload: lastGatewayReload }, routes: { hosted: sites.map(({ id,name,domain,tls,enabled,port }) => ({ id,name,domain,tls,enabled,port })), proxies: proxies.map(({ id,name,domain,tls,enabled,target,healthEnabled,healthExpected }) => ({ id,name,domain,tls,enabled,target,healthEnabled,healthExpected })), redirects: redirects.map(({ id,name,domain,tls,enabled,code }) => ({ id,name,domain,tls,enabled,code })) }, certificates: certificateReport, readiness: await domainReadiness(), recentEvents: recentActivity.slice(0,20).map(item => ({ at:item.at, status:item.status, message:item.status === "error" ? "Operational error recorded; review the protected in-app event log for details." : item.message })) }; res.setHeader("Content-Disposition", `attachment; filename="site-gateway-support-${new Date().toISOString().slice(0,10)}.json"`); res.type("json").send(JSON.stringify(report, null, 2)); } catch (error) { next(error); } }); // --- Upstream health, Logs, and Performance (request throughput/trend) endpoints -------------------------------- app.get("/api/upstreams", (req, res) => res.json(proxies.map(publicProxy))); app.post("/api/upstreams/check", async (req, res, next) => { try { res.json(await checkAllProxies()); } catch (error) { next(error); } }); app.get("/api/logs", async (req, res, next) => { try { const limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || 100, 1), 250); const host = normalizeDomain(req.query.host); res.json({ entries: storage.listAccessEvents(limit, host), hosts: [...new Set([...sites, ...proxies, ...redirects].flatMap(item => normalizeDomains(item.domain, item.domains)))].sort(), activity: recentActivity }); } catch (error) { next(error); } }); app.get("/api/performance", (req, res, next) => { try { const host = normalizeDomain(req.query.host); const hours = Math.min(Math.max(Number.parseInt(req.query.hours, 10) || 6, 1), 168); const bucketMinutes = hours > 24 ? 60 : 15; const breakdownByHost = new Map(); for (const row of storage.performanceErrorBreakdown()) { if (!breakdownByHost.has(row.host)) breakdownByHost.set(row.host, []); breakdownByHost.get(row.host).push({ status: row.status, count: row.count }); } res.json({ checkedAt: new Date().toISOString(), liveRequests: storage.performanceLiveCount(60), routes: storage.performanceRoutes().map(row => ({ host: row.host, hourRequests: row.hourRequests || 0, hourErrors: row.hourErrors || 0, hourAvgMs: row.hourAvgMs != null ? Math.round(row.hourAvgMs) : null, dayRequests: row.dayRequests || 0, dayErrors: row.dayErrors || 0, dayAvgMs: row.dayAvgMs != null ? Math.round(row.dayAvgMs) : null, errorBreakdown: (breakdownByHost.get(row.host) || []).slice(0, 3) })), trend: storage.performanceTrend(host, hours, bucketMinutes), hosts: [...new Set([...sites, ...proxies, ...redirects].flatMap(item => normalizeDomains(item.domain, item.domains)))].sort() }); } catch (error) { next(error); } }); // --- Icon search and per-entity icon upload/URL/removal ----------------------------------------------------------- app.get("/api/icons/search", async (req, res, next) => { try { const query = String(req.query.q || "").trim().toLowerCase().slice(0, 80); if (query.length < 2) return res.json([]); const catalog = await loadIconCatalog(); const results = Object.entries(catalog).map(([slug, metadata]) => { const aliases = metadata.aliases || []; const searchText = [slug, ...aliases, ...(metadata.categories || [])].join(" ").toLowerCase(); const score = slug === query ? 0 : slug.startsWith(query) ? 1 : aliases.some(alias => alias.toLowerCase() === query) ? 2 : searchText.includes(query) ? 3 : 99; return { slug, metadata, aliases, score }; }).filter(item => item.score < 99).sort((left, right) => left.score - right.score || left.slug.localeCompare(right.slug)).slice(0, 30) .map(({ slug, aliases }) => ({ slug, label: iconLabel(slug), aliases: aliases.slice(0, 3), preview: `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${slug}.svg` })); res.json(results); } catch (error) { next(error); } }); function entryLabel(item) { return item?.name || item?.displayName || item?.username || "item"; } app.put("/api/:kind/:id/icon", async (req, res, next) => { try { const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null; if (!collection) return res.status(404).json({ error: "Entry type not found." }); const item = collection.find(entry => entry.id === req.params.id); if (!item) return res.status(404).json({ error: "Entry not found." }); if (req.body.url !== undefined) { const url = String(req.body.url || "").trim(); if (!/^https:\/\//i.test(url) || url.length > 2048) return res.status(400).json({ error: "Icon URL must be a valid HTTPS URL under 2048 characters." }); item.iconSlug = null; item.icon = url; if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === streams) await saveStreams(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists(); recordActivity(`Icon URL updated for “${entryLabel(item)}”.`); return res.json(item); } const slug = String(req.body.slug || "").trim(); const icon = slug ? await cacheIcon(slug) : null; item.iconSlug = slug || null; item.icon = icon; if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === streams) await saveStreams(); else if (collection === groups) await saveGroups(); else await saveAccessLists(); recordActivity(`${slug ? "Icon updated" : "Icon reset"} for “${entryLabel(item)}”.`); res.json(item); } catch (error) { next(error); } }); app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next) => { try { const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null; if (!collection) return res.status(404).json({ error: "Entry type not found." }); const item = collection.find(entry => entry.id === req.params.id); if (!item) return res.status(404).json({ error: "Entry not found." }); if (!req.file) return res.status(400).json({ error: "Choose an icon image." }); if (!/^image\/(png|jpeg|webp|gif|svg\+xml)$/.test(req.file.mimetype)) return res.status(400).json({ error: "Use PNG, JPEG, WebP, GIF, or SVG." }); const extension = req.file.mimetype === "image/svg+xml" ? "svg" : req.file.mimetype.split("/")[1].replace("jpeg", "jpg"); const filename = `${req.params.kind}-${item.id}.${extension}`; await fsp.rename(req.file.path, path.join(iconsDir, filename)); item.iconSlug = null; item.icon = `/site-icons/${filename}`; if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === streams) await saveStreams(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists(); recordActivity(`Custom icon uploaded for “${entryLabel(item)}”.`); res.json(item); } catch (error) { next(error); } finally { if (req.file?.path) await fsp.rm(req.file.path, { force: true }).catch(() => {}); } }); // --- Hosted Sites: create / toggle / replace files / delete / edit -------------------------------------------------- app.post("/api/sites", upload.single("files"), async (req, res, next) => { try { const name = String(req.body.name || "").trim(); const port = Number.parseInt(req.body.port, 10); const domain = normalizeDomain(req.body.domain); const domains = normalizeDomains(domain, req.body.domains); const tls = ["http", "automatic", "internal"].includes(req.body.tls) ? req.body.tls : "automatic"; const hsts = req.body.hsts === "true"; const id = `${slugify(name) || "site"}-${crypto.randomBytes(3).toString("hex")}`; if (!name) throw Object.assign(new Error("Site name is required."), { status: 400 }); const portError = validatePort(port); if (portError) throw Object.assign(new Error(portError), { status: 400 }); const domainError = validateDomains(domains); if (domainError) throw Object.assign(new Error(domainError), { status: 400 }); if (!req.file) throw Object.assign(new Error("Choose a ZIP file or index.html."), { status: 400 }); const accessListId = String(req.body.accessListId || ""); const site = { id, name, port, domain, domains, accessListId, tls, hsts, enabled: true, createdAt: new Date().toISOString() }; applyAdvancedSettings(site, { accessListId, compression: req.body.compression, hstsSubdomains: req.body.hstsSubdomains === "true", customConfig: req.body.customConfig, healthEnabled: req.body.healthEnabled === true || (typeof req.body.healthEnabled === "string" && req.body.healthEnabled.toLowerCase() === "true"), healthPath: req.body.healthPath, healthMethod: req.body.healthMethod, healthExpected: req.body.healthExpected, healthTimeoutSeconds: req.body.healthTimeoutSeconds, healthRetries: req.body.healthRetries }); await installUpload(site, req.file); sites.push(site); try { await startSite(site); } catch (error) { sites = sites.filter(item => item.id !== site.id); await fsp.rm(path.join(sitesDir, site.id), { recursive: true, force: true }); throw Object.assign(new Error(`Could not start the hosted site on port ${port}: ${error.message}`), { status: 409 }); } await syncCaddy(); await saveSites(); recordActivity(`Hosted site “${site.name}” created.`); res.status(201).json(publicSite(site)); } catch (error) { if (req.file) await fsp.rm(req.file.path, { force: true }); next(error); } }); app.post("/api/sites/:id/toggle", async (req, res, next) => { try { const site = sites.find(item => item.id === req.params.id); if (!site) return res.status(404).json({ error: "Site not found." }); site.enabled = !site.enabled; await restartSite(site); await syncCaddy(); await saveSites(); recordActivity(`Hosted site “${site.name}” ${site.enabled ? "enabled" : "disabled"}.`); res.json(publicSite(site)); } catch (error) { next(error); } }); app.post("/api/sites/:id/files", upload.single("files"), async (req, res, next) => { try { const site = sites.find(item => item.id === req.params.id); if (!site) return res.status(404).json({ error: "Site not found." }); if (!req.file) return res.status(400).json({ error: "Choose a ZIP file or index.html." }); await installUpload(site, req.file); await syncCaddy(); recordActivity(`Files replaced for “${site.name}”.`); res.json(publicSite(site)); } catch (error) { next(error); } }); app.delete("/api/sites/:id", async (req, res, next) => { try { const index = sites.findIndex(item => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Site not found." }); const [site] = sites.splice(index, 1); await stopSite(site.id); await syncCaddy(); await fsp.rm(path.join(sitesDir, site.id), { recursive: true, force: true }); await pruneOrphanedCertificates(normalizeDomains(site.domain, site.domains)); await saveSites(); recordActivity(`Hosted site “${site.name}” deleted.`); res.status(204).end(); } catch (error) { next(error); } }); app.patch("/api/sites/:id", async (req, res, next) => { try { const site = sites.find(item => item.id === req.params.id); if (!site) return res.status(404).json({ error: "Site not found." }); const previousDomains = normalizeDomains(site.domain, site.domains); const domain = normalizeDomain(req.body.domain); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : site.domains); const domainError = validateDomains(domains, site.id); if (domainError) return res.status(400).json({ error: domainError }); site.domain = domain; site.domains = domains; if (req.body.name !== undefined) { const name = String(req.body.name).trim(); if (!name) return res.status(400).json({ error: "Site name is required." }); site.name = name; } site.tls = ["http", "automatic", "internal"].includes(req.body.tls) ? req.body.tls : "automatic"; site.hsts = req.body.hsts === true; applyAdvancedSettings(site, { accessListId: req.body.accessListId, compression: req.body.compression, hstsSubdomains: req.body.hstsSubdomains, requestHeaders: req.body.requestHeaders, responseHeaders: req.body.responseHeaders, customConfig: req.body.customConfig, healthEnabled: req.body.healthEnabled, healthPath: req.body.healthPath, healthMethod: req.body.healthMethod, healthExpected: req.body.healthExpected, healthTimeoutSeconds: req.body.healthTimeoutSeconds, healthRetries: req.body.healthRetries }); if (site.healthEnabled === false) upstreamHealth.set(site.id, { status: "unmonitored", checkedAt: null, history: [] }); else { upstreamHealth.set(site.id, { status: "pending", checkedAt: null, history: [] }); checkProxy({ ...site, target: `http://127.0.0.1:${site.port}` }).catch(error => console.warn("Hosted site health check failed:", error.message)); } await syncCaddy(); await pruneOrphanedCertificates(previousDomains); await saveSites(); recordActivity(`Gateway settings updated for “${site.name}”.`); res.json(publicSite(site)); } catch (error) { next(error); } }); // --- Proxy Hosts: create / edit / custom certificate upload / toggle / delete --------------------------------------- app.post("/api/proxies", async (req, res, next) => { try { const name = String(req.body.name || "").trim(); const domain = normalizeDomain(req.body.domain); const domains = normalizeDomains(domain, req.body.domains); if (!name) return res.status(400).json({ error: "Proxy name is required." }); const domainError = validateDomains(domains); if (domainError || !domain) return res.status(400).json({ error: domainError || "Domain is required." }); const proxy = { id: `${slugify(name) || "proxy"}-${crypto.randomBytes(3).toString("hex")}`, name, domain, domains, target: validateTarget(req.body.target), tls: ["http", "automatic", "internal"].includes(req.body.tls) ? req.body.tls : "automatic", hsts: req.body.hsts === true, enabled: true, createdAt: new Date().toISOString() }; applyAdvancedSettings(proxy, req.body); if (proxy.healthEnabled === false) upstreamHealth.set(proxy.id, { status: "unmonitored", checkedAt: null, history: [] }); else { upstreamHealth.set(proxy.id, { status: "pending", checkedAt: null, history: [] }); checkProxy(proxy).catch(error => console.warn("Proxy health check failed:", error.message)); } proxies.push(proxy); await syncCaddy(); await saveProxies(); recordActivity(`Proxy host “${proxy.name}” created.`); res.status(201).json(publicProxy(proxy)); } catch (error) { next(error); } }); app.patch("/api/proxies/:id", async (req, res, next) => { try { const proxy = proxies.find(item => item.id === req.params.id); if (!proxy) return res.status(404).json({ error: "Proxy host not found." }); const previousDomains = normalizeDomains(proxy.domain, proxy.domains); if (req.body.domain !== undefined) { const domain = normalizeDomain(req.body.domain); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : proxy.domains); const domainError = validateDomains(domains, proxy.id); if (domainError || !domain) return res.status(400).json({ error: domainError || "Domain is required." }); proxy.domain = domain; proxy.domains = domains; } if (req.body.domains !== undefined && req.body.domain === undefined) { const domains = normalizeDomains(proxy.domain, req.body.domains); const domainError = validateDomains(domains, proxy.id); if (domainError) return res.status(400).json({ error: domainError }); proxy.domains = domains; } if (req.body.name !== undefined) { const name = String(req.body.name).trim(); if (!name) return res.status(400).json({ error: "Proxy name is required." }); proxy.name = name; } if (req.body.target !== undefined) proxy.target = validateTarget(req.body.target); if (req.body.tls !== undefined) proxy.tls = req.body.tls === "custom" && proxy.certificatePath && proxy.keyPath ? "custom" : ["http", "automatic", "internal"].includes(req.body.tls) ? req.body.tls : proxy.tls; if (req.body.hsts !== undefined) proxy.hsts = req.body.hsts === true; applyAdvancedSettings(proxy, req.body); // Mirror the Hosted Site PATCH handler: react immediately instead of waiting on the 60s // background checkAllProxies() timer or a page refresh to pick up a Monitor toggle/edit. if (proxy.healthEnabled === false) upstreamHealth.set(proxy.id, { status: "unmonitored", checkedAt: null, history: [] }); else { upstreamHealth.set(proxy.id, { status: "pending", checkedAt: null, history: [] }); checkProxy(proxy).catch(error => console.warn("Proxy health check failed:", error.message)); } await syncCaddy(); await pruneOrphanedCertificates(previousDomains); await saveProxies(); recordActivity(`Proxy host “${proxy.name}” updated.`); res.json(publicProxy(proxy)); } catch (error) { next(error); } }); app.post("/api/proxies/:id/certificate", certificateUpload.fields([{ name: "certificate", maxCount: 1 }, { name: "privateKey", maxCount: 1 }]), async (req, res, next) => { const files = Object.values(req.files || {}).flat(); try { const proxy = proxies.find(item => item.id === req.params.id); if (!proxy) return res.status(404).json({ error: "Proxy host not found." }); const certificateFile = req.files?.certificate?.[0], keyFile = req.files?.privateKey?.[0]; if (!certificateFile || !keyFile) return res.status(400).json({ error: "Choose both the PEM certificate and private key." }); const certificatePem = await fsp.readFile(certificateFile.path, "utf8"), keyPem = await fsp.readFile(keyFile.path, "utf8"); const certificate = new crypto.X509Certificate(certificatePem), privateKey = crypto.createPrivateKey(keyPem), publicFromKey = crypto.createPublicKey(privateKey); const certificatePublic = certificate.publicKey.export({ type: "spki", format: "der" }), suppliedPublic = publicFromKey.export({ type: "spki", format: "der" }); if (!certificatePublic.equals(suppliedPublic)) return res.status(400).json({ error: "The private key does not match the certificate." }); const certificateDomains = normalizeDomains(proxy.domain, proxy.domains); if (!certificateDomains.every(domain => certificate.checkHost(domain))) return res.status(400).json({ error: "The certificate must cover the primary domain and every additional domain." }); const destination = path.join(customCertificatesDir, proxy.id); await fsp.mkdir(destination, { recursive: true }); const certificatePath = path.join(destination, "certificate.pem"), keyPath = path.join(destination, "private-key.pem"); await fsp.writeFile(certificatePath, certificatePem, { mode: 0o600 }); await fsp.writeFile(keyPath, keyPem, { mode: 0o600 }); proxy.tls = "custom"; proxy.certificatePath = certificatePath; proxy.keyPath = keyPath; await syncCaddy(); await saveProxies(); recordActivity(`Custom certificate installed for “${proxy.name}”.`); res.json(publicProxy(proxy)); } catch (error) { next(Object.assign(new Error(error.message || "Could not read that certificate."), { status: error.status || 400 })); } finally { await Promise.all(files.map(file => fsp.rm(file.path, { force: true }))); } }); app.post("/api/proxies/:id/toggle", async (req, res, next) => { try { const proxy = proxies.find(item => item.id === req.params.id); if (!proxy) return res.status(404).json({ error: "Proxy host not found." }); proxy.enabled = !proxy.enabled; await syncCaddy(); await saveProxies(); recordActivity(`Proxy host “${proxy.name}” ${proxy.enabled ? "enabled" : "disabled"}.`); res.json(publicProxy(proxy)); } catch (error) { next(error); } }); app.delete("/api/proxies/:id", async (req, res, next) => { try { const index = proxies.findIndex(item => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Proxy host not found." }); const [proxy] = proxies.splice(index, 1); await syncCaddy(); await fsp.rm(path.join(customCertificatesDir, proxy.id), { recursive: true, force: true }).catch(() => {}); await pruneOrphanedCertificates(normalizeDomains(proxy.domain, proxy.domains)); await saveProxies(); recordActivity(`Proxy host “${proxy.name}” deleted.`); res.status(204).end(); } catch (error) { next(error); } }); // --- Access Lists: create / edit / assignments / delete ------------------------------------------------------------ app.post("/api/access-lists", async (req, res, next) => { try { const name = String(req.body.name || "").trim(); if (!name || name.length > 80) return res.status(400).json({ error: "Access List name is required and must be 80 characters or fewer." }); const networks = String(req.body.networks || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); const deniedNetworks = String(req.body.deniedNetworks || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); if (networks.some(value => !/^(?:private_ranges|(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?|[0-9a-f:]+(?:\/\d{1,3})?)$/i.test(value))) return res.status(400).json({ error: "Enter IP addresses, CIDR ranges, or private_ranges, one per line." }); if (deniedNetworks.some(value => !/^(?:private_ranges|(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?|[0-9a-f:]+(?:\/\d{1,3})?)$/i.test(value))) return res.status(400).json({ error: "Enter valid denied IP addresses or CIDR ranges." }); const credentials = []; for (const entry of Array.isArray(req.body.credentials) ? req.body.credentials.slice(0, 25) : []) { const username = String(entry.username || "").trim(); const password = String(entry.password || ""); if (!/^[A-Za-z0-9._-]{1,64}$/.test(username) || password.length < 8) return res.status(400).json({ error: "Access usernames must be valid and passwords must contain at least 8 characters." }); const { stdout } = await execFileAsync("caddy", ["hash-password", "--plaintext", password]); credentials.push({ username, hash: stdout.trim(), password: await passwordRecord(password) }); } if (!networks.length && !deniedNetworks.length && !credentials.length) return res.status(400).json({ error: "Add at least one network rule or login." }); const selectedGroups = Array.isArray(req.body.groups) ? [...new Set(req.body.groups)].filter(id => groups.some(group => group.id === id && group.enabled !== false)) : []; const item = { id: `access-${crypto.randomBytes(4).toString("hex")}`, name, networks, deniedNetworks, credentials, groups: selectedGroups, enabled: true, createdAt: new Date().toISOString() }; accessLists.push(item); await syncCaddy(); await saveAccessLists(); recordActivity(`Access List “${name}” created.`); res.status(201).json({ ...item, credentials: credentials.map(({ username }) => ({ username })) }); } catch (error) { next(error); } }); app.patch("/api/access-lists/:id", async (req, res, next) => { try { const item = accessLists.find(value => value.id === req.params.id); if (!item) return res.status(404).json({ error: "Access List not found." }); if (req.body.enabled !== undefined) item.enabled = Boolean(req.body.enabled); if (req.body.name) item.name = String(req.body.name).trim().slice(0, 80); if (req.body.networks !== undefined) { const networks = String(req.body.networks || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); if (networks.some(value => !/^(?:private_ranges|(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?|[0-9a-f:]+(?:\/\d{1,3})?)$/i.test(value))) return res.status(400).json({ error: "Enter IP addresses, CIDR ranges, or private_ranges, one per line." }); item.networks = networks; } if (req.body.deniedNetworks !== undefined) { const deniedNetworks = String(req.body.deniedNetworks || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); if (deniedNetworks.some(value => !/^(?:private_ranges|(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?|[0-9a-f:]+(?:\/\d{1,3})?)$/i.test(value))) return res.status(400).json({ error: "Enter valid denied IP addresses or CIDR ranges." }); item.deniedNetworks = deniedNetworks; } if (Array.isArray(req.body.credentials)) { const credentials = []; for (const entry of req.body.credentials.slice(0, 25)) { const username = String(entry.username || "").trim(), password = String(entry.password || ""); if (!/^[A-Za-z0-9._-]{1,64}$/.test(username)) return res.status(400).json({ error: "Access usernames must use letters, numbers, dots, underscores, or hyphens." }); if (!password) { const existing = item.credentials?.find(value => value.username === username); if (!existing) return res.status(400).json({ error: `Enter a password for new user ${username}.` }); credentials.push(existing); continue; } if (password.length < 8) return res.status(400).json({ error: "Passwords must contain at least 8 characters." }); const { stdout } = await execFileAsync("caddy", ["hash-password", "--plaintext", password]); credentials.push({ username, hash: stdout.trim(), password: await passwordRecord(password) }); } item.credentials = credentials; } if (!(item.networks || []).length && !(item.deniedNetworks || []).length && !(item.credentials || []).length) return res.status(400).json({ error: "Keep at least one network rule or login." }); await syncCaddy(); await saveAccessLists(); recordActivity(`Access List “${item.name}” updated.`); res.json({ ...item, credentials: (item.credentials || []).map(({ username }) => ({ username })) }); } catch (error) { next(error); } }); app.post("/api/access-lists/:id/assignments", async (req, res, next) => { try { const list = accessLists.find(value => value.id === req.params.id); if (!list) return res.status(404).json({ error: "Access List not found." }); const collections = { sites, proxies, redirects }; const kind = String(req.body.kind || ""); const collection = collections[kind]; const host = collection?.find(value => value.id === req.body.hostId); if (!host) return res.status(404).json({ error: "Host not found." }); host.accessListId = req.body.assigned === false ? "" : list.id; await syncCaddy(); if (kind === "sites") await saveSites(); else if (kind === "proxies") await saveProxies(); else await saveRedirects(); recordActivity("Access List " + list.name + (host.accessListId ? " assigned to " : " removed from ") + (host.name || host.domain) + "."); res.json({ ok: true, accessListId: host.accessListId }); } catch (error) { next(error); } }); app.delete("/api/access-lists/:id", async (req, res, next) => { try { if ([...sites, ...proxies, ...redirects].some(item => item.accessListId === req.params.id)) return res.status(409).json({ error: "Remove this Access List from all hosts before deleting it." }); const index = accessLists.findIndex(item => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Access List not found." }); const [item] = accessLists.splice(index, 1); await saveAccessLists(); recordActivity(`Access List “${item.name}” deleted.`); res.status(204).end(); } catch (error) { next(error); } }); // --- Redirect Hosts: create / edit / delete ------------------------------------------------------------------------- app.post("/api/redirects", async (req, res, next) => { try { const name = String(req.body.name || "").trim(); const domain = normalizeDomain(req.body.domain); const domains = normalizeDomains(domain, req.body.domains); const target = String(req.body.target || "").trim().replace(/\/$/, ""); const domainError = validateDomains(domains); if (!name || domainError || !domain) return res.status(400).json({ error: domainError || "Name and primary source domain are required." }); try { const parsed = new URL(target); if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error(); } catch { return res.status(400).json({ error: "Destination must be a complete HTTP or HTTPS URL." }); } const item = { id: `redirect-${crypto.randomBytes(4).toString("hex")}`, name, domain, domains, target, code: [301,302,307,308].includes(Number(req.body.code)) ? Number(req.body.code) : 302, preservePath: req.body.preservePath !== false, tls: ["http","automatic","internal"].includes(req.body.tls) ? req.body.tls : "automatic", hsts: Boolean(req.body.hsts), accessListId: String(req.body.accessListId || ""), enabled: true, createdAt: new Date().toISOString() }; redirects.push(item); await syncCaddy(); await saveRedirects(); recordActivity(`Redirect Host “${name}” created.`); res.status(201).json(item); } catch (error) { next(error); } }); app.patch("/api/redirects/:id", async (req, res, next) => { try { const item = redirects.find(value => value.id === req.params.id); if (!item) return res.status(404).json({ error: "Redirect Host not found." }); const previousDomains = normalizeDomains(item.domain, item.domains); if (req.body.domain !== undefined || req.body.domains !== undefined) { const domain = normalizeDomain(req.body.domain ?? item.domain); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : item.domains); const error = validateDomains(domains, item.id); if (error || !domain) return res.status(400).json({ error: error || "Primary source domain is required." }); item.domain = domain; item.domains = domains; } if (req.body.enabled !== undefined) item.enabled = Boolean(req.body.enabled); for (const key of ["name","target","accessListId"]) if (req.body[key] !== undefined) item[key] = String(req.body[key]).trim(); if (req.body.target !== undefined) { try { const parsed = new URL(item.target); if (!['http:','https:'].includes(parsed.protocol)) throw new Error(); } catch { return res.status(400).json({ error: "Destination must be a complete HTTP or HTTPS URL." }); } } if (req.body.code !== undefined && [301,302,307,308].includes(Number(req.body.code))) item.code = Number(req.body.code); if (req.body.preservePath !== undefined) item.preservePath = Boolean(req.body.preservePath); if (req.body.tls !== undefined) item.tls = ["http","automatic","internal"].includes(req.body.tls) ? req.body.tls : item.tls; if (req.body.hsts !== undefined) item.hsts = Boolean(req.body.hsts); await syncCaddy(); await pruneOrphanedCertificates(previousDomains); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” updated.`); res.json(item); } catch (error) { next(error); } }); app.delete("/api/redirects/:id", async (req, res, next) => { try { const index = redirects.findIndex(item => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Redirect Host not found." }); const [item] = redirects.splice(index, 1); await syncCaddy(); await pruneOrphanedCertificates(normalizeDomains(item.domain, item.domains)); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” deleted.`); res.status(204).end(); } catch (error) { next(error); } }); // --- Streaming Hosts: list / create / edit / toggle / delete ----------------------------------------------------------- app.get("/api/streams", (req, res) => res.json(streams.map(publicStream))); app.post("/api/streams", async (req, res, next) => { try { const name = String(req.body.name || "").trim(); if (!name) return res.status(400).json({ error: "Name is required." }); const port = validateStreamPort(req.body.port); const portError = streamPortConflict(port); if (portError) return res.status(400).json({ error: portError }); const target = validateStreamHostPort(req.body.target); const tcp = req.body.tcp !== false, udp = req.body.udp === true; if (!tcp && !udp) return res.status(400).json({ error: "Enable TCP, UDP, or both." }); const stream = { id: `stream-${crypto.randomBytes(4).toString("hex")}`, name, port, target, tcp, udp, healthEnabled: req.body.healthEnabled !== false, enabled: true, createdAt: new Date().toISOString() }; try { await startStream(stream); } catch (error) { return res.status(409).json({ error: `Could not bind port ${port}: ${error.message}` }); } streams.push(stream); if (stream.healthEnabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] }); else { upstreamHealth.set(stream.id, { status: "pending", checkedAt: null, history: [] }); checkStream(stream).catch(error => console.warn("Streaming host health check failed:", error.message)); } await saveStreams(); recordActivity(`Streaming host “${stream.name}” created.`); res.status(201).json(publicStream(stream)); } catch (error) { next(error); } }); app.patch("/api/streams/:id", async (req, res, next) => { try { const stream = streams.find(item => item.id === req.params.id); if (!stream) return res.status(404).json({ error: "Streaming host not found." }); const next_ = { ...stream }; if (req.body.name !== undefined) { const name = String(req.body.name).trim(); if (!name) return res.status(400).json({ error: "Name is required." }); next_.name = name; } if (req.body.port !== undefined) { const port = validateStreamPort(req.body.port); const portError = streamPortConflict(port, stream.id); if (portError) return res.status(400).json({ error: portError }); next_.port = port; } if (req.body.target !== undefined) next_.target = validateStreamHostPort(req.body.target); if (req.body.tcp !== undefined) next_.tcp = Boolean(req.body.tcp); if (req.body.udp !== undefined) next_.udp = Boolean(req.body.udp); if (!next_.tcp && !next_.udp) return res.status(400).json({ error: "Enable TCP, UDP, or both." }); if (req.body.healthEnabled !== undefined) next_.healthEnabled = req.body.healthEnabled === true || (typeof req.body.healthEnabled === "string" && req.body.healthEnabled.toLowerCase() === "true"); if (req.body.enabled !== undefined) next_.enabled = Boolean(req.body.enabled); const portOrProtocolChanged = next_.port !== stream.port || next_.target !== stream.target || next_.tcp !== stream.tcp || next_.udp !== stream.udp || next_.enabled !== stream.enabled; Object.assign(stream, next_); if (portOrProtocolChanged) { try { await restartStream(stream); } catch (error) { return res.status(409).json({ error: `Could not bind port ${stream.port}: ${error.message}` }); } } if (stream.healthEnabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] }); else { upstreamHealth.set(stream.id, { status: "pending", checkedAt: null, history: [] }); checkStream(stream).catch(error => console.warn("Streaming host health check failed:", error.message)); } await saveStreams(); recordActivity(`Streaming host “${stream.name}” updated.`); res.json(publicStream(stream)); } catch (error) { next(error); } }); app.post("/api/streams/:id/toggle", async (req, res, next) => { try { const stream = streams.find(item => item.id === req.params.id); if (!stream) return res.status(404).json({ error: "Streaming host not found." }); stream.enabled = !stream.enabled; try { await restartStream(stream); } catch (error) { stream.enabled = !stream.enabled; return res.status(409).json({ error: `Could not bind port ${stream.port}: ${error.message}` }); } if (stream.enabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] }); await saveStreams(); recordActivity(`Streaming host “${stream.name}” ${stream.enabled ? "enabled" : "disabled"}.`); res.json(publicStream(stream)); } catch (error) { next(error); } }); app.delete("/api/streams/:id", async (req, res, next) => { try { const index = streams.findIndex(item => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Streaming host not found." }); const [item] = streams.splice(index, 1); await stopStream(item.id); upstreamHealth.delete(item.id); await saveStreams(); recordActivity(`Streaming host “${item.name}” deleted.`); res.status(204).end(); } catch (error) { next(error); } }); // --- Settings (general), log retention/pruning, log download, factory reset --------------------------------------------- app.patch("/api/settings", async (req, res, next) => { try { if (req.body.defaultSite) { const value = req.body.defaultSite; const mode = ["welcome","themed404","abort","redirect","custom"].includes(value.mode) ? value.mode : "themed404"; settings.defaultSite = { mode, redirectUrl: String(value.redirectUrl || "").trim(), redirectCode: [301,302,307,308].includes(Number(value.redirectCode)) ? Number(value.redirectCode) : 302, preservePath: value.preservePath !== false, title: String(value.title || "").slice(0, 100), message: String(value.message || "").slice(0, 500), customHtml: String(value.customHtml || "").slice(0, 250000) }; } if (req.body.backups) settings.backups = { ...settings.backups, ...req.body.backups, hour: Math.min(Math.max(Number(req.body.backups.hour) || 0, 0), 23), retention: Math.min(Math.max(Number(req.body.backups.retention) || 7, 1), 100) }; if (req.body.certificateHealth) { const warningDays = Math.min(Math.max(Number(req.body.certificateHealth.warningDays) || 30, 8), 120); const criticalDays = Math.min(Math.max(Number(req.body.certificateHealth.criticalDays) || 7, 1), warningDays - 1); settings.certificateHealth = { warningDays, criticalDays, staleMinutes: Math.min(Math.max(Number(req.body.certificateHealth.staleMinutes) || 10, 2), 1440) }; } if (req.body.logsRetention) { const value = req.body.logsRetention; const days = key => Math.min(Math.max(Number(value[key]) || 30, 7), 3650); settings.logsRetention = { ...settings.logsRetention, accessDays: days("accessDays"), activityDays: days("activityDays"), auditDays: days("auditDays"), certificateDays: days("certificateDays"), securityDays: days("securityDays"), pruningEnabled: value.pruningEnabled === true }; } await syncCaddy(); await saveSettings(); recordActivity("Administration settings updated."); res.json({ ...settings, backupDirectory: backupsDir }); } catch (error) { next(error); } }); app.post("/api/logs/prune", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (!settings.logsRetention?.pruningEnabled) return res.status(409).json({ error: "Automatic pruning is disabled. Enable it and save the retention policy first." }); const mode = req.body?.mode === "scheduled" ? "scheduled" : "manual"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: mode, lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`${mode === "scheduled" ? "Scheduled" : "Manual"} log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); res.json({ counts, snapshot }); } catch (error) { next(error); } }); app.get("/api/logs/prune/preview", (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); res.json({ enabled: settings.logsRetention?.pruningEnabled === true, counts: storage.previewPruneEvents(settings.logsRetention || {}) }); } catch (error) { next(error); } }); app.get("/api/logs/download", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const payload = { product: "Site Gateway", generatedAt: new Date().toISOString(), access: storage.listAccessEvents(500), activity: storage.listActivity(500), audit: storage.listAudit({}) }; res.setHeader("Content-Disposition", `attachment; filename="site-gateway-logs-${new Date().toISOString().slice(0, 10)}.json"`); res.json(payload); } catch (error) { next(error); } }); app.post("/api/settings/reset-defaults", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.confirmation || "") !== "RESTORE DEFAULT") return res.status(400).json({ error:"Type RESTORE DEFAULT exactly to continue." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); settings.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:"" }; settings.backups = { enabled:false, frequency:"daily", hour:2, retention:7, type:"complete", includeLogs:false, encrypt:false, lastRunAt:null, lastStatus:null }; settings.certificateHealth = { warningDays:30, criticalDays:7, staleMinutes:10 }; await saveSettings(); recordActivity("Gateway preferences restored to defaults."); res.json({ ...settings, backupDirectory:backupsDir }); } catch (error) { next(error); } }); app.post("/api/factory-reset", async (req, res, next) => { try { if (String(req.body.confirmation || "") !== "FACTORY RESET") return res.status(400).json({ error:"Type FACTORY RESET exactly to continue." }); if (String(req.body.username || "").toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); await Promise.all([...activeServers.keys()].map(stopSite)); await Promise.all([...activeStreams.keys()].map(stopStream)); storage.close(); for (const directory of [sitesDir, uploadDir, caddyDir, iconsDir, logsDir, backupsDir, defaultSiteDir, certificatesRoot, path.join(dataDir,"database")]) await clearDirectoryContents(directory); storage = await openStorage(dataDir, backupsDir); sites = []; proxies = []; users = []; redirects = []; streams = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites(); await syncCaddy(); res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); res.status(202).json({ ok:true }); } catch (error) { next(error); } }); // --- Backups: list / create / import / download / restore / delete ----------------------------------------------------- app.use("/api/backups", (req, res, next) => req.user.role === "administrator" ? next() : res.status(403).json({ error: "Administrator access is required." })); app.get("/api/backups", async (req, res, next) => { try { res.json(await listBackups()); } catch (error) { next(error); } }); app.post("/api/backups", async (req, res, next) => { try { const backup = await createBackup(req.body.type, Boolean(req.body.includeLogs), "site-gateway-backup", String(req.body.password || "")); res.status(201).json(backup); } catch (error) { next(error); } }); app.post("/api/backups/import", upload.single("backup"), async (req, res, next) => { try { if (!req.file) return res.status(400).json({ error: "Choose a .sgbackup file." }); const { zip } = await openBackup(req.file.path, String(req.body.password || "")); const manifest = JSON.parse(zip.readAsText("manifest.json") || "null"); if (!manifest || manifest.product !== "Site Gateway" || ![1,2].includes(manifest.format)) throw Object.assign(new Error("This is not a supported Site Gateway backup."), { status: 400 }); const filename = `imported-${new Date().toISOString().replace(/[:.]/g, "-")}.sgbackup`; await fsp.rename(req.file.path, path.join(backupsDir, filename)); recordActivity(`Backup imported from this computer.`); res.status(201).json({ filename, manifest }); } catch (error) { if (req.file) await fsp.rm(req.file.path, { force: true }); next(error); } }); app.get("/api/backups/:filename/download", async (req, res, next) => { try { const filename = path.basename(req.params.filename); const file = path.join(backupsDir, filename); await fsp.access(file); res.download(file, filename); } catch (error) { next(Object.assign(new Error("Backup not found."), { status: 404 })); } }); app.post("/api/backups/:filename/restore", async (req, res, next) => { try { res.json({ ok: true, manifest: await restoreBackup(path.basename(req.params.filename), String(req.body.password || "")) }); } catch (error) { next(error); } }); app.delete("/api/backups/:filename", async (req, res, next) => { try { const filename = path.basename(req.params.filename); if (!filename.endsWith(".sgbackup")) return res.status(400).json({ error: "Invalid backup." }); await fsp.rm(path.join(backupsDir, filename)); recordActivity(`Backup ${filename} deleted.`); res.status(204).end(); } catch (error) { next(error); } }); function humanizeGatewayActivityError(message) { const text = String(message || "Unexpected gateway error"); if (/upstream address scheme is HTTP but transport is configured for HTTP\+TLS/i.test(text)) return "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; if (/upstream address scheme is HTTPS but transport is configured for plain HTTP/i.test(text)) return "Gateway configuration rejected: HTTPS upstream requires HTTPS transport settings. Change the upstream URL or transport setting."; if (/duplicate.*address|already.*site address/i.test(text)) return "Gateway configuration rejected: This hostname or address is already used by another host. Choose a unique hostname and port."; if (/dial tcp|no such host|lookup .* no such host|upstream.*(invalid|malformed)/i.test(text)) return "Gateway configuration rejected: The upstream address could not be reached or is invalid. Check the hostname, IP address, and port."; if (/invalid hostname|host name.*invalid|malformed.*host/i.test(text)) return "Gateway configuration rejected: The hostname is not valid. Use a valid domain name without a protocol or path."; if (/unrecognized directive|unknown directive|parsing caddyfile tokens/i.test(text)) return "Gateway configuration rejected: The gateway configuration contains an unsupported or malformed directive. Check the selected host settings."; if (/certificate|tls.*(config|handshake)|no certificate/i.test(text)) return "Gateway configuration rejected: The TLS certificate configuration is invalid or unavailable. Check the certificate, key, and HTTPS settings."; return text.replace(/^Gateway configuration was rejected:\s*/i, "Gateway configuration rejected: ").replace(/\s+Details:\s+[\s\S]*$/i, ""); } const GATEWAY_CONFIG_ROUTE = /^\/api\/(sites|proxies|redirects|streams|access-lists)(\/|$)/i; // --- Error handling middleware & server startup ------------------------------------------------------------------------- app.use((error, req, res, next) => { console.error(error); const rawMessage = error.message || "Something went wrong."; const isConfigRoute = GATEWAY_CONFIG_ROUTE.test(req.path) && ["PATCH", "POST", "DELETE", "PUT"].includes(req.method); let logMessage = rawMessage; if (isConfigRoute) { const humanized = humanizeGatewayActivityError(rawMessage); logMessage = /^Gateway configuration rejected:/i.test(humanized) ? humanized : `Gateway configuration rejected: ${humanized}`; } recordActivity(`${req.method} ${req.path}: ${logMessage}`, "error"); res.status(error.status || 500).json({ error: rawMessage }); }); app.listen(adminPort, "0.0.0.0", () => { console.log(`Site Gateway dashboard listening on port ${adminPort}`); if (adminPassword === "change-this-password") console.warn("WARNING: Change ADMIN_PASSWORD before exposing the dashboard."); }); setTimeout(() => checkAllProxies().catch(error => console.warn("Initial upstream checks failed:", error.message)), 1500).unref(); setInterval(() => checkAllProxies().catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref(); // --- Scheduled jobs: automatic backups, log pruning, public IP checks, graceful shutdown --------------------------------- async function runScheduledBackup() { const schedule = settings.backups || {}; if (!schedule.enabled || Number(schedule.hour) !== new Date().getHours()) return; const last = schedule.lastRunAt ? new Date(schedule.lastRunAt) : null; const elapsed = last ? Date.now() - last.getTime() : Infinity; const due = schedule.frequency === "monthly" ? elapsed >= 27 * 86400000 : schedule.frequency === "weekly" ? elapsed >= 6 * 86400000 : elapsed >= 20 * 3600000; if (!due) return; try { if (schedule.encrypt && !scheduledBackupPassword) throw new Error("BACKUP_PASSWORD is required for encrypted scheduled backups."); await createBackup(schedule.type, Boolean(schedule.includeLogs), "scheduled", schedule.encrypt ? scheduledBackupPassword : ""); schedule.lastRunAt = new Date().toISOString(); schedule.lastStatus = "ok"; const backups = (await listBackups()).filter(item => item.filename.startsWith("scheduled-")); for (const item of backups.slice(Math.max(Number(schedule.retention) || 7, 1))) await fsp.rm(path.join(backupsDir, item.filename), { force: true }); } catch (error) { schedule.lastRunAt = new Date().toISOString(); schedule.lastStatus = `error: ${error.message}`; recordActivity(`Scheduled backup failed: ${error.message}`, "error"); } await saveSettings(); } setTimeout(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 5000).unref(); setInterval(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 15 * 60000).unref(); async function runScheduledPruning() { if (!settings.logsRetention?.pruningEnabled || !storage?.pruneEvents) return; try { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: "scheduled", lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`Scheduled log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); } catch (error) { recordActivity(`Scheduled log pruning failed: ${error.message}`, "error"); } } setInterval(() => runScheduledPruning(), 15 * 60000).unref(); setTimeout(() => importAccessLogsToSqlite(), 8000).unref(); setInterval(() => importAccessLogsToSqlite(), 30000).unref(); async function checkPublicIp() { try { const response = await fetch("https://api.ipify.org?format=json", { signal: AbortSignal.timeout(6000), headers: { "user-agent": "Site-Gateway-DDNS-Check/1.0" } }); if (!response.ok) throw new Error(`IP lookup returned HTTP ${response.status}.`); const body = await response.json(); const address = String(body.ip || "").trim(); if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(address) && !address.includes(":")) throw new Error("IP lookup returned an unexpected value."); const changed = publicIpState.address && publicIpState.address !== address; publicIpState = { address, checkedAt: new Date().toISOString(), error: null }; if (changed) recordActivity(`Public IP address changed to ${address}.`); } catch (error) { publicIpState = { ...publicIpState, checkedAt: new Date().toISOString(), error: error.message }; } } setTimeout(() => checkPublicIp(), 4000).unref(); setInterval(() => checkPublicIp(), 60 * 60000).unref(); async function shutdown() { await Promise.all([...activeServers.keys()].map(stopSite)); await Promise.all([...activeStreams.keys()].map(stopStream)); try { storage?.close(); } catch { /* Database may already be closed during restore. */ } process.exit(0); } process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown);