Files
site-gateway/src/server.js
T

2711 lines
345 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 os from "node:os";
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();
// Certificate inventory is expensive (it walks and parses every certificate file on disk) and is
// recomputed on every call with no memoization. It's called twice per client refresh() cycle --
// once from /api/dashboard, once from /api/certificates -- and that whole cycle can itself repeat
// several times in a row (see refreshPendingProxies in app.js), so a short time-based cache here
// collapses that duplicate work into a single real disk walk every few seconds. The window is kept
// well under the 7s hero-poll interval so nothing ever appears more than one cycle stale.
const CERTIFICATE_INVENTORY_CACHE_MS = 3000;
let certificateInventoryCache = null; // { at: number, value: object }
const loginAttempts = new Map();
const rateLimitBuckets = new Map();
let dockerSocketMounted = false;
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); }
}
// --- Edit-route change summaries: builds one human-readable, comma-joined line describing
// exactly what changed in a single PATCH save, instead of a generic "X updated" message
// that looks identical whether a rename, a domain change, or a TLS toggle happened.
function describeDomainsChange(previousDomains, nextDomains) {
if (previousDomains.join("|") === nextDomains.join("|")) return null;
if (!nextDomains.length) return "domain removed";
const shown = nextDomains.slice(0, 3).join(", ");
return `domain${nextDomains.length > 1 ? "s" : ""} changed to ${shown}${nextDomains.length > 3 ? ` (+${nextDomains.length - 3} more)` : ""}`;
}
function summarizeUpdate(kind, name, changes) {
const list = changes.filter(Boolean);
if (!list.length) return;
recordActivity(`${kind}${name}” updated — ${list.join(", ")}.`);
}
async function directorySize(directory) {
const entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error));
const sizes = await Promise.all(entries.map(async entry => {
const itemPath = path.join(directory, entry.name);
if (entry.isDirectory()) return directorySize(itemPath);
if (entry.isFile()) return (await fsp.stat(itemPath)).size;
return 0;
}));
return sizes.reduce((sum, size) => sum + size, 0);
}
// --- System health: cgroup v2 CPU/memory/swap sampling + network throughput -------------------
// All readings are container-scoped (cgroup v2), not host-wide, because Site Gateway usually
// isn't the only thing running on the host and host-wide numbers would be misleading in a
// per-container dashboard. Falls back to host-level approximations (with a flag the UI can use
// to disclaim them) when cgroup v2 files aren't readable -- e.g. cgroup v1 hosts, or a container
// runtime that doesn't expose them.
const CGROUP_ROOT = "/sys/fs/cgroup";
async function readCgroupFile(name) {
try { return (await fsp.readFile(path.join(CGROUP_ROOT, name), "utf8")).trim(); } catch { return null; }
}
let lastCpuSample = null; // { usageMicros, atMs } -- usage_usec is cumulative, so CPU% needs a delta between two samples.
// cpu.max sets a real CFS quota (from Docker's --cpus flag); cpuset.cpus.effective is the pinned
// core *list* (from --cpuset-cpus / Unraid's CPU pinning), which caps which cores can run but not
// how much of them can be used -- pinning alone leaves cpu.max at "max". Percent needs a real
// denominator either way, and which one applies (and thus what the number means) has to be
// reported back to the UI so the label doesn't lie about what's being measured.
async function cgroupCpuQuota() {
const max = await readCgroupFile("cpu.max");
if (max) { const [quota, period] = max.split(/\s+/); if (quota !== "max") { const q = Number(quota), p = Number(period); if (q > 0 && p > 0) return { cpus: q / p, source: "quota" }; } }
const pinned = await readCgroupFile("cpuset.cpus.effective");
if (pinned) { const count = expandCpuList(pinned); if (count > 0) return { cpus: count, source: "pinned" }; }
return { cpus: os.cpus().length || 1, source: "host" };
}
// cpuset.cpus.effective is a comma-separated list of cores and ranges, e.g. "0-1,4" -- count how
// many individual CPUs that covers rather than assuming a single contiguous range.
function expandCpuList(list) {
return list.split(",").reduce((total, part) => {
const range = part.trim().match(/^(\d+)(?:-(\d+))?$/);
if (!range) return total;
const start = Number(range[1]), end = range[2] !== undefined ? Number(range[2]) : start;
return total + Math.max(0, end - start + 1);
}, 0);
}
async function cgroupCpuPercent() {
const stat = await readCgroupFile("cpu.stat");
if (!stat) return null;
const match = stat.match(/^usage_usec (\d+)/m);
if (!match) return null;
const usageMicros = Number(match[1]), atMs = Date.now();
const previous = lastCpuSample;
lastCpuSample = { usageMicros, atMs };
if (!previous) return null; // First call has nothing to diff against -- the next poll will have a real number.
const elapsedMicros = (atMs - previous.atMs) * 1000;
if (elapsedMicros <= 0) return null;
const quota = await cgroupCpuQuota();
const percent = ((usageMicros - previous.usageMicros) / elapsedMicros) / quota.cpus * 100;
return { percent: Math.max(0, Math.min(100, percent)), quotaCpus: quota.cpus, quotaSource: quota.source };
}
async function cgroupMemory() {
const current = await readCgroupFile("memory.current");
if (current === null) return null;
const maxRaw = await readCgroupFile("memory.max");
const totalBytes = os.totalmem();
const limitBytes = maxRaw && maxRaw !== "max" ? Number(maxRaw) : totalBytes;
const usedBytes = Number(current);
return { usedBytes, limitBytes, percent: limitBytes > 0 ? (usedBytes / limitBytes) * 100 : null };
}
async function cgroupSwap() {
const current = await readCgroupFile("memory.swap.current");
if (current === null) return null;
const maxRaw = await readCgroupFile("memory.swap.max");
const usedBytes = Number(current);
if (maxRaw === "0") return { usedBytes: 0, limitBytes: 0, percent: null, configured: false };
const limitBytes = maxRaw && maxRaw !== "max" ? Number(maxRaw) : null;
return { usedBytes, limitBytes, percent: limitBytes ? (usedBytes / limitBytes) * 100 : null, configured: true };
}
// Network counters are cumulative since the interface came up, so throughput needs a delta
// between two samples too -- sampled on a fixed interval in the background (rather than on
// each request) so the rate stays smooth regardless of how often the dashboard polls.
let lastNetworkSample = null; // { rxBytes, txBytes, atMs }
let networkRate = null; // { rxBytesPerSec, txBytesPerSec }
async function sampleNetworkInterfaces() {
try {
const names = (await fsp.readdir("/sys/class/net")).filter(name => name !== "lo");
const totals = await Promise.all(names.map(async name => {
const [rx, tx] = await Promise.all([
fsp.readFile(`/sys/class/net/${name}/statistics/rx_bytes`, "utf8").catch(() => "0"),
fsp.readFile(`/sys/class/net/${name}/statistics/tx_bytes`, "utf8").catch(() => "0"),
]);
return { rx: Number(rx.trim()) || 0, tx: Number(tx.trim()) || 0 };
}));
const rxBytes = totals.reduce((sum, value) => sum + value.rx, 0), txBytes = totals.reduce((sum, value) => sum + value.tx, 0), atMs = Date.now();
if (lastNetworkSample) {
const elapsedSeconds = (atMs - lastNetworkSample.atMs) / 1000;
if (elapsedSeconds > 0) networkRate = { rxBytesPerSec: Math.max(0, (rxBytes - lastNetworkSample.rxBytes) / elapsedSeconds), txBytesPerSec: Math.max(0, (txBytes - lastNetworkSample.txBytes) / elapsedSeconds) };
}
lastNetworkSample = { rxBytes, txBytes, atMs };
} catch { /* No readable network interfaces (e.g. host networking with restricted /sys) -- the hero panel just omits the network stat. */ }
}
setInterval(sampleNetworkInterfaces, 5000).unref();
sampleNetworkInterfaces();
// A recursive walk of /data (directorySize()) is only needed when DATA_DIR_LIMIT_GB is set, and
// only to compute one denominator-relative percentage -- disk usage doesn't change fast enough to
// justify redoing that walk on every single hero-panel poll (every 7 seconds, times every
// concurrent viewer). Cached in the background instead, same pattern as refreshDatabaseIntegrityCache()
// above: compute once shortly after boot, then on a steady interval, and have the hot request path
// just read the cached number.
let dataDirSizeCache = { checkedAt: null, bytes: null };
async function refreshDataDirSizeCache() {
try { dataDirSizeCache = { checkedAt: new Date().toISOString(), bytes: await directorySize(dataDir) }; }
catch (error) { console.warn("Could not compute data directory size:", error.message); }
}
// One combined snapshot for the System tab's hero panel -- CPU/memory/swap/network are all
// container-scoped (cgroup v2 + this container's network namespace); disk reuses the same
// statfs-on-the-data-volume approach as /api/system/storage.
async function systemHealthSnapshot() {
const assignedLimitGb = numberEnv("DATA_DIR_LIMIT_GB", null);
const assignedLimitBytes = assignedLimitGb && assignedLimitGb > 0 ? assignedLimitGb * 1024 ** 3 : null;
const [cpu, memory, swap, disk] = await Promise.all([
cgroupCpuPercent(),
cgroupMemory(),
cgroupSwap(),
fsp.statfs(dataDir).catch(() => null),
]);
// See refreshDataDirSizeCache() above -- this used to be a live directorySize() walk on every fetch.
const appUsedBytes = assignedLimitBytes !== null ? dataDirSizeCache.bytes : null;
return {
cpu,
memory,
swap,
disk: disk ? (() => {
const totalBytes = disk.blocks * disk.bsize, freeBytes = disk.bfree * disk.bsize, availableBytes = disk.bavail * disk.bsize, volumeUsedBytes = totalBytes - freeBytes;
// DATA_DIR_LIMIT_GB lets an operator tell the hero panel what's actually assigned to this
// deployment (e.g. a dedicated share/zvol sized smaller than the whole host volume), since
// Docker has no real per-container disk-space quota to read the way it does for CPU/memory.
// Purely a display denominator -- it doesn't enforce anything -- so usage over 100% is a
// real, meaningful warning rather than a bug: it means actual usage has exceeded what was assigned.
// Critically, comparing against an assigned allowance has to use Site Gateway's own actual
// footprint (appUsedBytes, a real walk of /data), not the whole filesystem's used space --
// statfs reports usage for the entire volume behind /data, which on a shared array or pool
// includes everything else living on that mount, not just what this app has written.
const usedBytes = assignedLimitBytes !== null ? appUsedBytes : volumeUsedBytes;
const denominatorBytes = assignedLimitBytes || totalBytes;
return { totalBytes, freeBytes, availableBytes, usedBytes, assignedLimitBytes, percent: (usedBytes / denominatorBytes) * 100 };
})() : null,
network: networkRate,
throughput: { liveRequests: storage.performanceLiveCount(60) },
// Same source dashboardSnapshot() already uses for its own uptime figure -- included here too
// so the Dashboard's hero panel can show Uptime as a plain value on the same 7s poll as every
// other hero stat, instead of a separate client-side ticker anchored against a one-time fetch.
uptimeSeconds: Math.floor(process.uptime()),
};
}
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;
}
// Small fixed-window in-memory rate limiter shared by the sign-in routes and the
// bearer-token authentication path. Map of key -> { count, windowStart }; no dependency.
function rateLimitExceeded(key, limit = 10, windowMs = 60000) {
const nowMs = Date.now();
const bucket = rateLimitBuckets.get(key);
if (!bucket || nowMs - bucket.windowStart >= windowMs) { rateLimitBuckets.set(key, { count: 1, windowStart: nowMs }); return false; }
bucket.count += 1;
if (rateLimitBuckets.size > 5000) for (const [entryKey, entry] of rateLimitBuckets) if (nowMs - entry.windowStart >= windowMs) rateLimitBuckets.delete(entryKey);
return bucket.count > limit;
}
function requestKey(req) { return req.ip || req.socket?.remoteAddress || "unknown"; }
// Rotating a user's sessionVersion invalidates every session cookie they hold AND every API
// token they issued, because tokens store the version that was current when they were created.
// Callers that rotate the CURRENT user's version re-issue their cookie so they stay signed in.
function rotateSessionVersion(user) {
user.sessionVersion = crypto.randomBytes(16).toString("hex");
user.updatedAt = new Date().toISOString();
}
// --- REST API tokens ---------------------------------------------------------------------
// Only the SHA-256 hash of a token is ever stored; the raw value is shown once at creation.
function hashApiToken(rawToken) { return crypto.createHash("sha256").update(String(rawToken)).digest("hex"); }
// Resolves an `Authorization: Bearer <token>` header to its owning user. Tokens carry the
// owner's sessionVersion from the moment they were issued, so a password reset, an MFA
// change, or a deactivation invalidates every token that user issued -- exactly like a
// session cookie.
function bearerTokenUser(req) {
const header = String(req.headers.authorization || "");
if (!/^Bearer\s+/i.test(header)) return null;
const raw = header.replace(/^Bearer\s+/i, "").trim();
if (!raw) return null;
let record = null;
try { record = storage.findApiTokenByHash(hashApiToken(raw)); } catch { return null; }
if (!record || record.revokedAt) return null;
if (record.expiresAt && new Date(record.expiresAt).getTime() <= Date.now()) return null;
const owner = users.find(item => item.id === record.ownerUserId);
if (!owner || owner.status !== "active") return null;
if (record.sessionVersion && record.sessionVersion !== owner.sessionVersion) return null;
return { user: owner, token: record };
}
// --- Docker integration (optional, off by default) ---------------------------------------
// Talks to the Docker Engine API over the mounted UNIX socket using Node's built-in http
// module -- no client library, and nothing happens at all unless the socket is mounted AND
// an administrator has explicitly turned the integration on.
const DOCKER_SOCKET_PATH = "/var/run/docker.sock";
function detectDockerSocket() {
try { if (!fs.existsSync(DOCKER_SOCKET_PATH)) return false; fs.accessSync(DOCKER_SOCKET_PATH, fs.constants.R_OK); return true; }
catch { return false; }
}
function dockerRequest(requestPath) {
return new Promise((resolve, reject) => {
const request = http.request({ socketPath: DOCKER_SOCKET_PATH, path: requestPath, method: "GET", timeout: 5000 }, response => {
let data = "";
response.on("data", chunk => { data += chunk; });
response.on("end", () => {
if (response.statusCode >= 400) return reject(Object.assign(new Error(`Docker replied with status ${response.statusCode}.`), { status: 502 }));
try { resolve(JSON.parse(data || "null")); } catch (error) { reject(new Error(`Could not read Docker's response: ${error.message}`)); }
});
});
request.on("error", error => reject(Object.assign(new Error(`Could not reach the Docker socket: ${error.message}`), { status: 502 })));
request.on("timeout", () => request.destroy(new Error("The Docker socket did not respond in time.")));
request.end();
});
}
// Lists running containers, annotated with whether they share a Docker network with Site
// Gateway's own container. Unreachable containers are returned too (flagged, with a reason)
// so the picker can dim them rather than hide them.
async function dockerContainerOptions() {
const ownId = String(process.env.HOSTNAME || "").trim();
const own = ownId ? await dockerRequest(`/containers/${encodeURIComponent(ownId)}/json`).catch(() => null) : null;
const ownNetworks = new Set(Object.keys(own?.NetworkSettings?.Networks || {}));
const running = await dockerRequest(`/containers/json?filters=${encodeURIComponent(JSON.stringify({ status: ["running"] }))}`) || [];
return running.filter(container => !own?.Id || container.Id !== own.Id).map(container => {
const networks = Object.keys(container.NetworkSettings?.Networks || {});
const shared = networks.filter(name => ownNetworks.has(name));
const reachable = ownNetworks.size > 0 && shared.length > 0;
return {
id: String(container.Id || "").slice(0, 12),
name: (container.Names || []).map(value => String(value).replace(/^\//, "")).filter(Boolean)[0] || String(container.Id || "").slice(0, 12),
image: container.Image || "",
state: container.State || "running",
networks,
sharedNetworks: shared,
ports: [...new Set((container.Ports || []).map(port => Number(port.PrivatePort)).filter(Boolean))].sort((a, b) => a - b),
reachable,
reason: reachable ? null : ownNetworks.size ? "Not on a Docker network shared with Site Gateway." : "Site Gateway could not identify its own container, so shared networks are unknown."
};
});
}
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.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;
}
function commonHostDirectives(item) {
const output = [...accessDirectives(item.accessListId)];
if (item.compression !== "off") output.push(item.compression === "gzip" ? " encode gzip" : " encode zstd gzip");
for (const header of item.responseHeaders || []) output.push(` header ${header.name} ${caddyQuote(header.value)}`);
if (item.hsts && item.tls !== "http") output.push(` header Strict-Transport-Security ${caddyQuote(`max-age=31536000${item.hstsSubdomains ? "; includeSubDomains" : ""}`)}`);
if (item.tls === "internal") output.push(" tls internal");
if (item.tls === "custom" && item.certificatePath && item.keyPath) output.push(` tls ${caddyQuote(item.certificatePath)} ${caddyQuote(item.keyPath)}`);
return output;
}
function proxyBlock(target, item, indent = " ") {
const targets = Array.isArray(item.upstreams) && item.upstreams.length ? item.upstreams : [target];
const output = [`${indent}reverse_proxy ${targets.join(" ")} {`];
if (targets.length > 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)
: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>${title}</title><style>:root{color-scheme:dark;--bg:#08101d;--card:#101a2b;--line:#25344c;--text:#eef4ff;--muted:#95a4ba;--green:#62e6a7}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle 260px at 50% 0,#163829 0,transparent 100%),var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif}.card{width:min(620px,100%);padding:44px;border:1px solid var(--line);border-radius:22px;background:color-mix(in srgb,var(--card) 94%,transparent);box-shadow:0 28px 80px #0006;text-align:center}.mark-stack{display:flex;flex-direction:column;align-items:center;gap:16px;margin-bottom:40px}.mark-icon{width:88px;height:88px;object-fit:contain;display:block;margin:0 auto}.mark-wordmark{width:290px;max-width:100%;height:auto;object-fit:contain;display:block;margin-top:0}h1{margin:0;font-size:clamp(34px,7vw,56px);letter-spacing:-.05em;line-height:1.02}p{color:var(--muted);font-size:17px;line-height:1.65;margin:20px 0 0}.foot{padding-top:28px;margin-top:30px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}</style></head><body><main class="card"><div class="mark-stack"><img class="mark-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAB+RUlEQVR42u29d5xlV3EtvKr2Pjd198xolBMiimAyxhgbm/RwwjY2Bj9scOB9BhOMCcbY5ByMsQ0GY4KNMTmaDBIgJESSCBLKAUlII81oRpNnOtxwzq7vj51qn27Zz36gEdOn5nd/03379u3ue0/Vrlq1ahXQWWedddZZZ5111llnnXXWWWedddZZZ5111llnnXXWWWedddZZZ5111llnnXXWWWedddZZZ5111llnnXXWWWedddZZZ5111llnnXXWWWedddZZZ5111llnnXXWWWedddZZZ5111llnnXXWWWedddZZZ5111llnnXXWWWedddZZZ5111llnnXXWWWedddZZZ5111llnnXXWWWedddZZZ5111llnnXXWWWedddZZZ5111llnnXXWWWedddZZZ5111llnnXXWWWfrx6h7CdbJG01U3ABARIqvRxOR4tZZFwA6+0l0eGYwEZqm+R87MhHBGAPnXBcQugDQ2a3d2BgQgKZpivsXNm2kU2572+Htbn+7zbe57W1POvqYY47euGHDpqpfDeHQ1NPp5MDi4uK2bVu3brl2y9ZtN9yw/9prr13Zt3uP089jjIGIwDnXvdhdAOjs1nLaM3Ph9IPREPe+z302PvThD7/fzz7o53791Dvf5SHHHX/cvYbVgOObLup/CjcJt6mbYfuN2y+95IILPnP2WV/95FfPPOviCy+4YLGZzW72Z3bWBYDObmEzxhRO+MAH/fymxzzudx/5v37pl590yh3v8OABWawAWMYYK5PJtHFN3Yg4B0mODxBAAoi/xxKzZWOHvX5vjgboA1huJrjqiitO+/JpX/zXj3/0o18875xvH7i536GzLgB0dguk+i44XX8wwKMe/du3+z9PftJzf/bBv/A0C4sDmOLAZHk6dfXUQdiRWEeChsBOBI4ELtXzlDIAAGAhGCJYkGOHmoVc3/YGG3rzdgEG42aKs790xuv/+S1v/ocvfe4L2xPmQNSVBl0A6OzHne4nRyPg957whDs+83l/+ca73f2ejxxDsHu6bzqpZ1PHZGcstoZDLQ0aCBo4+JNf4CBwcAAoZAIhCAjAIBhiGPhbBYIFwwrX3GA6NFVv82DToAJwzje//q43vv5vX3zapz69LWYDETDsrAsAnf0o031r0dQ1AOChv/yIo1/44he9+v4//4tP2o8x9iwfWJyR2MaIrcVhSg61ODRwmKFGA4EDvPNLDgAuZQA+AHD4zIBgwLDEqMCwQjBk0COLvrBjx3WPDR/Z3zjog3H65z/z2le96MWvvuT8C5a6sqALAJ39SNN9hmu8q55w8on2xa9+9Z8+9g8e/5YpCDtWdi1PqeEJiZ3BoUaDGg4zNPCf+1s6+YPzC4CMA3jXJ4gPAAIwERgMA0JFBhYEC4MKBj0ysDDowaByVPdgcMxg82C2fPDA2974j7/3d69+zRcmyyvSZQNdAOjs/zHdT0g7AU/+s6fd+7kveMEHjzjuxLvcsLJzeYyap0bsVOrg8P42lQZ1yABqSJH2S8wExEEIEHhHp9ADIALYAUQ+MHDIBFgYFbMPALCoyKAnBhUZ9GFhaoxHtuqd2Duid/H3z/+Pl73g+U878wun7+iygS4AdPY/SfeV0/zMz//cxpe8+lWv+YUHP/RpN8oi9qwcWGxYeivU8AQ1puHEr1FjBoeZNGgQnD8AfhLT/9D8c+KDAZFP+pkI3DqoWUIQEgKTLwcsfBCw8I7fp5AJ+MDguMb0mNER80MQ3vu2t//R61/68vftuWmnY2b/O3QgYRcAOvtP0n3mxLjbfPRR/Bd//VeP+uM/e+pHpr2+3bp802LN0ptSwxOpMSHv8NNw8tfB8f3Nn/ROxJ/0MRCE3p+IQPcAiChdDAQAQmD1OYNhyICBFAQqCtkADCpi9FBhAAN2qHuocPLgqMEN1179nde94IX/+1Mf/PAPU2BzTSYhdNYFgM5WE2t+9w+fcKe/fsXL3n/iKXe4/w3jneMDbuJq43pj1FiRGSZoUKNBE1L9ePI7+NQ+1vflTcLJH01C+89nAcHzEzeAQIAgBAL/uSHAwiSA0ISPfQbAPisIZQI3NN482jB/JObwxc9+4lWvfM5fvvK6H1w9jYGuaxneCjLN7iW4daT7ESy7013v3H/ru//1pc943vM/Nt5YnXjD0q4DKzyrlk1tlzDDssywghnGMsOEakzR+BJAmgT4Nf4A9xkAACeAhNPfQdCEzADUYgBQ4AIBEPLO78FCagUVgZB/nphtNKHjUJNgKv5/MmTHs+l072x5eo+fut/DH/P7v/eH0+n06xd877vbXONgTHf5dRnAOj/1Y0+/6vfwFy954a8++elPf7fduPGYG5ZvWpxSY8fcWO/wNSZoUrrfSBMQ/9Wovvgnz9Te4OzeuSUFhlD9p1S/9cuBQBDxDs+6XEAGCH1p4LsFhhhGOGUEQ6rQh0GfKnCD6Ybe3OhYswkXfudbH3jFs57ztO9/85z9HUjYZQDr/tR/8C8/4ui3vf89//Ko//341223K8Pt470rE276izTlRUywhBnGqDFGjYT4S4OaxJ/4ImhIpfYUHJfiaZ6dH/ANv+zMlIIFiJSbI7YCfCYQvuRCkSAxcaAMLkayURPwhppCaQIHMMysqes908WVk297x/v9zu///p8NBoPvXfDd714zHU98NkDosIEuAzj8Qb5Y+x534gnmRa979VMf9YTff/N+1Ni5vHexMU1vjJqXZYYJaoxl5kk9Mb2XJpB44kmeW3ukTmjRaT0ASmBgdnFOdX8eD2pATMRgEYQqogAM81XjkQMO4UQSWEiBSmxhQb5jQAZ9suiLRQ8VrON6zvbt8dWR9vrLL/7S6573/D8+8zOf3dZlA10GcFin+/HUBwFPfPpT7/3mf3/3afd60IOeuGVl53hPvVhPjestyZQWaYolzLCCGhPUmFI48cWhIe34oR4HSqcvPHWNe1WPP36cWwJMERBkQCgGCWqXB3p6MOALFPEBSkGpURyEJgQxMLgW5/ZNF1c2HX/CXR/9e7//Fyff5rbbLzrvvAsW9+93ERDtCERdBnDYpPvxVLvvz/7Mhhe/9jWvecBDHvb0G2Uf9iwfXKytDLyzzzCRkOqHNL+hxqfUoYPvwkktwbVJNfNyFiDq6C/f7nzWS6j+9XAwwRGYwKH/Ly4VDuJ/vuiMITy/E8nVg8TAQgWRyIY2YmwfVqjQh4V1XPdR4fjBsYNd27Ze/raXvPQxn/rXf70kvW7O5bSlsy4D+ImKrkGRxzmH+U0b6XkvffGvv/ptb/72EXe8/c/9cHn74qIbY5nraglTLGOG5fD/BL7Or8mf+tH5E4tPl8oSS3dKtX07VW+Ddxr5J6Jc6wMwBGEiAYhYSBCyhNgx0NJh3vnL3yc0DwLLMJYOOWtpSFDD4wMOQEPgGuA904PL1eaNJzzyNx/ztHvf9750+YUXnbP7ph11LJu6bKDLAH6iXlXD+dT/9d/9nds+9xUvfdcpd777Q6+bbq+Xmum04aaX0H2pMSOP8Ecyj0+fXWLuZVfKpz3HAJBS+TLNF6HWG01lyR/uE2ImEUeSH+MZgoo4FHuCFJiEIc/w3IKcDjBCC7E4YWI2wAl9MMTowfp/ZFGhQs8ZNxRTnzQ8ZuQO7Nv1zte/8X+/5/V/85VmNummDLsM4CcH5ItMvpNud0r1hre/9TnPetnLPjc9anS765Z3LI5palcwM4sYYxm+pz8hX+vHXn6c1Gtizz74rK/78/ntJH9MUMKfrdhenPj6Jv7xnMNIep54pOfAktKA/JwhOMQMhIosIbchIQQhgnPAzDlMRSCBK+A7GR4r8L8SmZ0rB5eXemb+fr/0a0+8zy8++JTrL7n0KzfdcL0nEAVJss66DOBWB/IlJp8hPPEZT7//M174gg8vHHX07a5d2b48wYwn3NglmWIiM0xRY0oucfZrNEGgw5NsaqHizI9Ox+kUzj0+73iSXJjSyZ77d4QyG6DQyhMiZhFHGTnwXw3ZQDzxi7Zi/J0S8Oe/ntWGM0LofHoAW1mwtf70hwWDMYVDUzv0YDHPPfSZYVD5YWQhVzcyXehtnp9fmY2/+o5/f9I7XvXKD+7bdWPTzRV0AeBWC/Ld5wE/s/CC17/m9ff9xYc+5cZmL/ZOFpcb2/TGMsNybOtJDf/PoQkgmoPv6afSPjHxJDkTQ/HzRdX4kk/qVXW6pvcVuQH5PJ8IJAHhI1VSpJ8bnDqc/r4lGBoFYaAIRHBqksA1/u+pKsbA9mFgsbw0xvVX7ceOK/djz7VL2LttGQd2jjFdIZCZgx0uoBoOMViwOPKEBRx1yibc5vYLOPXkXn3ikUN7Ii/Yq2/c8v2/f+YLHnPOR99/dcwG/Jh0lxF0AeAQgXwEeJBvwwZ61ste+Du/99Qn//t0UI1uXN69POParlDNK6gxRY2JzMLQjp/bb1J7LAJmsuZbEg/9mN5zdHgpATmkx+RSQIojWT27AMLMijUQ2cEqi8i1vajvlvaVE/CGxgkaEfSHFSoMsH/fGFd9YzsuP/1abPneVuzZsggsA2ALsAFsH6hGgB0CpgdwDyALoAcMNgIbj8GmE0/AKacu4MQ71eNTHzCYv/MdB7jhU59+2b89969ft+2qyyf+fTAQ13EHugBwS5761qCp/UX3K49+1MnPedXL/+Wku97tl26Y3lQvNdPpTIF8Y3jH9/9c4M1L6JFrtDx37nNzzp/grBJ5JkopfyTspxEeJhCYWeAo9ONT/q6yBABwHukDibgEDgqXgGLE/lSJEPv86cQPqf5g2INBD9dcvBPnfehKXPzFq3Dwmv3ADEDfggZ9UK8PMj0IVYDpQagHMj2ADGAsiPs+MPTmAbsBDTYBdjPQnwM2cn3KXZz7zd+YH91l856tp7/5db/zuTe96dxmNvXZQNcy7ALAjx3kU+o8J55ym+qFr3/Nsx7+u495/U1Ywq7lfYszP7HHnrs/88M6UqcxXaf0+GLtnD0/03KLFD98bgqqbqzDW9t9iGCEs9BvqtopdwskBgCEAACXaMJEKZNYy5cidVjCnEDtHGyPMTIjXH/pPnz5refhwk9fBtk7BUZD8KACYs1OFcADEFcAW4jpMbjvQBYw1mcAXAF2BFRDwPZBNAAN50Gjo0D9OdgBwW6qp8ffqT/62QcYHLH7ex/80iv/6hmXnX3G7lwWdNlAFwB+nCAfAX/0tKfc609f9PwPjY475i5blrcvLqO2tYmDOw1mobVXS+1BPsmyXH4yrzWPr87+lManIzgIdoTHOMlJuShMgCkGEFbZgazi+KefRsQOAAU0rWwnhp/Q4gD7r7MnJwkwN5jDrp0TnPnGc3Hu+y9Ds2cFWOiBqwoCC5HA8ScLmD6IewAbFtMDTB9ALAd6APcBM3QwA8BWAFX+vt4A6G0ADTbAzPfQ2wSYBXHcb6a3v9to/r53dNj+mbc+7qtveOlHFvfuEWIOf3oHEnYB4EcM8t3v5x+48Tmvetnr7vmQhzzlRrcXe1YOLk5N3ZtQzdMgxzULLb146tdKiyf2zLVfGTWPF3vwVFTcXqSTROMCUjxLnurjVeIeotj6uiHowUJJjh7Bw3JxiGb6h8yhAWzPYGCGOOczW/CZl52Bxcu2A5sWwFXP/41kvAOTBciA2EJMP5z+FYvtO+JQChAxmR7EDgAeOLAFTA9EFYR7QDUAVUNIbwE0GIAGgNkomDuaMJh39fAI5jvdrdfbtPfKMy59y8v+5OJPffDaLhvoAsCPAOTzZ6VzDvMbN9CfPf95j3zcs5/+8aWe6W1b3rk85dqOqeGxTBN7rwnCnI1oGW54ao+oAzWk/L6m5+RxqZ+f3C+f5xnU8+ezFNAcA5TPd8bqSQAu0cLQy/fc/TILyVTjEukDaicYDUc4sKfBp1/9TZz/bxf5Q3xk4RoJJ3oVgL4I6sX6vgcx8aTv+8eRBUzlH2vi94XnoL4HB20fZIdAbwSpeqA+gAHBbgJGRwEbjyAMN9TjzScM5085Flg848PP/ubrnv9Pe7b8cBbLoq5l2AWA/9arYowC+R776FOe9YqXvPPEu9ztEddOt9eL9cp0apreclDmmaL20tuSB168BFdm7et6WnzrLUzjlZh7POUZAlDk5KM40Sm6v0h5ysf6PWj8kTrFCwKQ4gUKMnPPBw4p8P+oLdDUAjaEud4Czj9rC/7jeV/EgctuAh+xEWAbSEkMIROct+dP/ZABIIB+MMH5Taj3qfL1v7Eh5Y9ZQ+XBQNP3gcF6TADWAD0C+gCNCNUGYHgEYW4jML+xqftzcCfcZjA6arbzB5e9+eWPPe/db70AELCxcK7pQMIuAPwXIJ8a1z3+5JPsX7zu5U//5d9//Bu34yD2LO9bnHLTG2PGXpHHi3IkOW4R6H+pQ60cVc+8M5XsfGlRaqHaeVGuO2YHImV5Hj82KZuIKr86wMTuAYr7RLH7SHJ2EH/rWd2gP+phOiGc9obz8fU3fQtoHHh+AOc4oPrhpI9OTf4kJ6ogqgSA6YFMH8KVygpy0PAZQ+UxANPLN9v3OIEhoAJQEWhAoAHAc0BvARgsCDZuBkbDZjq3uT865QRG860vveGbr37ui7dfduHYZ3Vdy7ALAP8FyMfW4A/+7Kk//cd//ez3V8cec+rW5Z3LE8y45sauYBqm9XzK7yCYab39sGMvc+cLCr2qsyWw+JSDSpbmJlDR148pPKs03SHTdqV1spOu6xVHQESzBuOPZZCIEgXJjD4ngvnBHH5wxS587FlnYvvZ14E2zwFkvE4QGYANiAce2Y8lQAgAPhhUIGNT6y9nAMbfz1UOALEMMOE+E+4zPX/62/BCVAT0APRCpTAAqgVguIEwtwGYn3euP2ymxx4/mj/aLe669r1v/L1z3/r6M2ZLB6VrGXYB4GZBvnve/37zz/37v/n7uz3oQU+6od7l9k2WlsXKYEVmnsIbdfjS0E5I9UWDcup41id+cDAigFxszWtKLykcQJUE4VQ3KgCoyFV+WuQU4R4qfx+S1Zz/HJz8rWkEvb6B5R6+/J6LcdrLvopmzxS8cQjnOKTrIb3nKqD7VQD1ArLPvrYnrnyGEHEB01NOngMGmR5ge5CIA6ggQMZCjIcYYCklCv5zHzt4SNyfA/rz4nojYH5B3HDYTPvDanCH21Z2+MNLT/vqi575hB9+/ctdy7ALACWTb25hgZ76kr9+1KOe/ifvHQ+r+W3LuxZrauyMGjsJJ/40KPFOkcd0Y49dkDlzogG9cD8rJ6NV9Fwp03Q/ylcM52QNPko/E4mAq4OCmuZTo8JQnIAo860vA0o8f0/lHQ372LO7wX+84Cu49P0XAnMD0KCCNJwc29fqypkpAoAViMLJbkLqTt57yVQ55eeeD2vx+2w87QMuwCobMAwY8c5uQxkQA0JsOPQIZiSwA4LpC3o9caMFYH4BbjBopsceN9pw0mA2vfb9//z4b7zh5R9f2b9HiI3P2NZpy3DdBgC9Z+9XHv2ok/7slS9951F3u8uvXDvZPl1upvXUNL0JZpiKB/lirR/Vb5tWGy437aRE9KEm6oRAJJ6I0z7IUfbqJSH5mbzDYdpPCvKP5+S7OJmHkgrsM4jypC/IRgkHEDQOYDYY9YY478wt+ORzz8DilTvBm+ZCqDHJwZPzR+DOREBPf90GIK+X2oEwHAg/vVQepGwhOr9VAcAYgMnX/gY+CFSUmguw5LnRsdoYUHh6ga3gen1gOA8sLAjmhq4eLjCffHx/MHfD1V8/97UvfPzFn/rwFp8NWLimXn9+sO5APhPGdZ3DSbe/bfXSt77p2X/6ypeetnT08I7XLe04sEK1HXNtl2Xq5bdRYyJh8YZauhFBuJKOT6uiqw4CeqSW9LgtRZouZ4EO5G8kPYarxDlJYQpxIKdAASjBCnlIKDl/nuUVIsxqh/6wwswxPvOG7+Bzf3EWpnvH4I3zcI6Ck0bH7Svn7YWefS/z+ePpbmMJ0MuYQGz5xcDBFrCDUOPHABAzAusd3JJPIAxSICBOoIi/jwGw+MfF1874aqepRWoAYtgKgMXF6ZJsPvZOP/U7j3n28Xc4dfeOiy44f7xvtyOmkDmtH2xg3WQAGuQjJjzhz55y3z9+0XP/o3/00adct7JjeYyaZ+SZfBNkCe5E4ZWc8CtgX4/Ilym9lFJ6lNLsiOqLctWS9gNFA9anOCvEX/f6CSikvnNLjxIGEGf/22+8OE9OGg3ncNWlN+FTf3kmbjxzK+jIkXd6CStDQ61OFBF/Dg7fT6c/sQ31e6V6/uGxbFR20Mv9f9Pq/xuT24KGU4oPDk5v8uf6a7Ec8BCEDxjcFx9r/NyR688Rj4aC+RHcqO/q0UBw4nHD0dy+XVef//oXPep773n7JQkbWCcg4brIANgYSFCTuccDfnrhb97/rjc96ilPfueOwWTT1vHO5Ympe8uY8lI49VeiOk/Q329I1HptrazbCqGrRDR0Wg4YIpYMwQWCT6m46bfvEAMkXLQOaY1wTQVbkNowIGVUXwcfEUFDQF0L+j2Lud4cznjvZfjwn3wWB6/YDT5qwffzoUA67gE8yOk89zOin9D7ePr3c+rPJqTxMXsI/xujUv5eLgFSEGB/+msHLwKBQDhomFr4MsGqJkakRYS/2zHICaF2hLoBOSESZl46OFma9OePvdtv/+bT73iP+2HXZRedu7jTS5FFfKDLAH6CT/24eGMwGuJJL/qrX3vsc57+ieU+925c3rVYc9ObouZxPPXFb9mp1eCOluJyQIti23oBpUz7odp57NdqsVq44fS5n6ftCAzioLWRAT9N6U/Ifi46WGsFxVajkKom8mMbEYxdg8Gwj6XdDT7/kvNw8fsuBg0B6hs4CSd2JPNA1/WxZ29A3IeYUOuzIv5wBbIVhDnQeUMQgXqcCVmF6QFVL7cTjYVwcPwqBLAiAEjABELKXwV8gAQxMYEJ3CNLICsgEoceMfcIxgpsjzDsA8MRMDcEhj1XD2xTn3TCaP6E+uBN3/+nv3vcWW/6mzOb6fiwzwYO2wCgx3V/4Vd/6dhnvPblbzvpXvf4ravHN06X3bSeGdcbywxTzMJqrUDmUQCf+y9euFRG6wAQMKkyhQ8IfpLiJ7gwgZeCgJ7lhybiSOuELzWCsnP7kWFStOLie8gj/+IaGLawPYvzztyCLzz3LBy8fD/4qI1hws/XwZKovD3VzzcqAFQt6m5PObsFGeM/jrW/ziZMbBPagPzHzw3IMMQSYLxDS0ynknMHBWJLkRTEBDghAVUxQyDACsgQyEjEA9Ln3CNUfUGvD/T7wNyAMD8QjKpmvGHBjk49seLlb3/vg6e/+LlPv+6cs/YC8H/PYdgyPOwCgNbkO+Loo/hZf/PKP3nEEx//9h1Yws7FPQdq4wYTrnlFwqhukN/26b0rU/01XihqBYCUnUvW2y8GbsLjLfyET/weVwhwBKYf5UWdmhMgJJ434I/1Yn1ngS8IyjVfIQeOf0/dNBiNRuiB8NnXXeK+/vqv1pAx84aN1okBuB9OfAaRZeGey4QeCyITRnrjzfi2HunWX0z5q/R9/uTXzq/r/XAfmQDwhVv8VgJEZwB+56BfWtAD+3kNODEBAIzZQOgugimpKhET2PisgXsCG3DHwYAw7Avm+oSFkbiBqcfHHT3acKx1uPTdb3/CV171/A/MlvYflgSiwycAEMGo7bqP+qMnnPrHL//rDwxPOfF+1y/vWPYLNpyd0pSnaDBBE/j7LqnwRt0MaeX2aaJWnfjlzH74FcIpzm3pLX9dsnbaxjknqmJn1QUgXU5oaTDkDoBO60kLcKqvSXguF5R6quECbrzyGnzp1W+vr/vqysD2H4JmWkNmB5Zh+j0/rtsPJ7dp3TRpp8oneEL0PTTvT/7Q/w+5OHHPBwa2ALPq7yvQj6LjU3b28GtEhF+YMvofW4IRAwD5mSoLRyyA8Y+V+GKG7yWWMGpAMJX4JKQi9HvAsA+MesCGITDsN/VoQDj52P5ALr/si195yXP/4AdnfP6mBBIeJtnAYQECsmGI86f+He9218Er3vXPL33s85790V0b3QnXL+1cHHNTrfDULtOMVhTCX0tcrgk0EpD0tO/uvwiT6jHxmtSz/Bmnz9WAdlQnWVkrUnBToi/wfAG/cJMl7vdkAkW97qKupxKIpDjg40k9PVthQ38jzv3MGfiPP335eM/l149Gmyc7ZPcFD3VyzG6M7vgwoK5BVQMzZA/Y9Yv0PnL4/ccKGDR9gC3DGAFbP+5reoH+GzIDk52dYgZgbAn4Gd3rR2r1CccTn1Jtn9B/8oQgYvgQSuFFNiQRLCQK1OoIHsZ0iyPdOexRFKBxQC2CqQNcw0xCvH9xumyPO/6uP/17j//LY297h13Xnfut70U6cZcB3BoiWKDxmp7Fk57/lw//3Wc/4wMrGwfH3LC8Y7mhxq5QzcuYYiXN6EtQ44trsrMklz6RW/sv2sV30eePAUBP9q3mBEh5f4vME13aYwVcBIaQbTAIaLw6+CoZTG41HX3KX6M/WkC9NMGX3/Le+jvv+wIP+8MeFpffOb3+xmc24+UVEIGOfOQjsPnXvyioATdZhhn1QMZjAUGqK/XTIhaQqL6G/f3sUooPq1qC+eYBPg6lRMwICLCx3ZdRfzLw1N8I9jESJwDU6gRE4IN9giEWLgUA40uo9DqbFK0BJp+4WIGxBGMExnhMclgR5vuCTXOE4aCp+1bcSccNR+aHV3/9Ky989mMvP/0z2w+HbOAnNgAQc5rxvvcvPHDTc/7+tW+9zU/f//eume6oD86Wpy4w+caYYUX19J1ImrrzirxO+TYX5Bpao7UnawQBf6Dkqb2Ugsf6n4idiCtbdaJkvUIfgCgo+3A5tusBPBa4AB6qcWCilhqwn2YUAIPBRlx98aXutBf+43TnJdfMj44/2uGmfb82/uH1p7umDkes//tp/m5H4YQnfUh6xz0czd4DoMGATI+FPH0XMBA2oc6PJQIDZDik9S5Re1mBfOFj7/xBF4AMJB7JMYWPNbsh7/gsifjjMwD/uVgCp5RLlQUCjlkDWbionEppjFI5vnqjIreAYlPCEqwRVJUvCeYDSDgciutjNj5y89yGY0fAlo/+259+6eUv/tfFHVubn2RsgH6ST30w4ckv/KuHPv6Fz/n8nj4G25Z2HZiaZjDGjCdBkHMW+/lpPRUS2yYv3Grj7Hmmvujvt1qAejEGp2255cY+A2IO6b3EH0lUOH8xhafVf2NrkAKHQMS5oAwUtwPHIoND9tE0Drbfh2GDb77/8/VZf/ee2i2tzFc9+7l6284/rPcd2LNGEeVfGTPPdPKfvwqbfuH5aPaOYQxgB1aEQ4vPgMh4jT8KuTcbBgIYKNb5I9WUVF7iXOuzjbm5d/7U8gsgHlM6+cl4Bw9MQAaJSwBhiM4SdVAMgQgJB4hpE8W2DBVIbQoKqSwwABsKzQhBZYGqIvQrwaBHGFbAgscGpsMKOOWkwYivv/780577zN+48guf2No+lDoM4MfW3rNomgYnn3qH/t9+9P1vesgT//Cfrqx3ut3T/ZOprQdLmNEiphirHXuNIAQAUnP6rWgtOd1OXre6wi55P+oTpnIKT/L4L6WFGRBJE3gFapc39WaecFmGNJJ7BqVaT+4Y1E4wHM5jcc9BfPKlb55++58/NuwNhz0znT5pcvWWv3TL45W1Q34YV5KJYN/XziDIN+moh/0R7EIFzJZhBhYxZTcVQD0GBwSNKoLtR4Tf99mS81etej9kBoZLfn+Y8Iu1FOmPDcW2H4FJYnsPJr7oanehT1IktVTgZy8k/q9pEuljSs+TyRb+NXbO4wONEGYCzBqgdmSEiQ7uHy9h05G3/ZnHPe45g/7c+T88+8wrIA3I/GSRh36iMoAo1vGARzz06Be+++1n4YSj7nbd4tYDUyuDZUx5RaaYaYEO5KUb+g91aKvcFfiZqtVLJZ2be9GISiAwD+gTjJf1ScSf9suf0ncpgcXYGVgtyqnVekP2EoZ45voLuORb38Xpr3zHeP91N873R4MLm607Hj3bsfvq3BaU/4vLQUAb7nMk7viyD2Lh1EfIbPsyUFmw5UQGCh5HMBDLDGP9x2IcFOiHkDH4o1WBfXHBQUL0KTh8SPXj5/HrEQS0kkG8FusvYQW5p+pbqOQDQNh3Hh6LHDw4t3kodQsAZvF6pQaoKp8RDCrBoCduYY6wsXL1qC+4/QnD0c4vf+WNH3/KHz53acfW5idpsOgnIgOIPH7nHB77rKc+4DnvedsVNy24o7ct37Q8q2SwiCktydRv3UGDWarvBU3i8Ps3PO2w13CdQu6llQ3QGqO2RTlASpEXeRceQj0fQea8vYdVsFGU3ULfrzX5o9l8cXAoUlwbBzsaoBbBV972gfpLr/wXuJXJsMf8xskV1zym2Xdw93//BTfAZNsKbvrM+2h4yiJtftgjgUYgXJPpcx4D7sUxYPH9tUpSX9940I+MSWg/Wc5tvcDgi1BCbAFS5flKKSMwqk4PwSOl7XkdUiofKDi2aDpm0FQjVf8nmnTothS8bv+GBgZm7hJEGrhzJI0DHBMbYj6wOFk54h6n/uK9fu03Hrzl62d/ePGmG2s29idixJh+Epw/0nn/9G9f/ujHPPc5H79ivG284sbwU3s1puTFOhqRoMsnaT21E4UAay6dX4Ct+PiSt2hJ3rPnLxCdqqsOgCLfRIVdUvVB5g7koZ50glMW8ySNLgo057cIUpw4f34k2DmHueEG3HjdFnz2ZW8Zb/v6pfPDzQu17Nn3q5Nrt31ZnPu/OPVvFmXNAOFJf/KzuPMrviRs51HvXwTbgYfcKdc/gdqbAD62Xn7LEIgZMOxbeqzR/fAHx4zAZPIPQi8/BYHI92d/Sgvn1D4FgED2SZhOaAFG8I9ITUhyeI85K7DEbAEqS4itREM+obEBKOxXgrkesDAkLIyAnqnHR2wazh853rv1c096wv1+eObnd7C1cHXdBYD/F3IPB+d/2j+85nG/+qynfvCa5a2LU3a9JYx5SeJW3Tpt0vWDLlmIM9b6xs/WJMjLO5txMcCUqbasSa/RE3jx1StIQeEa9RJgefS2yBhEnTKiRUGlqEHKZZ0xWPkQ0DgBWYOBHeL7XzzTfek176pn+1ZGfWu/OL1my/+u9x/c9yO7PIgBaYAN99lA93rHx2TzTz8Ck5uWIeI3fUIAjgQgExzQZGaf9TV2PPmlSOuRT2qSovbPeADyzD/HdEul/oHpR6EsII3NhMdSqYganFzyxwCLD0Au6igmgJBDPGKAybcJOXQK+j1g1COM+oINI8LANNOF+d7o5GE9/vyT/uDOV372I1tu7UHA3KqdP6T9z3jrG574sGc8+b0/WLp+cWaawUGMaUl8i8/P6ksQ64i6fFB0XgHDca6vCZ5Yy+JPVU4kHD1oq1V7hLSk9mpxT+hMnZC1evwIL6Nc3pWGhCRu6WlNFGlprlLDn1A3DnY4h3rc4LS/eef07H/4wMCYXmUnkydNLr/muc3yykqBHv4/m8SSYIKtH3o/+pt34NgH/1ZILSYwfZNpvrHet4rcg5TqS4gLEmGE2LZLpzoy2SfW/Tak/lSm8nHkgRC/nh2ddIqmTn/fMsxZQwi+rPgbFJAXAomQqCGsQlYtpBESh8QIjQOI2YhrpivC9n6Pfcyz911z7Qd3XXL+Hrb2VtsduNVmALHV9+S/f+XvPvLZz/zwZUs/PNBYGS3KGGPxCL8f4vFpf5DITFtt89iuqFNfp9JI/yfyjKCYnlt77AZFGaDpwazjQqj3DSFwAPTqbQoiPQKR3G6Mvy+1W4JEcM5rBI0GG3HdZZe70172T9OdF189P9iw4bpm645HzLbe9IP/O6Dvf0y8SCUBTn78XXG/fzwL/c3HYHlpEdYMEnlH1eY+3S9reWFN71UvJofXTaf7BD/UUzx3GPahsEo1BAaJ96uRTFKtP8nBgWMASSvNKfViE0hILA6rAoovKTi0DK1FaBf6VuHIAvNDYH7g6lGf+OSNtnf6k/7gDj/49AeuubUCg7fKABAn+X7taU+89//5p384/6qlLQdqI4ODNOYVmWAmDSZSSnHHFVqpx07l3H5B0A21v78CBCy5tUaEVch/6tpJJvzQGi8grVEepKAk7c5C2XZa662R0N93tYPpV+iZAb73kdPqs/7xfdaNaztgetv4yi3Pbg4ujW+xy4XIB4INPzVHD/zXd+P4BzxGlsdjIgdhawuyTey7RzafQSbuWBUxoQJH4OrHTEDI1+mxxody2KKtx0r5TNX46Vfm9EM4Zh1tL0iBmwgw4ij+/qJ4BIj0B4I1kd4g6FXAoCKMeoKFIWGuauq5AePEOcGnH//bp9xw9hduujVOFN7qAkBcvnnPh//CUX/1uQ9fv8Xtc1NM7DJNeRkTTKTGTAS1KGbfKsBsdUsvAcDESivHpVKx7Pur6fxWIIG+btvjwMg+XWYS+mDyxw8VNX97kVdWCXBOMBxuwP7du3HG6/51fNVp35rvzQ+Wadf+35hed+NXpHE/vlP/Zq8aC0gN0JDovi/6ddz3Lz+JxrA040VwNch/sKrXWbf6Qorfqs0lpudJCyA2JShPAup4yR4MjRlBEYG51UxJgKXk9ye+NY44VDou7FXNg0RpN3vpNcyZOGRYYA3SQNHQ+oGiUa+ZbpivekfUB2/82K//4qn7r7p4fGsjC92qAkB8cTaffLx95Te/8L3JcQt32zM9UI95ascyCYCfX7TZBE3+OFNLxcmaazVKiL6EaTvOpBuRoI0HpZhTEoColVVngc/VL2SRFRTPV27lkVVLu2gV+i9OQGww7G/CD7/7/fr0V7zN7d+yfTQY9s+e/WDLbzV7D+xFG4u4Zd+s3CU4+VePw//6l6/K/AmnYnl5EWQGRa0UZvRFzeWndUdMBWAnFMYNOKLyKniQrCLyFAGklYFF0JBamUPUYcwCrsQguAIYjLIKCgMo9B9DxmFiEDBAL8wRDCrBqALmR8DI1OOjjh7Nu8su+tQnfvtBv+1WFkViX7EDAVeDfkLAUz/0tjdsvu9P/dbO5V2LE1P3VxCdH2F8N/T3xanUXW/RQ3H2x/57ckCiVp6ec0mGi5ee6J4AWuVBqjg0H0BzAZR6r1/2Wa4A03yBoucP+OGm0QACg2++/SPTL776bf3ZeNIbNO4vp5de8xS3uPwjBvr+h+AgyAN++69cxFWfeieOuttmnHznn0fdTGEEMEyJ7st5jp+YSkHPhAlQEvVAK733w36knJ9Wqa6WXwuEnjjXQer7kRBi30GgHI0ptANXCULcjOCrho4jpuPCW9s0ABm245XJ4sY7nnSvjUffdt+W0z52jmcLdl2AVurvp6r+17Oe9LMPevqf/MvWpW0HGovBskxoghpTgdfn8wMxisUf33zOHrkqNafM7VcXBJRjp149gQTsVPho8QhXXw+lBiCp9Zox5ae1U62AJqcFluJ3FAxHG7D7+h3ucy940+Syj395rj/ob6Htu39+cuWWT0ndyC2e8v+nccD5nH66u8Hl7/88nPkO3eUhfwRbGch0DGNMAu5MZPrFoKDagkH1J7XeosqvYvwRr5Hi65OeoVh/4X/1Uq+l6pJIQLQG70M/jvJhkRsC1GoKUFgQk3cukhAqa6rlpcnybX7uvr+5eP2OT+y96Ds7bi16g7eKEiCm/ifc4y7D533zs9v22pXB2Lf5eBljnohfxFGHmh/FZtw8kUMa9KMSA2AltpGXZVDBrKeEGMkaUwBY3QFAmfOTQuFFJHUcOHQCibLaiCgNPyJG0ziwZQyrOVzylW+6r7zh391k78FBX/D22eXXPrM5uDzBrdl0l+D2jzyeHv3Os2TD8adi37InDmkGnlI7TfP+wYGBANi1mHupq6CSNolMPi7bgMk9y1ku/6oH7v8q6AVqqcsqdFdfTHqFmiTaMEXFIc6EoYgJLPSA0dC5uSFjY7O097OPuPdJkx1bas80PLR4wK0iA4iO8bh/fcOrNt/9Dv9r32TveMJ1bwUTmkqDWVzGIe0xnhL+E137r9LLyVN2muYrrXeb1oqPkofXdK9fKweVNN/k/MxExMR+0ldEVv9MRu0cquEINCOc+Y/vn37tzR8eENjavQd+Y3rx1X/vxtPm0Kf8/42SYM8Vi7j4E++g4++xme5w6oMwq6dgOFhmMi2RTyMJGIzz+km8I2YIbSIPVDBBuTGZNAgbg04O+h59jYGD8mLUVfpvrQBAqiwgnRXoMjIRz0oxiVASktTNbHT0/BHzR55w8IbTPvpN4kO/g+CQX1Ux9b/LIx92/JM/++5tO5Z3LE64HqxIHOd1mDkfALBqGw8K1D4r9DC4Jc1VbMtZ9WeLyirLCQC9wEPjVdABgLD6eUXATLBg5tBfblypCeAHlYDhYANuuuZ6fOk175juvPjqUX9ucKW7cstDZ9t2bfux9vZ/fG8q4BoADPql1/wW/fJffcLNAKysLJO1vSx2mBKxnLqvBfAVj2sFhNaVTEIMK14QhDwlK7ZTxRELBGA/Vpzw0wYeBISsKhVIEciSoKPKPEhdILH9zCZkAhboVYK+Gice9Vx97FH9wdmP+5Vjdn7r9J2HujV4aDOAcGFzv8Lj3vd377PHbTp1ebbcjLkxfvuueJVeCUs401IOWaN9ViLpWWE3ztMLc5wMUcgwhVNDg4G0BtqTKKaSqaZCZYZIrd8j4A5EIDRefCg9YeMcyFpU/TlcdPrXcPor/nm6uHvPqMf0b/V5V/xas/vA/lv/qX9zyYCEI9wBV3/5crnmu++gu/zcL9IxR98W48kiKq605n+s7dNgUEL+c9otCsAjXrsLkMl8QW9E8psjsUhncdKq44gCQdt3AyTRw0mtWSvHSIpSktrvk6xBIYvESBY36Bs+9i73uOM1H3vPR+AOLTnIHNqDwi/suOujf+V2D/rzP/nnvSu7VibG9SZSYyoOtUjW55cw4CNSAG6anklaV4/ybr2ANwUcxyP81AoU+vzX3TVRJwHJ2l03tYE7IP/FhUMOFBRAPUDYNA790QjTlQm+8qb34bvv/kzNo8HQTKfvm51z6RNlZdL8xJ36N1sSGGD3FYv4zkfeRcfftY+73e3hMqvHZGDSEFBM1U2W8Ur1FrcR/laHQGMKQZHFn8xrJLuSm0B6aWpyfp9BEFgksRnjX0OCcloUhSBEgTdQ1hskqMOGgYqJ3Wy6cuRdTr7Xviuv+fDBy8/bdSg1BG4VGMCvveklr5i/3XH3X5ot1RNuzKQ4/UV1ZOTm2zJ6MSZKdV72p3/gbbNrK/TRGkM7/1WNtEbQb/0y8WcTNeJDR1wh3h9uwI2X/RCff807ccM3L3S9YzYRG3PT7Kvff+jh4fytbIANMD0g+O4Hv0xNdYG5z0P/ANaNpXEmtfgKDT+62V5/lAknhQVATfARrwb/1IpmKbI6UQJt8YcGP2+PgSOOBIvuHK2FHrWEWjU/gcg3QIw4ZjILJ97x9tf/x799QJoahyrbO2QBgIKS7wkPuOeGh77szz+8vz5YT7ipJlJjikD2icM96u1cNZ1PCHXYakV+NVQjzCAHdp577yIthSTNk2KVGrDarNXa9nMzQSIJy2S8IbJLGhFw1UO/N8T5nzwDX37ze7Gy/yCqTfMzmR8O3HlXPsht23XjYeX87ZKAAFx5xuV0za6L6L4PeQKO6E1p6pgspTkBtBH91olPxTz/6pYPEa1ZIohq2UXnF4XiUpYOQqRpxkwybWMWUtOctMrly36wQqxIlQFMMIYJ9Wy6+Q4n/tTu8y941/IPLz1Axh6SjsAhCwAcaJwPeuFTfv/4n73vow+M94+n3Ni4pceF1N9Jeb5KuKAoXFCrFXx0n1+p5EpO4IlATLGdHnb1KZH/FrLQ1uVQqV4EBylt5CGhVBfGfnDjBP3hPGYHpjjjLe/HBR/7IrhXgUf9qcz3R7Jz33vdty5522Hp/GuUBLLj3MvkYnMqn3z7++DUTWOZkoHkIaDIAcjJlErpqYUBoATiV6doeUFqkglbtZs9rlrjvKQpdPsTlUAAuNhF0D+CUgsxlSvQHQMqphPZawuQhcwGc1XP9OfH2z//gTNoLcXZwzYAEEGcoLd5Iz/sjc//gBvJ5rHMaIKafO3vUroMfZO85QVtIAZqvbaOumVOEN8gNThOkluRqztBbUCwDTyz6i6kqzMw/ySs/hgNN+KGC6/CF177Ntx48Q9QHTEPYUAMNzQ/rPD1i39D9i0e+IkF/f5bcSA4X737bLlw+Dxy08Y+8LZMMEDd5CEh7eyKSkxUBuVVSxrTVGAE/jSnsx3eVdxXW1bTbKZerybxHFE/lLQQDKV98XHUey22H5FXOa6IrHXiNtzmNvfbevqn31jv3VHjELQF+dCc/v7H3uYhP3PShhNPuMvyZDyekuMZ/GIGF28hjYoKuNLaf6En+NpTdVKcBFkfTgA0IOeEXWT8+UUdmlzkLf7MhEFIXsOdruWidMjyYo1z4F4fVW+A737qdHzmNW/F/l17UB0xD2cI0u/V2DgcYf/SV931N12f+4qHu4UVq0uX7IK54mXy+csG8qYvjdksg46rvNZ/BT8MVMFLh1lAjGQKMa0OyAW9XsqyUVqZSHqnRb+/3mmFxAnBiVJeWoUlRIp32PCS3rqwwUV8tuCXjriwgt15kdHaAdMZsOIIyyuzMW0azh/7iN/5eV8i3PLuyIfyUrjjIx/8ayLipqhdUzi+5FVdxRvn0hsWT18u5kLRcuH8YCnARL9KU6P87UMqBhun237668iDPeInSZKGnBOHwXAey7sX8bnXvxPnvOfT4H4Fu3GExjLQq0BDW/PGecj1O9+CxmWSwbqw8LfuP/PvcNTGcXPJlt7sFZ8BXbUFfNvKb/y1AupTDgJJQCRTiP2cP60i5jsHSNO+ttvFogJrhfwb7VK7MI8zS9geVIixQsVrynucYxBw4rO/sCrABwGCEz8fMGuAaQ2MG7LjZeDIX/jVPwLZQ8IH4EPx3rumAfcrHP/Ae/7uEi2zI1jP9Etrs/xJKy4EBcCHZSph3FWNASWaqfKEsJOjkN0rznspM4u440FPA+liJL5yolp/YeU3aiIMBhvxw+9eik+89I24/rzLYTfOAxVDKgMa9iFzPSejgRXLkB/eeOYax9T6yAJWrlrE8vUfxoaNPSwdrJu//SKaD30bOFpAGyuAndceNVA7A6W1jllWaaiWSkpQFGxdl2s2V8SJJNG4xWV9iezZ0G3DfO3EXW8un/jtxREiAmn8+rGmAeoaqB3b5YOzenine/xWddKdB5k/cRgHgAiWbbzjbYbztz3xgSuTldqR2KTdL5KStJhuRzAwrq8unFFTMrWjSis+CCWh0JwGSsktpLJ1ky5XwmqOuJR/k3MO3OuhMn1888Ofwxf+7l+wcnAZdsOcF7C0BlRZYGCB4cDRhrmBLE0vlu1796yf9F9fCKHeXfre24jmgX6vloUK8onzgb85A9Qsgo6vACtA2D8KK3BJMjwrCuu9f56GLQCHBgyjoPv6pk/esAwXZEQDv4QK2Em1AR2pwyDl+6rMoNhnzqeYtLp7IqkcaEIWMBnPxmbjcH7hPr946qEoA275ABBS3WMfeK872eH8wNWzcez3u4j+iwuntmRMlsuazuk13hLTcEkCIV4XMKfm8aR2kjcCETkmcsykNQNbSSOt4fPpWog7+ByGw41Y2bmIz//9v+L8T54OM+yDe9afKlUFqiyoFzWkjMPcEDi4fDYmMwFh/Vl0rsXzLxCiZdj5AagPOnIj5Ac70bzsK+ArtoJvY0EDv28EFYG8dLgPCEo7MIKFHEeOoVvGopSHtGQ4AIoz5aQ2QFEOBE5WnyaSpYc07pA83hGLY5aGWOLWWaiJzwZoGkFdi5vWQA1gw71//mH+edzhHQDii7j5vnf5Gc/zb8Kor3f8Bi5nApIjawR5IjaQswMkmrBT90vrf6dWgHoegON8XShQSJcCWJX95cwOYSMxMeaGm3D1Od/HJ17xRtxw4eWoNs6FbRIG6FmIDf/3KlDPgirraNQDLa1c2spd11kZAGBy/Qrcnm/ALljiXi1SQeb6kOUlzF5zFty/nwdsBnCEBYwvCVAJqPJ7AuPqcKgBokIKvCXJIlJuBC6aTI447YdP97eWuYoCicLjxMVOQ5wkFfW9WfHZOcA14kuAhtA4Qg3m2RQY3OEeDwFVQS3olrse7C3u/0EOaf5OJ//0BBNMITxznviTnDilY6VmnhdqiMNcntTrCgEttX47RTdR/O1AJ3a5PhQhNCBHZTKH9oShpgYT/PhuNRqAZoSvv/fjuPDzXwX1LOxw4EeWrfFbb42XzI7BAIOwMWdQQZbH28pnX29mADcDJjd8BaNTHyFSO58fT4BKAEuo/+MC8A92w/zZA0HHzwPb69BGXqMV6KBOccmHR4uvJy63AFYBew5MHNDmCPUH0hDl9lDYRkyu2PMmEsVPHOWJg6J8APtP6wZohBhEdjZunD3uxPvShmOM7N/a3JKXwy0bAAIYQqM+jU489t4TmaAmsNf0jyd45Py7kLkZxd7iBCDF7T6xoqdWfq4XdOaejf+ad3gKiryxvSeK0iFh3XeZuESWXy0Og9EGLG7dhbP/7cPYfulVsAujrCxg2Dt8vEX1yL71N2shfQuMZ/uwni1yL6Y3nIv+RkAOOtQesEXDAGpg8wju8m2QF3wJ/KT7Aw84CdjZAFMHMuy1BJrg1B5IaqVvZQ8vzZFFNaD0JdXucRqJoywOIwKRuOpN/BQhk6PwvCCAXNpf4AQCckFhyEHtK/RZQ+N8FjCZzqaDTZtP7p10p42T/Vv3pF0Mh2sb0B6xYO2RC7ebNTPUJJxSeA3RhcjtFEiXu7tSovNpdVPeBJQAPim/Twq8rxwujthClJGWVkfROU8Wmx9uxA3fuRyfetVbsP2Ka2A3LXgSWRKGs8WNBhVo1AMNKki/AvUtqF9BGre8rgNAfPVnu7ahNwfYnl/CZweA6fntQmKBhRFkuoLmDWdD3nM+sJGAIyqf+lcAVeJ3BlqlK7DWIhAUaVyhBpdlhjWfIAQDB4YDS0NcAEFCQAN2Dfl6X7UAYycgtgEVYBWAQL9LYNYA9dTV6BPsCXc87pYuCe0tHvAFsAsjw8Pehrquna7PHVxoxWj0zWcH3O7+kKIFpx58QICFiung2NuNgz8SReBCSSEKCeDwxG2hCNcIqkEfMhV860Ofw0Vf/hqYCHZhzreMTEz544nvswDqW5/69yofCCq/vpZ7PRChEXSGZvEg7ACwIyaatvCYBpAaZAmy0EA+eRHkil0wf/4A0PEbQDtrDwQ2BJohE77qSNTJB8oqJmerwxc/8IS81vxvwvrIZxqtNF1CriBIOyHTerKkZQBdegpqR5g1gGU4IaA6+sTjAFx6SxaEtzAG4P80M+xb1zODppnWToRjr78M0arPh7wEQrSOWwHOZvBFFPfTQY/8omzltCJKEgijFhvQARtGG3Bw+y6c9a6PYMdl18AuzKVywaf5yvktZxCwMpDK+kDQt6BeBYJxNLB+11RnQLM88Wt4hyxx74CEVmEz86y6xtO+cARDfrAdzV9/CeapPwP8wsngnQ0wCadCndY9ZPQ9jpAL2no9/j1sclpA0cmjF0eCmU4LSEH1tLqxASlpAoVSmeSA5FzmBcwaABuOOaHVbzr8QEAAoH5ViWG4GnUDsbHnXzBttOpuAAP1rL8kOXCd07WKdkiaD5X2Zt80XpBbPim4hAc1ztNPB6MFXP3ti3DORz6Dlb2LsBsXAmEE/riwxpN8rA1O78E/WH/6cwQAw43YgvsGSU6G1nkAkNr5rcJ9/14bF96MWr2vNURqX5HPDyHTGerXfw3m6rujeuI90RwEZF+thD498QZNPJl9QIhj4/H9E1ldGqRIweLQBBUhqLYh2ktnEOm/ARwUd7OnuJolCKUANyCuG8DNbTryFvb/WxoEDK+1ZdMwpQDt4pJ7rbbaGtCXttQvrV7Xlt4Y0mRhWrV4N2UT8a2gOEqUf2TTAP3BEFVjce4HP4fLvvg1YNCHmRtCGhc2Q4QNuZa98/ciyKeyAd/39ySgKjzGGPaBoMsAEkDbGwB1pWh05IFAmgFNmApqArDrak8RtoLmIxdjcsVemGfdDzh2HrKjDteDzwbAvjxALVniLZV9pWqwcDjZhViM3ysfx8pFL4ZY1TqI15FzbeIIKfQfTmWyLqyvd4S6BjcAaDDauC4yAGHVu1fiHBIwAKEs1CHFppxW6qameGNdRYqzne+LWYCE9SA3rwBJ8KnZcDSPyU2LOPO9H8W2i6+AGY0ghv3vZk1aiS3MoIj294PT92zGAXoVJGQAPghUPnD0K6Ay1Hm/f6PIVhA7zNe/0ZF9tnpG27F/ozYTmku3wj33AMzT7wt64ImQHQ1oHIaHZnEAMQJ8AjhKp3YSFdGycHHhRMwU0oWbVZ1XgYqIrWkolWg1ySKCuGzUg4IECd2LJgwJia36tzggf6iw31rEd22injpR2ZggSRx7rcyh6zMJSxwYuXAv5QJzXReJGkmvvd3nDyk/M2FutIBrz78C577/E1jZfwB2w7wne1jDfgMugUScxHZfz3hwz/oAQMYkp5fK9/2psoD1G3TJGOZ+DzDUBQDfaROpAJihr9eN83wRI0l+u1Bn9cc6QI1vqi8YyGQF9Wu+AfvYu4H/4O6gAwD21/66miHxR8Sp8oByGim6HaiKen+9RV0X8WQh/3u41FZUZJFCI0DDCMQeZI4ZSMQAhGDhsQCB6d3CFcChCQAAqKbMtCw0tklUl18Pe+e8KrX2dDcgLeDU6i95TbBQ1uGPCG+c4/dbXASmV6GHCud/6su44PSzQQLY4dATkzxH2wkzkzUQJoaBo150bh8EYIznqvc84IfK+IzB9nxLsLJgZldVFabMnfMDPqD2AZlUgPQgduYvjEaCMEiYsEnkDAZRA4mCmq72Gde8oP7QhTBX7oZ59k+DjpsDba+DxFgoCRpAagI5f02AfQ0awT9pwLHQCEtGnR4OIT/sqTJMituF8hpylTDkQ09SyZq6E87vfqzF/2oCbvUhD7MA0KbZugT3SjFZJyQJ+0+VfMEKzH19F9BiUlVdW8hDiu/KmUJ0P2kE/dEcVvYs4hvv/iBuvPhKmA1zqf1Hxtf7XjDGORjLsOSS08d+f0D/xXL4OJQHtgpAIcMYA64YtjJg7kqA5ElDACsEND1PlrfwZBjnfBAwUpz8KWNMMyOhe3BEH80FW+H+4iD6z3kAcM+jQVsbn/qzbxWm3rxPPjLnw+Wl0bprVRCIir5TC9cDCqyAoKnjanGVyzMxEB8EGpd1Jg77EiC11xJZJ87/5889mJp1Gr2Wr6/gY1oY03mXRFxz3aXJRF76Ows5xr3wLuxymxttwO4fbMHX3/0RLO3YBbMw8r8Lh3qfCGICsm8YYslRPNkD008M+88D6i/GBwOKdOCKwcZ/j7UGPWvAjC4A+AyA0AfQE8BZAP10RPhTO7AraQY0daDWzgAx8OohhFRQuwa0cQhZWsH4JV+F+f27w/z2XRn7HLDcuKgW7UI/PzmxCyCg+LFeKjy35fxKkUqoNShUtKf93gBpd5/Ucklx4pzAzRzgHFfrJgBISJXiVF/c96eH8PW+NUjWa9SJs47MLmQL8X4JOnBOsloww4OC8UQYDeZx1TfOw/c+/jk00wZmYR6uaUBswsoqE7JO7+ASkH0fABhi4onvv0bxVhnAWJ89GAM2BmwNuDKwlmGtTaPRnSEw+gjSEOAsyFWANFm406kdXE10oBoQGyF8XwqQnzehnoVUNZp3nwdctRv8tPsDR/ZZdk8dVV5Ixs3ihcNMJE7Yg1KkWnWkrst0aTpisLhMR1EHj2SWqZYdz+PrER/Ij494gO87rIsA4M96B6CGOAQykHZmUo8senyhbcgJpJWg1Ygi5fLplrALVYIHYAiSZvctyBl87yNfwFVnnwPu9WEGfk8BGRuGOsKJzxQGehhc+ek+USd/JP2QuiXAzxrPWWcDriyqnoW1BGsYnf/nGkAYXvmnBjAjCPsAmrq/Dfk2IGv+NxAnOyTO3jgC2EFcGAo40qD51rXOXbeX+Zk/6/jUo4Ab6wDOUTo5ooxX+ty1nb9FD411bDiU9LVbdKHQYvbG/QJZXIRjpSPONesiAMTUPNT43IRMQKn8+ZMbOZqSAlI4fg0adFEa7yFzqMFOi8QLEWrXYDgaYnpgjO+892PYeclVMKNh1nkzJi2QBHM4wTlN9okNyL/J036ows1GGnBA/q0FWwaT5wlYY1AZA2tiAOi6AKHxI+jB8/kN+TFfZ3Mkj1McIkBjQ4swgoF16AbUPo0AgVztJcMEntCxYQDsPOjci88EP/l+kEfcHrLDa05Qbvx5klEtuUsU9f5Yt/rgt7qRXvEu/yl2lzhGcWjQFbrDAIgbn3WYdREAIILGp3YsgkKjhxXTLy18JOXZVNJ1GTECR8Vgn95J3sqYvr9xgsHcCIvX78a3P/hJHNh2E8zCnCf2EAeVmUD8YAYxg3oGwmGvU6z5jQl7nvznbCqGqXw2wOHEN8aRYRi2YMPgyqCyxgcBBipjwQEJWq/DwFBYGw0ACQM9vi9mvAMa5++Q0BaUgJZx6OioJSyeOWj9deA0BE+QYQ+oG8z+8VzwD/eD//BeHrzdP/Pv+1QC0YhATfbmzEsiHY/y/JDOHFpRIK2wk/D7CvIBp8RDYaLGxS1/HhyqEgASpL99/R+m+ETQUNR3oHK/WtyrG8kWkhWBjFrMqDIzxAM2ZozDuXnsuOgqnP/hz2A2mcLODSG18+vJKaTk+uS37Ov6+LHxGx8jwg9rQFwxmdgG9OOpPgCAmY2TgB2YeDMMYwSVtSAih878e2TDAW7h9f8a+LpfKkUPRjj9OTlOsZVFImmolzkCwclIgpTYRoZ8+jK46/bBPPsBwLEjyI4ZqM+eLxCdPuytAKu2VStUR3k6AtoKtiiXiUKNGyttgCBGKg0gt3T/75CWAHHMl4L4YnjBMkGT0tR/1PvLKrxqj3sA+vIItsoO9OSXEAajEa792ndx8afPALGB7fUC2UTpTHM4USpOtTs4BIEqMPsCyYei0AdbSC8EBQOP9lfWERuwIRhLMIbBzDBMYGawQdgZjI4KHPw5qf9GHKARTwWWGB2k1GQj3xEEK8AtgUN1LsIbhLahcrgj+pBLt0P++myYZ94fuMuRkBvrxNTTFHNRIiPJyVUcoLbDa9+XFugvefwjXcVEICdOkpjEesgACNQUYgz5/QLUlH5c+JkIP4Wea8Bk1ujJRrBWCMSM/rCPq77wNVzxxa+DhwMwkx8MYVOAQT6Fp7jGNQB8VvX2jarzCcIVk43cf4CtAVWVYzYgDid+FU599l0IYwiVYVh/KnQZgLoSqQdgKkDtabLURHaHQR6cjjTeloelQY54BYR2IcVTnHxWAPhW4fwAcuAA6ld8Dfb/3At4xO3gtjde0CNeYwyw84dVVBNOQIBTgULK4CSqi1X2BtVym4LoFigPzS2vz3FopgHDG+Uzq3xqi0MCVMLCLpUFlMIeekebnvSLu+ZFAGFGv6pwySfPwJavnxfAvhAYTJYQ969EwAA4nPJMoNjeSxlBoPca3yIUjqO/5NhasLXwzs+e7GMZRp38lQ0tQGb0jYExbDrPD+Gbket/Cz//3wQkXkzJsJNADkJQAIpZBIXSIKXadcAPyJOLYi3PDJEG1O9BXIPmrd8GXbsf9Mf3BA4QcNCBiSA1eV0BE7KLRh30gqz9176+RVqbLEX7P6DW1kNlB+uoDYikkBrBjyjoGV9YAsGE7gDFZZsiSa5LlAaglnZJ9F9jYSuLSz71FWz95vdh5+Z8ayiGdiKP7gaJaRB7dpaNQB7ldN8yyPTY9/nhOGYBxP6UsLndx+RLA2MYHE5+NgRrfQAwRGAiZ40FW2NW5y3rEwREwgDIZ/w28MVTCu7xF+8tVnFqkZVgk/KPupGq350vB+IyEHH+Y9lkIZ+7DLxzCfYZ94cc2YPbWwfgLgysaCygLTmvd0tqDcJ2X7sYOQ6BpA4Kgy7+IeuiBIgILgoZb83cY/IgH4U2C4ukwOAobwIW1TZMuSATjLG4/JNnYet3LoTdOA+pXdgwkbMPMp7hRybsFAwTfghU3sT+qyyEY39f/NeNBRnPH2NjfG1P/nvZGnA49ckQDDNsCAYVMypmWDZ5j+F6dvyAkHM/A4E09SQ/WLWpBwgIoJKIrqPzheDvAvmLJQnAeoQ+BH62SJRhNMGpAwtncx/uvOtRv2KK6nk/A3esnyMgS4mwntJ6Ci1DF0bJtXqwKuSFSs2ZFFACKk2idw1ARbRbFn45VD2ArNlPgKM8ERjFPrXcdyOrdf79i8zFei4HAvf6uPxzX8XW717kJbtEQhsv9usJVGVqbxT10AKeYgzIVqCqAqwFWevZgWx8ICG/6ZatATHYr6X2JQQz+dYfM5gDC5AINgQAS+wHATsIMF8TUeM/lgEc7mP49h/HtWDWv/5sPD5jrL/PGF+SwQBcQbgK71NgEMaPPQLrMz4y6XvhCNjQR3PDLkxe9FXQVbuAYy3EOEhPwg4CKraJJXSPpNWN8MFBC8/olIHgA5OfTASLCUy2Q1ACHJoA4ARNHLkOwiDS0t5PQSAtDMnLQlP7UAXfJuo/DAa46kvnYNv3LoHdNJf0BYQp7Z8n5oToU+zxG8X3t/HiqhimYmLLlL7fs/pg2FMODIMsga3PJJgprIEmGGNgrYU1DGsMDBtYZhg2bH2J47oKAGmChozPAMSoFl/waTH5/fNEq+DgMSCQSZ8TKn9/dHAOgcLY/D3Gen4H5c/hDGhYQQ4uY/aar0G+fYPfThTxiRAEyoWwUAKkglXLJXQGIGpfYFonhlJvfr2UALHh14T9f1Ko8ksY8PEvNkd0VWK2kEu7tMDFOfRH87j2a9/D9d/4HszcIPRxuRzCCCe1pLrfg4USFH6k8pRdj/LDwQbFWXa5C2A8iAhmR8wwZGGsJw6l2j+VASEQGO/8zASGOAMGMXdMQABERGSyk1HltwBJEwpCB5/WcwSKg+JSQuDNqjIbEhiDVAdwsM6eGsaI/fM1uW/MzrelBxaoa7h/+jb44Az0kNAhSFk6xfldtQJMSimBNSodxKnWsLQigYkJV6B10gYUkAtLGF0Ac0QBJHIzmEEKEOLluUPl5xV75wbYfsEVuO7s78DMDbLCQwBvKOZuad+8CVmBAgCNavUZDilflv0KYh6B2x/4/exBP2Lv+GxDvZ9AwNwJMGzgZQQIBgyijgpcgIDhIJeKfNofCUEm1NscHgOCwOR2oE4kRNLaQTTh43hRcaMUheqQgoeC3oWvsYfjyVSQoUPz7+eB905Bj7ozsKtOxT1FJp/eJASNA2ReS/oeDVhLWludh+CadZMBKLwzATUqEMQaSwIdSO1sS9uAXFwK6mCHA+zbsh0//Mo3YYa9zON2IbOIBB+/0yHUl5nv708U8oQfztx/X9MHYNDY5OiF44eAwJxPfGbyDh8DgcIGDBsYMFcAWDoQILV1Q9buIggYCUFGchBQa+L8UW+CTkC5rNPv1Mi6D75laAoE31uT0TpWnYS4m1IEGDHcJy4ErcxAj707sMev+BWrHF5aG4GjFF0E/iBh+o/Kpk/7/T8EBfkhkwRzSsCj3U4R/eJIpn5nhMRz90UAqixWDizh2tO/7u+27B8Ya8gAxVKozYVjJhDedDXnH3EBtLKCONZLPiiwT+vZkQ2nfGz7sS/ifFyhlPIbJt9dDAHFgP2/LgEICZ4Ip8wsAoKBGxCzAgl0XptPVIlBgEN6YEwuIh38fU14kkj4TCpDypxyQC3jE59oo4V88TIfkH7v7qDdDWiiyLtqUaWKAaXEmCCgWJnSvirXdeulBFD6PaI4/FBgYCRtEEpwkDItEMIAOcL1Z3wLk4MrsP3Kt/sofz+CJkBS9Ilcf4Jq+6m03x/9ICZHUdQjpPwc5MA8qm8CvTcGAfaaFYSQ7lOoHDz6bylzAliAgAF03q9OP4GETE2S40cAjqS1sQdICg+ATek/hPPkIBso0alwHUgeFokpP8TPHSC3DCkcFhSWiWKDgZx1hb9ufvfukD0NMA3Za1wUEp571c5CZBGa4hgUFNTjQ5EPHjpFIFGLONJwUBlBI49Cn/4cQrkTAfcHuOnci7C4dSfsqA/ULrO2QnsxrHILg8dx4i/U9brvHzMA+BZfAPmYDDsKiD8xZWEPsK/p2bf0mIV96w9gZufTf88B8F/3eEK8pgMe2UWA6B7ej13MAIjDad8gZ3TRWUxg4bmg79hwbvcRB/qv8jxpMnU3cQFiKh5bunW4yBjEog5udfwsVJAzLvcago+7B2R3EyYWKaH7RLm2LyoTnVUUn5ISwpFmnQSATJV0qq5r0sJGUbp+ue+f4AIRmMEQi1u2Y9clV8IM+37dKsf+jMtCIpzUGlPLT2K7jtnPnhuTugAgAlVB74/ha35jQESOfHsvfCuDGUz+YnUBYght5nj6+6yAiRLU4PfbBCITdylAMgOQJRt7/xHwi6CtJB6AWr3NUcqL4gNyd0AfwS6dOmp2oIU4i/HRxnmAMS8WRBYRdA60sQc560rQhgHokXeC3FinAEBOypJCEdtAa3UFokx5DG5yi6NCh6oLEGp9gXOSg6UWZNSbgHThIACsRbMyxvZvnZ8mAD3LKugIsNr8mGr+UAIYddrHDIAZgjDD24PzpB8GG3ZkQ8mAAOaBHSOCgeRiXU/MjmP/Pzi+oQBus0//DEIwEIIpl5V1fcDg6DDB+fXNJM0+7/QxOzShlnbRgaMOgN4egzQaKnF2gMICwOj4saccCT0uSND56yk0H9VegQ19uM9fBlroAQ88BdhR+46FK4HB4NTqmkc5AAAlLuC3FK0XDCAJf3plFZE0cRUZAYlcRYIGueVHALiqcNO3z8PswCK41/N1f4Bx46af2PYjDvlESv19nS9MzteYoSxQj4k1v58TMN75jU//fdpv0ilOxCDiRP3NQcBTfT0I6O83Yb4hkIC6EKACgDGAYzgJjOqIpor1Thv9O6XUoZND4nEgfwqY7OjxG8Tk7ZKJNCKBAISk+uPl6C2Ewr4BgpcZEwM454MAEUj8KlsaCvDxi0Cb5yC3Owqys/bykXGXQHt5pUL+U1tS04I9CHiLZwCHhgko4rl9aROTwnbCvjYXRqOcFDLicP0+Fq/fgYNXbQH1ewoogCL66FNe/Z9afPAAXxTtZAIqcWTDso8QCPzMvscDPNMvTBEagKPwBxOM8c5vDIW6n1IgoIBBmBCUmMjHFQDEHQYQm3uR3csMR/EFCid+fG9jiQ/dMYilQgjmQqyk3Hx9JhQYfxRYnBFdJHVdsFG/hIWq/zL1OD2X/2WEHdz7zwd2HQRG1vty/L2pXfe3herjtU6BFgx4cXDconygQ3UBkgQGYNwMrMeC0Z4FCFmBI0bTNNh74ZV57NOp3mpK8wNxJ+7oM6ZF8DFF64+Cc8NGwY4gBkKsMoJMCuKI5keHt74LQBRJP0YJgABMXDg+Ay7mEJ0BWRUmHNGB9ZeDenD8yA9p328jldvXW1Q4s+L/GxMChA8CFFsNtMZj/c2F+1wMIPEXE/jlL1haBj50IbjXAP1AV07zSVn7X1aFvLiYVuDpsACiTOwtmAccshOo1hmZbvWFFwZKCUgiNbhXYfHyazG+abd36IYYYpjEt+6Kfn+M7unUNqr+R+L/k4mz/hWYK3/q+1o/DPQQiIV9mh/afuRvJqT6hhlV6PvHzz3yH9qHRHEMGAYRA2AQuuWg8YKn2CZjxdHg3AbMQGAmhgoFXMCCYZCGsYT06R55HWs4egIYOH+96CYQQOwBXqL82Jg9OAKGfeD6PZAvXAHaYBAfVsrZiXqnc/ubXP7b05KL9VACKEWvRJYUkWIISAIDUIJWgBiD2dIYB664xq/iChE26YKIb+4TqTYf5Vsqyygu9YxZg/Ey4MakLoCvzgPwRxSwQsr03hb5p2T9eee3zDAUCD/xUhPy9xF3539xFZL3RxJQTPvV5B3CKU9mDZDQY0gOYZo0AcKso0VO26GDgzFh/iZ0f2Ig4NVpv8R+tMogwF6yTOYtcM41wAU3Ags27DdEa9dgOSREEfFu4BL239zyL/0h2w0YNRIjACiFrk+5WFEcwJXF4iXXoJlM/SruxgFWHBrPqKX4xqeeP+U3nfPJIkn002cJFJV8Y3BgNTMQxT2NZJpvIgWpEiDV/ACThNq/RP5NzAJAMMQc7u8wgNz08f5U1PZhJkAyKQ8uMANT2khJG5KCyEveD5nSBBS1degnS1rbHcpOqjNbl6JgSPgZFDQE0jdwFiUR54eXTr8MuM0RoEEPMnb5WoKUQGCZ/AQAEKplcLiDgJwofuxCep9XhcWySXK3wBrU+w9i8fptQK9KrRMhAEYcjDhYONKpf6DyRpVeNpx5+4aCco8NrEBK/f6YRaTywASBT47z/TET8B2GyPMn7eQU2n2x7Uc6m4hnDoE7HkA6ElOaTZRrew4DQVETwKIEByPhJ9wnRtLoMLiFFkYQoajzw/hwUQ74mxQlQ8wGAtBD4CKjQJCI37cIOuMKYI5zxskogO7VS+6L1vg6kQQjJmJWnZJQFwnS/150IQiCGoulH1wPN56CjUFk+SWmQIzsrDMBSuk+xX2vlAG9xPkP2vIe5Iu3XLv7p7IutfSIEknJa4dyPNVD6g82sXaMz5kwq5ABAB4LIOr2AkQ3oNiKy+99WqMVOPpkJPB1KM0HxGwRJu6QCG1flyngysHSFlB/sUhuQaRTmNRKn9giVOoeLheuQq4cWJvrQS7YCr7b8cBtjwH214nGSmqxiLT/bs0diJnKLZQMHJITKKbbWeTLvwAuyIClmwBiDZqDSxhv3eGBv/wEYYOPIvSYVssvUHwlrfnyYJCwX/2VVXwygGTIS3qZIOsd0/0oG0YsHKECj+rHk58T2k86C6Dk7KlshZAzfmFJBwKmM6HlE6mzIwXqjxZDkBRTMJaAuUPQKgEpk8Ione6awtkC+ZKKEGegMOpGBHIXpfZi+D4G5MwfgFzjs5c4i6J1ABU4WMiMyy2PAhyqFNQRsSJs5T5PBEMltFrIWIy37IBrXEjREXr9SCd2eoG18k8U9YxMPZOXffiln6FxEDoEcbgnLv1kG2f+Q1uQwWSEiQhswFHp16TUHyH9N47IOBOJP6n3TwkUDNSiuA+ts3AG6CDgHSdzPPzlQTkYqP5/VnpSJYJBMfwV273R4YU1MEhJ4g3tciB+L+tAwBl0JPJtx/BL0qACtu+DfH8rMGeSl/kOV+5sJTPgNRWEDu8ugCjUX7cBI5IbZMKMgVuZYbz9Ji/M6aJeQN4bEGcKBAwRyxHNlSC2E1tLYiPxhxQpyHMETHR+E9mAALhhooY5kDoo1PIc5NsM57SfU+kQQe1M9WWJeACFazeXBd1egLUOhuyPKHCBEBCYFBagg0PYJk2tbIGzk0tB/KFMDNInPViNIeaPJZ7yrLgDqVrgsOk5qFb1DOQ71wJLU69yHLKAGODyfgEldONTXl4nAcDrHxb9f3VzoTuAymK6Yxea8ST19ynWWykCR2XWoP3FpKI7khAIgo6f8lKAvfN7cJBidw5M4sW6/LohJnI5EKS5fk7UXwr7BzkGitRQiGvJU1MxZqkefegEQcL14AL/mzg6c6qMKe+R8M4eBDo574yUQBzSHIGUOZicPVAcFlLiA6Jbf7pFSKtBxMAvcDEbIDLl4a0k6WnPMnDBDcDIqDkAfXgp0FvA3hnWDQ9AJJG/4slJVGaEhiHTBivX78hgEPK0YMoUOQhIWk/lTaUF6R1/lV/YoeYD/KksHLI9L+6Z+vbsmCPvXyH9rBiiYQYgKf2E544IfwL8mFMXAMjzALlI7czXYeElMcRpYYuW1aWypY8WIzC1jGPZQGovX6TuUlkSFIxQ1s5PJYcACgMIgYAosA1DJtCqVYA+AxdtBZamoB6nlWCi8AA98RrQa9MCCg7TNmCk/8adoOE+UXppZC3q3Xsx238gROxwEaRhYRXVaa16LrL+rJKBplTXh6wcZBRTJ33NgGGcYeMMW8dsHcM4JuNiy4/UEop88lNaVJpagJ6kllaZ+/sDEahTBFKpsGrTJz8sp/nKkz3fRwobSCPD+vGUAzd0EGDVyjOcW3tqPsA7dFJxcCkbiOvJUxBQOAL5fZKyfwm4dDsw4FbPryyHUyADr58SILG+SY0Dxx0BoW8627ELxURVnK+m7ND5MEVWD4mAH9ukKoSkNgMAwnFgSFAAkS4+b6T9GmKfIARn9oGAHRURXD0FQt9fof4cuwBJOIZgQwjovN8XwCQI+xU0JVhSlyae6On0VyrCMSsQ3Q3gnBHEGjzrQhaAXm4XGs6ikVSe+qvYhBwDgX8MhWstRTAynrF60Q2QlRnEcskFUiKi0qSLaX3sBdAAnkt6CKoXahj1eILp3gOep682r6RyIX5/mhmgBPqkwZ1QXiQdAH9UM9gkUVDP6yBAyFHYO4GENyjWHzMsk5oFyPp/WgGqOOlBJR6lwG0DRuh6diYSs3NHKrXPszdS1PaxbVvIK7GiEaeVb/FAyMrfvn3MJeuTCHDsIORSMEiOTiWwELNONU1IcSlJaC/HTBVVBdm9CPrhLtAwLLRLF4usWop7KIwP0U9NI9zlYtXQEDWMZs9BuMnEt270FmDSEZ7S+vD4RlJq52TkNzP2KLMDmUDsW3Yg9mRQEjA75oARxAChRD/C8uCcyicuAWWEn0E5SCT2H6XvS8pAXQDIlwQXe3VVizZUxtS+Se68qFOfWjyB/DW1XYhagAK3ZwXUfUD5eAQsQQ0W5Q5BOPnjtqp4LV55U04iyvZnbm36z9eJLDgAJ67QSReRoLvi3+HpTXvVOxgmBUi1UARlSiiGAedE7V/PqGvs/ZIXIQuJAIV3hFQbT4QdhZZNOrVJ44rCBLjI9zdREES1A6PDF9yV1v3s6a9dCRDeKmZACI69vBpTEnP1WR5F1l4x2CUFKOilwcK1wfl485cHFSVo0g6Me74o3peu0NxGELQpfEGiKH7SZBEQMiGbbfzpNrCQ7fuA3Uug0RykqUGa/eck0gkBNOuGCJRSeNGYgDifnq1M0RxcCjvbXJkntYQWivlwMSxiuESD1RCQAt8pOWuY4DN5zLc4zeNocLxf8qluEuyQdf5M1PxTU3+s9GtN4gR0vt/GARMGwCjJQLG21zJ9FFe+oez/t7OANjdAn+gFcMytk17RdCkCfu3WoHp8Gjluzx4QMKmBK3f47ceh9peyDRC6g7ReBEEoZTtR+CP+gyG4g0uQ2SxMAgZ4sNX2ye2f8GJa58Di/FIQsJYUlnC0M4SJIyvfZwEmbO/xQcDABIePGQFpEA/smX7x8ygimrIDTt9j4vdx5gJokJATfN1ZOnz9C8Ta+XTHR3RQABTPg8qWYCujT/wB5deknZ5jCk8K1FkdJES3+UCtciGXndT+4dZArtoNjMNsAAoOkHohuFoXbUAoFqBIWBEeVi05AJPdeyHOgURaNVNJ8NE91bQJJr+w7EXdcsvHb58wqW6PraZI7sm6fpq+LSASRGpv0UGCziQi8McZG6AYPCJDNQSfrH3Web/3Bh0J83qMopevgcDYP/HvjVgpNAJojZM/kcZCNiHxUFFtv9jiLUbJ287ewgNyC1BJhbV4A6gscGAFsmsR6BlVlyjp8FVbiw7zEkDgN/42gRPoAkLqpjPU+w5kx+ZcJ6TzIKCzuk+cZrQSSKhkvv33OCBzAZhVWq/nRBIn3ecmUWEsPSWQufykWX56AIhQlqmqZSnQkied8+uyXMLK6LiYo2DM6qgcp0CVb2p6sPo+4VUgfiujoNQZkATwtW8KDEpIdE5J81wLlXTE1NIMOMIN+8PvqXCqzHOKq4PWQwDwAgDOL2BLKqlkGbKyApnUgWCh1FVFy4WJatdmiW8i8ik+awHPqOcnHE+UINbpCsFJ5bxMeTowMf4CHqDFPvLUX7oU8ph6YBVSSkfyYearEwKxoUPeB7o1OL9rXHB6ToCe5NAg4k/6wv84/0+MFjNQCrUvUe1BIV8OkO4msWKPYg1+QKIJq1PChKGQSPtahSOgRKmNAW7YB5k2udulgkDobq0TIpBDXr4ocFEjjNjAHVxOrT5Cbv+t2guFVsQ1BmTzXH/ke6eyknOqX6aREZMogUGrh3ziEhGoHq8fAQl7/ijPAMRMQEQN/rRozora3Bla69vD7BwplEwr6ygmTRrdX1VPl1OD1NIYFM6tN2o7a5FWcAsHSL1/zhdPdCGjniNnnxJ/EWuB/SugvSt5h4Cg2B14+M8CJF8WcWkQLhN5xAHNwRV/ggd5cBHd0tNXDKmsK4/uBkVgR17R1cXjwS/24IDLEEAusc4IedAoofcUtAFYO2xuGRGy3BeJAvqgxT+Q+H6pIykZWCSVEK3rDCDQ/wtiDOVTPzM51amPPHuTrok2SMw5OKRBXD07EEk7hMLZEyB4sxwBTxxblXoUwcO0RA4YqB2w/SBgPUadbr6TCDi3PiTB0k70+DuId2KZzuCWl2OQKHeqh4yLdNvFUJ4TJ4IwOzLGUcjL46xNcDhHYEdkvVIPC8ASfD1nDlHkM8p/ZUJPvB4ooft68Uye/NPXn5RJS3wekEY0OiMqhvhWKea0fSs6d6u7Vzi+Yu+KcvqiRCeUijyJYap5AbS6bSjhYMmjhmUPsk1b1M+/bX/YTpQAoawJeAimww/dNKAf/uEYCsQwmqVlyHiaRQIgRX2cWn6tKa9cHArDCQvUDHkKwBSG9Qgk7HQ3njkKfXiqL6VOgGLxIYt66unEopRMAKCSFlPZT+IHBNK3E9ehgCjL7zITV9p/LR/MU6SZx7OKLYhWQIjAX9QQpKQpqoKIGhJineaXwz7l3HFs/BqVdrR+IYE/+fcuA8sTP6vSLmrXDwiYpdCj/hkTwS0uQRqtEkCpLawq9bxlKWwVzsE78rqF/TonYqZI/WVQ0nhnEKwjsAPIQeCiw0d14szuC6koytVuMRvRp38p+MGgFgmovGQEXROgvBIJKuOOQRuKwx+rpbSFtyzbs7RW1uEvfFEtHiVFFCLW6+SQO0ho0YU1jVivsSa++ciDlhT5yhTYu+xnUyRMkKdUZp2oAuf9bUoZqHGQxWUf2YvCLqeCoqmgqQ1otN6/A5PzKG9G65lIofo63ePUt8+EHlKCFKF40GWAavlpkC9zB2TVQcRrpKrdOHALHUoAoO6iSakKsqrGl/Jx0cmJ1hQELluBVOgKZMkxLolAsasU8YH0BvLqjoHOItDSE4g/vHHA9sW07jzOAbcQyVvMDsksQMDMQhD1Mk9uNkOzPA4bV2J0l1QFiFJRIDEsAgiTI93CIVX3J0YfpwEdkBoIiv9idgB9muuLkNLFQnroCFn4g1aXoDlApHKPUnCj2AaULgeIkTZpZ1ImZ0UgUOJosGRqfjrelZRUdCWxoYSk3F0gJgiB4cRFaX8vwEV5dgDkZ3NdCCARNJAmzf8nirnEAQZpdao0VhCLex35GXLTIqhxcalJur5Eit3mh2EGkDwlDXkHwMVAlieQWUBHAjSaNACVK4U32Z/0hWCEbsEYlzYDa84loez5MynWHhf9W11okJKrpij6EURFc+uP1RQgrx4R5jX2RXaagAofyeo48XWF0k/MLb/Yls2KzpoRmhxUXeFSzIaEj80ahzQjafslgo9mi6mMsOgapGqgNUfQYhMSxXbgBLI49cxUoUNaCh4iWXCVAogXfcDyBNJIsUbZmwvxwBQvPCnaXlLoSXgNg8i4lP6rgYtUu0c5cNIYIqXhHsobfIvan0NrKtX9aegHqXUYKcxa2FIljzoz6GqA+Monh5EW50ORgIiSZk67ZZ/LdcqKwap8IK/p75KwaCQJEQpgWYnNhTVjOoXXMwGrGYNCtLr2b3cXrAEmDWj3il9eq1eer5suAJEC8cIU4NJSAn208+f3QzQAw6uowKqlk8E3cv6w4AQqMXnJP+IwH+jTTGYt9hk3/EQQCqKPbaVWHd1Z1AkmxYRXkfKrciJsBjKd88fAmqEAnbFprYgM3/gmqkBKuTAua/z0JhQleR4tRnt7D8qtvkSx36/D9WrikGhpulLNJE+HFR0BgexcVoByksFaJyAgx92IoRVTN3ArY8SpiCgLUSKtyEM+nMlDmgxCzKlMEJKQketesuNE5Ep7BYQ9aBiIQbGtR5kDoM/vrAakJvx0mSEZPixaV1TiBUX7p7OyQgxRk4o6Kt9X+pOUI+GaDqzOGy/OEQMHWkq9atwYedeEKJLYmnwAddInQe/24EEh8xfxBgJ2LUJmLh90RXA53ANA6oD5N6+ZTiHTqSfXxLVLQEttNaG0DoZdngqE2gZLqy4igWOEPn/8nqzj79d+k1LuUUhD1gSg3L9P2QUpFaD0R0lR+RfdDEUG8rIkAnGu6dw+V3yryJ6ppKM1I0WJzK9uHBV3B6xA0gJZlZ2rMf5WKpAHhtYk91DrGm19XcJOsTgxqNOdA8vA4iR6oEsaBusiADgRkojwM2g8BWqXhoLytqRy3FL0BFfi86s10kmpVaWQShSEiB3BuOSgHgR0DOuYjPObfaHmzDV3n1q04VLWmWKrMsuclApGxXWZOhQdBgAAzm/ITn1+opL9reW/VEpfDP6o0z+Vg1bdB5QCHK1WohYRze87Vkt+tzMB/f5SyUwtS4UMMpNhjwPsG4OYHSLl/RBQQw/5dloiApbH/k0LvIA0HERlWkba+TXrntqwGgEBBAygjYuobtzInd+/qOdPqaYHSrooaS33ou+sZgQQZwJWqwXrGZYIfhowyJgOA1DFkKggKqso9nouIDsvrRrdl3Jk36DYE0A6YCRcSVKQIDXeKbSKmlgulqF2X2ctFgitCgLpefeseG2CtB5L1o8mYCHXvTIJL5xLFwSpU7V9hAoUUUuvlEbu1YfWnkv9fvWGkbAjiaQTx0y+pViy+UOHInUsyr1uWbJOrfoC1uQH6EvEIDNMOz2AAuwHkxS6Co4ktdxJ03jjUSlRM6A9IZp9TjSfQx8grOoEF7ZNBWdM8n/iQWERp6I6+6xB3OqgkH6YqJSDUZCN9Zry3SugmVMBYL2UALFnxwTUDdx4pt4t/WZltEb0OREZgYnfreWWFdknjvdyBvcoP6cjsGMYh5xzhIGd2OLLgFCOM2qNuJRxX2cj+rrU0mKUMo1uFKi4JETHc2rtBdOgnmJpKjQ/SYW3D5k1aPnMKvVHWb4nQFArwrKSGOfWhGDxeavTtWpKSf1N1kAOTiDjhhNp6BDwQg4VCOjlAIiByRSYThNoRqrAixtVpb0+lVrTWpFxr+ScMh4jvsxnYSGX9gdGBy9aTxS1RcjrAaiMQK/+jpOJCUwsBEHVdykpsHaDp1MCKb2fWA9+SVmfp1JL7QNUuECuuyUFD1LTdqtkwhPAl1N/UZiA5vkUICCXxLOkTrVKB7DVXyyIaLGEYGBagw5MA6Pw0Hjjod0OzAwZzyB147Mm55D3wOkSoIWwqzcgT92piwS+DUjR4SkTfHybn5VYpwaF/O8VJ/vaz6kXgqAg9khuHaY5Aa8gTKIyhoSB+qDmmnqddwHCe9rMGnKuNaehHU1KHCZkWgJZQyQ2ZIVaFrxF1Y8KQaTVpdrbiJGXw6iWXiEEovU7RAtJtoVEVq+HCX+3gxycACYoIWG9BAA/DwwQwa2sQJwLzt9Kodppwxo4gmZuxQtIVA0WJ/M4LvlA2j+VdxEiv/larjsu/EBLHq4deHTzsFS3KUhJ/mMhCMg1cBgvLq90/g+45aW6mawcICbrJ6UlnfbUWvbpsZxMAhJN0go6D6Q3CenOQCsbF5X5lUKirggo7QEyavcaqXXCF/qFNzejHL6+Z7kkPB3WASDPTHis1znI0kqO8lBjligbw7mmB0gc59o8vCEsnG5wadYyKPw4ptjqY2dIncrUmsrjdqsodg9QPL7QBlSSYmiBgdwaHBKws8TcwOHgTbv3AOtZECigOvt3z9zB/duoMgzl0FqgV2dppFSaCpC9HCpAsU1KrxGTVeB9SSxCzi6IRAmFouhWpNHh9kVTZBHxFzer+0NMwL4VoG4iKLlONAERNPkbgUxmLSEtoByVp5JTHX9nES5qxvBis9oNGJ7RlYSeFgksUYQz4AdV27Oa7IdoTQJ19qvhH53+F8KPINiYBbDl2WyMA9t27Ski4zrNALCyzzU7b7qUKgIELgdbWQNYI5RBszXsVdxNq692rc0GKYaEvFpvm15Oq1l6xcIQKrZP/ecnf8vdmIGl2t8YXiloPQQA7wQMntV+AIg5y2VHgMa51pCGImwLQSjuZpIIFrq8mss4ao/ciOSorgsK0v17Krb7RocvsKO1kGuk8d4MDqZAJnn+P0CEVc8ODuzas3X/tdsOYl2nAOLp29Jgcs2V5/jNOc4RUYG35ZxQzVmUonKeUi6tWCqK3xfTiVbaXyycQT59tNrQ2k6vJ0hV7b/W9poifrS2n84caP/kkLAAb/kAQGX0a1bGcE2TerlZBDTWXQr15/R9zot+FprBq52TyMVVSxHljSw9tYVG8TOolb1RSzQylgMtyhatzVSNFGNSBCFDBHZwAzPkGy6+4gvjHXtrIsJ6VgaT4J/L3/nalwGALVzaGRU7OtzC08L234QRtIJofL+JNCegVVbSWsBOVhCW4n2XsufPeTt1OfbbUghaJT64hlSMANgzjifMOmECBvCmWVnxmu8tgGDNkwJlZyW3hx0HzX+WIuVm9bwCoYbTDgK02JrqRxILg5y/tboOScBdaK0/SV1LVDL/QlbglVCkZlS4/CvnfBhAoQ23PiOAB3+n5519OQ5Mp6aygyQQoLAgai/piFqBaGfcVEybgkr1YJEWLVjpRsa0n0IrUNpAgZBbfaq3aL/ta4ZvjjqcGYWydxZ2kdrDHAPIsuBUNzWalUmAgd2qMphQoq8STtXoiGGTDBPpEz4tfvUOzD4oJNkxFRLa+/r8z3PsyxBJur1acoYVk19af5ce+9UhqEALiGCrXm/feE992ee/ek54LdZ3AAjdn2brFSsrl176JRpZK5B6VZTmFuBHmfgTD5E4N5DeaRYHDvlVq8O4Jk6g5Zz+kwy2mD1pT/+1Moq1swAuAgAOTIAJDok6xKFTBZ7VkOUV5yWRo0C6awFnrRYME3SHl4gd0m4/vfQjRNa4OYjJSznHbEILdRAnmjAVyD7KGXQVOIoFk2uUAhlLKLEF17hpv5qzF5/5jXfsveiqRWIOIqjr3IwB3BQHPvX+14oluIADrEWn10lie8BH/y+cT5xVibXeH4jV/lvqS2DVZmFBEP8oev9rLBbhYqKpDAjxiZmAsQOWANj1wgQECc1qYDpBIgBEGTBp12pKwScSM9i4KN1ExDBkHME6gQf/8tYYDszevL0Hbf1/rF7uaXzFvqoyp9bUJ9DW/csPjO1JTnuQ/e8zJcdf/6f3vzbuv+ssZwFLp/37t8bX3nQdz1U9QBxRibTmQUGVmYX9ELJGhgBWE5qCgk8gq2T7lRjEWo6f3te2I9PNZAJrPcFa6YRfGEJ7aoBv+fHwQxMADJGbziB1DT38n2ZCVx2r4YUX4XbaJjDOL3b0z+OEnCtOh/bOdVWlK30Af7OOyTrAZwuJPRh0APMasNLx09YqrQ8QFpswCJYYrsF0NNo4+t6Xz/iXa7/wjRuIGa7p5ABS8DcGWNrpFj/w1j+tNloLqadpTj+Ar6TkwqQoDSNBqI0FtE9fgAyhrfEupGjBghKU1VJjbUygACBaJUu8AAX51FgFOiney77ZOpAF17zblXHAf0IPULAq56IInPk2Xp4D02lYZIUV4gzG+VscA4ZK8X0LEsWMfxb21PW61gGImQFrvj+trT6Rvx8g8gSQylRYqSf44ove9Dw46dQA18oCmLH0sTd+cXL+lV+vNg1HcPWU0wKeMt1OWEtL3os0QNha87cmIzCS0DjMoErp/EmoYxUwqGsH3AzIVxKIVov+ZHxD9tfriAcggExqD4G3Uyyl+ihIe9g402+paNkSpFDeoYJ+m8uEvBbcJLmv+HxrLpcM2aBlxxUJZ0YfioUfJLn/r0sCDu0/AUEaGS8MNo9O/4d3/PGucy/d29X+N5MFgIDJftnz6qc+muoGXMERSc2cywFKzh0ov6xKPi7BvaT0rbX/SyiqKDcpgvyKjRgVhMCajH4z2gCyxmGnDhspFIPUYWjI0f4ZsLxeBEGcXwRS6KXp0itN6UXnllUnLLXKhLjVNy7zjC82KTCRwxaiLAwSyT5KNZJWtX4AEIxfHlSk/sUkQNhCnPv+BCIDVzfjDXNHz5971hc/evaL/+k9zNwh/zd7XTQAG9SXfGXnnn/8m9+ujhzOw9RjMgJicaQQ9XYnNr6mhfw6ZX5HnvgjdXiojCCWoUCpGF1Q0lVmqLEAWQvpR94FX5S/YTg9pyWODEOmBEwOd1Xg+FrMmobGdazri/6MngWXoJRATA5sHJgdMbm0SGatTUxxwkttE4pLRuMhk6EGUktHy4ASF3s4sGuInfPbK1iAYvSHWgEj8v0YDFc34/n5I+Yvv+yC73z8D57/BExqkWKysbM1SwFjsPKBl35673s+9KL+MXObQPUYJIARr6BT1PiihFn1USFQaIEC71olQzstjxuoNOmofSasooXSauyqmGuJv9LqeYF0WDkAe1ZW1iopD6MMwL8Ybv/BsRw8OAUahvO5MBWz3KTWQkVF19WgalrooYUegwy4j7OhJDABNw61goPApQ3FWj+g1V9SbcAG5Br43YJCtGoCTLf+QAQ3c+Mj5o+e33LVNZe+79ef+ouTG3ZNiRndPtD/i2vEeRLn8hv+4NUHPvjRF9tj5zaAmzHE1WTFxf2cMeAL1CLYKK63xlauDBGh1AGITimrDxWtRKH3E5TOLmV9m57D5d8p3hd3E6TnjfssCdi5b8/6yAB27htj3+L1MLZHDg4EpaWWEV7SW35TipZHgEWXDLSaeplUYlr0zWIrbOz76quF4qK6jB+UpUGRtIROgV8QIg3V7Gi6YeGY+Yu/fe7n3/Mr/9/9Vq7ZPibDq0eeO/vP8QDUWHzt41+1/23vehZtGm2gETOkmcICZJGVekyo1SPvI3Z3ou9zqR5U7g9AqvtFv7+0aktBnv9fCzMq5sBzF+i/zvYCw1CMw8Et1xzeGUDQAMCBFSfXbf8q9ysHSF1MX6mwK5wd14W3KHaAXQBoXBRZjISfsC1I4lqwdmso7g4IaX7RsYnpGPkV4Zl7kFuFWvMfKUwwSMjRTMbDuQ2D3mjj6Ix3vus573v4//cbK1ffOO5Av//JtRI192aY/PP/96aDL/7zB2Aym5qjh/NAM4U0NYwkSW+w5wQIxTKB4Eq0WKn2IikGF7oBBor3n+cMCsIRl6XiatZf/DIXXaQiq1zdRWCIMPafe+4t/TLf8jNIcaHi0vgi/pUHPAuTyRgEE/i6AogQE5ExQZ9LrXsKs9fE7BsIhkE2/M/sBVsMg40J/+cbsb/PMMMYf2P2X0sfh68R+8f5VV8EZgND+XND7LfBCzsjNDNiZqPRwnDY39S74dIrzvnUU178kAte/97PY9pId/L/P/eMAGa4q8/dOj3zC/8oR9xxc3XnUx8oQ2tlMpuicTVcmAZZq1VPCqvRATwcMIS4AASlFPladOBVXT7Gf9Ljy2li6hRAHSCx7SBT9DYMsHj9hbjqpX8LmR7mASBsA8Z1N+2j446o6YE/9auyNF6Cc3VgxzkiamCZwCTCmZgvhoQMCRkWsiwwnD5mw8KGhA2Hx5CwNUIcPmYSNlYMG7Hx8cz+fvYfW2JhNmLIf2zBYoLKiAUaAzgW1FYw65F1g/5oMNffVPV6o96Oq675/hmvfcvjz3jKq16y/4Kr9qR2Zlfz/4iuGQMc3DZrznrP52YXX/4+2nDi8XTy7e4lG6oKxhiRegonU3GuFhJHRCJBXSgsaxSQeCkqUeMjLOGW2k5CJD4ZZBJK35tv/uukniOKGkJAaEDiPH4srqQukoCpAZEDw0HcBNWwAo7o4aJnPAjLF+4BGZQbhX/s4fVQBPXwY3uGzF897q/oN3/2tQIAkxrkJAE6Eok5HNQ6TdjVbnzKH0/9eJITkz/xw/8mnupB5NNYC8sMy4wq7P9LXyMDywRLBj0iVCBUqQXMsGAYGFTooQLDYYrxjj03Xv+N899z1YdPe9eW0755FQ6MPaBpupT/x3PdsFqgSaA7PfxY/oUn/C7f92FPlhNOvrsMgu/MANTwh2kT7nP+Y2n81+L94tTX63ALj0XjB8rFARS+T1rPlx6f7vO+L0588HdhxkXqfBiIbxELesDy3jEued6Dsf3d3y7/vsM5ALTAU/rpO53AD7v3b8vtT3gwLwxPRlya6btpAFkHYqaKTdAGEPhMwZEBszFgMl6y3ZiQqZOHCXwAIGMMLFPDxLVltszs2AmMYbbETBBXgZxly1VQ7+mx/yXIAc1kuljv2b/l4HU7Ljx4xXUX7r3oB5fv/e4V2+od++r0J1njHb9r8/2Yy0jjOQPR7Camk+6zGbe7753opLveA8eecneZ33xbsgtHC9kKjpiaSPMlIiELR4AjK0KMuvGlhLBFHa9LZjQANU0tDg4NmJw4cSLhMR60qgVoBJCm8Uw+YoIxEAdx4uBc4wMADJwTSDNBMzmI5Zuuxv7vn4mt7/ksVq5YPhTOf2gDwBodFABAFZUebiZziN9TUDZp7b9IWuCMi5kYrfGcktsCsW2ThskFmNWCmVvjV/JZibjO8Q8FNpDahmt9nQae/E+6J89a7DGgv0688+l6Pjx34ZTSurB010KwSrQCotJ5yimHzKTg/R4i5z/0AUADg+TTp1t/FhrHi8WneZ3T3zqCgdKK8E52K39folBoqkGwjgPAf+s3WittWOsb5Uf850m3z/snLSjcupHN7i3qrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzzjrrrLPOOuuss84666yzH6X9/2yKxnNuqYSWAAAAAElFTkSuQmCC" alt=""><img class="mark-wordmark" src="data:image/png;base64,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" alt="Site Gateway"></div><h1>${title}</h1><p>${message}</p><div class="foot">Host. Proxy. Secure.</div></main></body></html>`;
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 caddyLoggingDirectives() {
return [" log {", ` output file ${accessLogPath} {`, " roll_size 10mb", " roll_keep 5", " roll_keep_for 168h", " roll_uncompressed", " }", " format json", " }"];
}
// One builder per route kind, each returning the exact Caddyfile lines that route
// contributes. renderCaddyfile()'s loops and the "View Caddy config" endpoint both call
// these, so the popout can never drift from the configuration Caddy actually runs.
function renderHostedSiteBlock(site, logging = caddyLoggingDirectives()) {
return [`${caddySiteAddress(site)} {`, ...logging, ...commonHostDirectives(site), ` root * ${path.join(sitesDir, site.id)}`, " file_server", "}"];
}
function renderProxyBlock(proxy, logging = caddyLoggingDirectives()) {
const lines = [`${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("}");
return lines;
}
function renderRedirectBlock(redirect, logging = caddyLoggingDirectives()) {
const target = `${redirect.target}${redirect.preservePath ? "{uri}" : ""}`;
return [`${caddySiteAddress(redirect)} {`, ...logging, ...commonHostDirectives(redirect), ` redir ${caddyQuote(target)} ${redirect.code || 302}`, "}"];
}
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 = caddyLoggingDirectives();
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("", ...renderHostedSiteBlock(site, logging));
for (const proxy of proxies.filter(item => item.enabled && item.domain)) lines.push("", ...renderProxyBlock(proxy, logging));
for (const redirect of redirects.filter(item => item.enabled && item.domain)) lines.push("", ...renderRedirectBlock(redirect, logging));
return `${lines.join("\n")}\n`;
}
// --- "View Caddy config" pretty renderer -------------------------------------------------
// Re-uses the block builders above and annotates each directive with a plain-language
// comment, so the popout explains the real configuration rather than a paraphrase of it.
const CADDY_DIRECTIVE_NOTES = [
[/^\s*log \{/, "Write this host's requests to the access log Site Gateway reads"],
[/^\s*reverse_proxy /, "Reverse proxy to the configured upstream"],
[/^\s*lb_policy /, "How requests are spread across the configured upstreams"],
[/^\s*transport http \{/, "Connection options used when talking to the upstream"],
[/^\s*tls_insecure_skip_verify/, "Accept the upstream's certificate without verifying it"],
[/^\s*tls_server_name /, "Certificate name expected on the upstream"],
[/^\s*response_header_timeout /, "How long to wait for the upstream's response headers"],
[/^\s*header_up /, "Header added to the request before it reaches the upstream"],
[/^\s*tls internal/, "Enforce HTTPS using Caddy's own internal certificate authority"],
[/^\s*tls "/, "Enforce HTTPS using the certificate and private key you uploaded"],
[/^\s*header Strict-Transport-Security/, "Tell browsers to always use HTTPS for this host"],
[/^\s*header /, "Response header added to every reply from this host"],
[/^\s*encode /, "Compress responses that benefit from it"],
[/^\s*root \* /, "Folder this site's files are served from"],
[/^\s*file_server/, "Serve the files in that folder directly"],
[/^\s*redir /, "Send visitors to the redirect destination"],
[/^\s*handle_path /, "Match this path prefix and strip it before forwarding"],
[/^\s*handle \{/, "Everything not matched above is handled here"],
[/^\s*handle /, "Match this path prefix and forward it unchanged"],
[/^\s*abort/, "Close the connection without replying"],
[/^\s*respond .* 403/, "Reject requests matching the common-exploit ruleset"],
[/^\s*forward_auth /, "Require an Access List sign-in before allowing the request"],
[/^\s*path_regexp /, "Pattern of known exploit-probe paths"],
[/^\s*@/, "Named matcher used by the directive below"]
];
// Container-specific absolute paths, flagged so the config is readable outside this container.
const CADDY_PATH_NOTES = [
[/^\s*root \* /, "container path - this folder lives inside the Site Gateway container"],
[/^\s*output file /, "container path - the access log file inside the Site Gateway container"],
[/^\s*tls "\//, "container paths - the certificate and key files stored inside the Site Gateway container"]
];
function annotateCaddyLines(lines) {
const output = [];
for (const line of lines) {
if (!line.trim()) { output.push(""); continue; }
const indent = " ".repeat(line.length - line.trimStart().length);
const note = CADDY_DIRECTIVE_NOTES.find(([pattern]) => pattern.test(line))?.[1];
const pathNote = CADDY_PATH_NOTES.find(([pattern]) => pattern.test(line))?.[1];
if (note) {
const previous = output[output.length - 1];
if (previous !== undefined && previous.trim() && !/\{$/.test(previous)) output.push("");
output.push(`${indent}# ${note}`);
}
output.push(pathNote ? `${line} # ${pathNote}` : line);
}
return output;
}
function prettyCaddyConfig(kind, item) {
const builder = kind === "sites" ? renderHostedSiteBlock : kind === "proxies" ? renderProxyBlock : renderRedirectBlock;
const heading = kind === "sites" ? "Hosted site" : kind === "proxies" ? "Proxy host" : "Redirect host";
const tlsNote = item.tls === "http" ? "# Served over plain HTTP - no certificate is requested for this host."
: item.tls === "internal" ? "# Enforce HTTPS with Caddy's internal certificate authority."
: item.tls === "custom" ? "# Enforce HTTPS with the certificate and private key you uploaded."
: "# Enforce HTTPS with an automatically-issued certificate.";
return [
`# ${heading} - ${item.name || item.domain || item.id}`,
`# Generated by Site Gateway v${appVersion}. This is the exact block this route`,
"# contributes to the deployed Caddyfile, with explanatory comments added.",
tlsNote,
"",
...annotateCaddyLines(builder(item)),
""
].join("\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();
try {
const liveAfterReload = await caddyAdminRequest({ method: "GET", path: "/config/" });
if (liveAfterReload.status === 200) lastKnownGoodCaddyConfig = JSON.parse(liveAfterReload.body);
} catch (error) { console.warn("Could not capture post-reload config baseline:", error.message); }
} 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 });
}
}
let configDrift = { checkedAt: null, drift: false, detail: null };
// storage.integrity() runs a full PRAGMA integrity_check -- a complete scan of the entire SQLite
// database file for corruption. It's one of the most expensive operations SQLite can run, its
// cost scales with total database size, and because this app's SQLite queries run synchronously,
// it blocks the whole single-threaded server for its full duration while it runs -- not just the
// request that triggered it. dashboardSnapshot() used to call it on EVERY /api/dashboard fetch
// just to compute one cosmetic "Healthy"/"Needs attention" label, which is why unrelated requests
// (confirmed via container logs: /api/system/security, /api/logs/prune/preview) were getting
// stuck behind it in lockstep, all finishing at nearly the same multi-second mark regardless of
// what they actually needed to do. A dashboard status badge doesn't need a fresh, exhaustive
// integrity scan on every single poll -- checking it periodically in the background and caching
// the result is more than sufficient, since real corruption doesn't appear and disappear between
// one 7-second poll and the next.
let databaseIntegrityCache = { checkedAt: null, status: "Healthy" };
function refreshDatabaseIntegrityCache() {
try {
const result = storage.integrity();
databaseIntegrityCache = { checkedAt: new Date().toISOString(), status: result.length === 1 && result[0] === "ok" ? "Healthy" : "Needs attention" };
} catch (error) { console.warn("Database integrity check failed:", error.message); }
}
let lastUpstreamCheckAt = null;
let lastAccessLogImportAt = null;
let lastKnownGoodCaddyConfig = null;
function caddyAdminRequest(options, body) {
return new Promise((resolve, reject) => {
const request = http.request({ host: "127.0.0.1", port: 2019, timeout: 5000, ...options }, response => {
let data = "";
response.on("data", chunk => { data += chunk; });
response.on("end", () => resolve({ status: response.statusCode, body: data }));
});
request.on("error", reject);
request.on("timeout", () => request.destroy(new Error("Caddy admin API request timed out.")));
if (body) request.write(body);
request.end();
});
}
function stableStringify(value) {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
async function checkConfigDrift() {
const wasDrifting = configDrift.drift;
try {
const live = await caddyAdminRequest({ method: "GET", path: "/config/" });
if (live.status !== 200) return;
const liveConfig = JSON.parse(live.body);
if (!lastKnownGoodCaddyConfig) {
lastKnownGoodCaddyConfig = liveConfig;
configDrift = { checkedAt: new Date().toISOString(), drift: false, detail: null };
return;
}
const drift = stableStringify(lastKnownGoodCaddyConfig) !== stableStringify(liveConfig);
configDrift = { checkedAt: new Date().toISOString(), drift, detail: drift ? "Caddy\u2019s live configuration no longer matches the last known-good configuration." : null };
if (drift && !wasDrifting) recordActivity("Configuration drift detected: Caddy\u2019s live configuration no longer matches the last known-good configuration.", "warning");
} catch (error) {
configDrift = { ...configDrift, checkedAt: new Date().toISOString() };
}
}
// --- 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 entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error));
const results = await Promise.all(entries.map(entry => {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) return walkFiles(fullPath);
if (entry.isFile()) return [fullPath];
return [];
}));
return results.flat();
}
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() {
if (certificateInventoryCache && Date.now() - certificateInventoryCache.at < CERTIFICATE_INVENTORY_CACHE_MS) return certificateInventoryCache.value;
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 [managedCertificateFiles, customCertificateFiles] = await Promise.all([walkFiles(certificateDir), walkFiles(customCertificatesDir)]);
const certificateFiles = [...managedCertificateFiles, ...customCertificateFiles];
const parsed = (await Promise.all(certificateFiles.filter(file => /\.(?:crt|pem)$/i.test(file)).map(async filename => {
try {
const [contents, stat] = await Promise.all([fsp.readFile(filename), fsp.stat(filename)]);
const certificate = new crypto.X509Certificate(contents);
return { certificate, names: certificateNames(certificate), updatedAt: stat.mtime.toISOString(), filename, source: filename.startsWith(customCertificatesDir) ? "Custom upload" : "Caddy / ACME" };
} catch { return null; /* Ignore non-certificate PEM files and unreadable entries. */ }
}))).filter(Boolean);
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;
const result = { 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 };
certificateInventoryCache = { at: Date.now(), value: result };
return result;
}
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(precomputedCertificates) {
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, httpResponding, httpsResponding] = await Promise.all([precomputedCertificates ? Promise.resolve(precomputedCertificates) : certificateInventory(), 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" || item.kind === "Hosted site") ? 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 || !/<svg[\s>]/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(precomputedCertificates) {
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 = precomputedCertificates || 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}.`, target: "hosted" });
for (const proxy of proxyHosts.filter(item => item.status === "error")) attention.push({ kind: "proxy", name: proxy.name, message: "Proxy route needs attention.", target: "proxies" });
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}` : ""}.`, target: "proxies" });
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"}.` });
if (configDrift.drift) attention.push({ kind: "drift", name: "Configuration drift", message: "Caddy\u2019s live configuration no longer matches the saved configuration.", target: "administration/defaults" });
const disk = await fsp.statfs(dataDir).catch(() => null);
// See refreshDatabaseIntegrityCache() above -- this used to be a live storage.integrity() call on every fetch.
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: databaseIntegrityCache.status,
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", lastRunAt: lastUpstreamCheckAt },
{ name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off", lastRunAt: settings.backups?.lastRunAt || null, lastStatus: settings.backups?.lastStatus || null },
{ name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off", lastRunAt: settings.logsRetention?.lastRunAt || null },
{ name: "Access-log import", enabled: true, schedule: "30s", lastRunAt: lastAccessLogImportAt },
{ name: "Public IP check", enabled: true, schedule: "60m", lastRunAt: publicIpState.checkedAt || null },
{ name: "Configuration drift check", enabled: true, schedule: "10m", lastRunAt: configDrift.checkedAt || null },
{ name: "Database integrity check", enabled: true, schedule: "30m", lastRunAt: databaseIntegrityCache.checkedAt || null },
{ name: "Disk usage refresh", enabled: true, schedule: "60s", lastRunAt: dataDirSizeCache.checkedAt || null },
]
},
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 createBackupInternal(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 };
}
// Wraps createBackupInternal so every attempt -- successful or not -- is written to the
// backup_events history table, which is independent of what currently exists on disk.
async function createBackup(type = "configuration", includeLogs = false, prefix = "site-gateway-backup", password = "") {
const safeType = type === "complete" ? "complete" : "configuration";
const backupType = prefix === "pre-restore" ? "safety" : safeType;
try {
const result = await createBackupInternal(type, includeLogs, prefix, password);
try { storage.recordBackupEvent({ type: "created", filename: result.filename, backupType, sizeBytes: result.size, actorUserId: currentAuditActor, status: "success" }); } catch (error) { console.warn("Could not record backup history event:", error.message); }
return result;
} catch (error) {
try { storage.recordBackupEvent({ type: "created", filename: null, backupType, actorUserId: currentAuditActor, status: "failed", errorMessage: error.message }); } catch { /* History is best effort. */ }
recordActivity(`${backupType === "safety" ? "Pre-restore safety" : safeType === "complete" ? "Complete" : "Configuration"} backup failed: ${error.message}`, "error");
throw error;
}
}
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.`);
try { storage.recordBackupEvent({ type: "restored", filename, backupType: manifest.type || "unknown", actorUserId: currentAuditActor, safetyBackupFilename: safetyBackup?.filename || null, status: "success" }); } catch (error) { console.warn("Could not record backup history event:", error.message); }
} catch (error) {
recordActivity(`Restore of ${filename} failed: ${error.message}`, "error");
try { storage.recordBackupEvent({ type: "restored", filename, backupType: manifest.type || "unknown", actorUserId: currentAuditActor, safetyBackupFilename: safetyBackup?.filename || null, status: "failed", errorMessage: error.message }); } catch { /* History is best effort. */ }
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));
}
}
dockerSocketMounted = detectDockerSocket();
if (!dockerSocketMounted) console.log("Docker socket not detected at /var/run/docker.sock - container selection stays unavailable.");
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|select-enhance)\.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, { setHeaders: (res, filePath) => { if (/\/(app|features)\.js$/.test(filePath)) res.setHeader("Cache-Control", "no-cache"); } }));
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";
if (rateLimitExceeded(`login:${key}`, 10, 60000)) { recordActivity(`Security: sign-in request rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in requests. Try again in a minute." }); }
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(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>Sign in · Site Gateway</title><style>:root{color-scheme:dark;--bg:#08101d;--panel:#101a2b;--line:#25344c;--text:#eef4ff;--muted:#95a4ba;--green:#62e6a7}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:22px;background:radial-gradient(circle 220px at 50% 0,#17372b 0,transparent 100%),var(--bg);color:var(--text);font-family:Inter,system-ui,sans-serif}.card{width:min(430px,100%);padding:34px;border:1px solid var(--line);border-radius:20px;background:var(--panel);box-shadow:0 24px 70px #0007}.mark{width:48px;height:48px;display:grid;place-items:center;border-radius:14px;background:#18362b;color:var(--green);font-weight:900}.eyebrow{margin:25px 0 7px;color:var(--green);font-size:11px;font-weight:850;letter-spacing:.13em;text-transform:uppercase}h1{margin:0;font-size:34px;letter-spacing:-.045em}p{color:var(--muted);line-height:1.55}label{display:block;margin-top:17px;font-size:13px;font-weight:700}input{display:block;width:100%;height:46px;margin-top:7px;padding:0 12px;border:1px solid var(--line);border-radius:10px;background:#0a1423;color:var(--text);font:inherit}button{width:100%;height:46px;margin-top:22px;border:0;border-radius:10px;background:var(--green);color:#05251a;font-weight:850;cursor:pointer}.error{color:#ff7185;font-size:13px}</style></head><body><form class="card" method="post" action="/_site-gateway/login"><div class="mark">SG</div><div class="eyebrow">Protected by Site Gateway</div><h1>Sign in to continue</h1><p>This service uses the <strong>${String(list.name).replace(/[<>]/g, "")}</strong> access policy.</p>${req.query.error ? '<p class="error">That username or password was not accepted.</p>' : ""}<input type="hidden" name="list" value="${listId}"><input type="hidden" name="return" value="${safeReturn.replaceAll('"', '&quot;')}"><label>Username<input name="username" autocomplete="username" required></label><label>Password<input name="password" type="password" autocomplete="current-password" required></label><button>Sign in</button></form></body></html>`);
});
app.post("/_site-gateway/login", async (req, res, next) => {
try {
if (rateLimitExceeded(`access-login:${requestKey(req)}`, 10, 60000)) return res.status(429).type("text").send("Too many sign-in requests. Try again in a minute.");
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) => {
// A bearer token authenticates as its owner and inherits that owner's role for every
// role check further down. Cookie sessions remain the fallback.
if (/^Bearer\s+/i.test(String(req.headers.authorization || ""))) {
if (rateLimitExceeded(`bearer:${requestKey(req)}`, 10, 60000)) return res.status(429).json({ error: "Too many API requests. Try again in a minute." });
const match = bearerTokenUser(req);
if (!match) return res.status(401).json({ error: "That API token is not valid, has expired, or has been revoked." });
if (match.token.scope === "read-only" && req.method !== "GET") return res.status(403).json({ error: "This API token is read-only." });
if (!match.token.lastUsedAt || Date.now() - new Date(match.token.lastUsedAt).getTime() > 60000) {
try { storage.touchApiToken(match.token.id); } catch { /* Last-used tracking is best effort. */ }
}
req.user = match.user; req.apiToken = match.token;
return 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 364 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);
rotateSessionVersion(req.user);
await saveUsers(); recordActivity(`User “${req.user.username}” changed their password. Other sessions and API tokens were signed out.`);
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 = [];
rotateSessionVersion(req.user);
await saveUsers(); recordActivity(`User “${req.user.username}” disabled two-factor authentication. Other sessions and API tokens were signed out.`, "warning");
issueSessionCookie(res, req.user);
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 }, backup: { encryptionAvailable: Boolean(scheduledBackupPassword) }, docker: { socketMounted: dockerSocketMounted, enabled: dockerSocketMounted && settings.dockerIntegration?.enabled === true } }));
// --- System tab: storage usage, restart-policy check, and self-restart -----------------------------------
// Read-only, non-destructive live resource stats (CPU/memory/swap/disk/network/throughput) --
// shown on the Dashboard for every signed-in user, same as the rest of the Dashboard's health
// panel, and additionally on the Administration > System tab's hero for administrators. Unlike
// most /api/system/* routes this intentionally isn't administrator-gated, since there's nothing
// here a standard user couldn't already infer from the Dashboard being slow or fast.
app.get("/api/system/health", async (req, res, next) => {
try { res.json(await systemHealthSnapshot()); } catch (error) { next(error); }
});
app.get("/api/system/storage", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try {
const breakdownDirs = { sites: sitesDir, backups: backupsDir, certificates: certificatesRoot, logs: logsDir, database: path.join(dataDir, "database") };
const breakdown = Object.fromEntries(await Promise.all(Object.entries(breakdownDirs).map(async ([key, dir]) => [key, await directorySize(dir)])));
let capacity = null;
try {
const stats = await fsp.statfs(dataDir);
capacity = { totalBytes: stats.blocks * stats.bsize, freeBytes: stats.bfree * stats.bsize, availableBytes: stats.bavail * stats.bsize };
} catch { /* statfs isn't available on every platform/Node build -- degrade to breakdown-only. */ }
res.json({ dataDir, breakdown, usedBytes: Object.values(breakdown).reduce((sum, value) => sum + value, 0), capacity });
} catch (error) { next(error); }
});
// Inspects this container's own restart policy via the Docker Engine API, reusing the same
// mounted-socket + self-identification (process.env.HOSTNAME) pattern as the container picker.
async function ownRestartPolicy() {
if (!dockerSocketMounted) return { checked: false, policyName: null, restartAvailable: false, reason: "Restart availability cant be verified — mount /var/run/docker.sock into this container so Site Gateway can confirm it will come back up before offering a restart." };
const ownId = String(process.env.HOSTNAME || "").trim();
if (!ownId) return { checked: false, policyName: null, restartAvailable: false, reason: "Could not determine this container's own ID." };
try {
const own = await dockerRequest(`/containers/${encodeURIComponent(ownId)}/json`);
const policyName = own?.HostConfig?.RestartPolicy?.Name || "no";
const restartAvailable = ["always", "unless-stopped", "on-failure"].includes(policyName);
return { checked: true, policyName, restartAvailable, reason: restartAvailable ? null : `Restart policy is "${policyName}" — set it to "unless-stopped" (or similar) in your container config to enable restarting from here.` };
} catch (error) { return { checked: false, policyName: null, restartAvailable: false, reason: `Could not read the container's restart policy: ${error.message}` }; }
}
app.get("/api/system/restart-policy", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try { res.json(await ownRestartPolicy()); } catch (error) { next(error); }
});
app.get("/api/system/security", (req, res) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
res.json({
adminPasswordIsDefault: process.env.ADMIN_PASSWORD === undefined,
sessionSecretIsDefault: process.env.SESSION_SECRET === undefined,
acmeEmailConfigured: Boolean(String(process.env.ACME_EMAIL || "").trim()),
});
});
app.post("/api/system/restart", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try {
const policy = await ownRestartPolicy();
if (!policy.restartAvailable) return res.status(409).json({ error: policy.reason || "Restarting is not available." });
recordActivity("Administrator restarted Site Gateway.", "warning");
res.json({ ok: true });
setTimeout(() => process.exit(0), 250);
} catch (error) { next(error); }
});
app.post("/api/system/reload", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try { await syncCaddy(); recordActivity("Administrator reloaded the gateway configuration."); res.json({ ok: true, lastGatewayReload }); } catch (error) { next(error); }
});
app.post("/api/gateway/resync", async (req, res, next) => {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
try {
await syncCaddy();
try {
const liveAfterResync = await caddyAdminRequest({ method: "GET", path: "/config/" });
if (liveAfterResync.status === 200) lastKnownGoodCaddyConfig = JSON.parse(liveAfterResync.body);
} catch (error) { console.warn("Could not capture post-resync config baseline:", error.message); }
configDrift = { checkedAt: new Date().toISOString(), drift: false, detail: null };
recordActivity(`Gateway configuration re-synced by \u201c${req.user.username}\u201d.`);
res.json({ ok: true });
} catch (error) { next(error); }
});
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." }));
// --- REST API access tokens: list / issue / revoke -----------------------------------------
app.get("/api/tokens", (req, res, next) => {
try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
res.json(storage.listApiTokens().map(token => ({ id: token.id, name: token.name, prefix: token.prefix, scope: token.scope, icon: token.icon, iconSlug: token.iconSlug, ownerUserId: token.ownerUserId, ownerUsername: users.find(item => item.id === token.ownerUserId)?.username || "unknown", createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, expiresAt: token.expiresAt, revokedAt: token.revokedAt, revoked: Boolean(token.revokedAt) })));
} catch (error) { next(error); }
});
app.post("/api/tokens", async (req, res, next) => {
try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
if (req.apiToken) return res.status(403).json({ error: "New API tokens can only be issued from a signed-in session." });
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." });
const name = String(req.body.name || "").trim();
if (!name || name.length > 60) return res.status(400).json({ error: "Enter a name of 60 characters or fewer for this token." });
const scope = req.body.scope === "read-only" ? "read-only" : "full";
const requestedDays = Number(req.body.expiresInDays);
const expiresAt = Number.isFinite(requestedDays) && requestedDays > 0 ? new Date(Date.now() + Math.min(requestedDays, 3650) * 86400000).toISOString() : null;
if (!req.user.sessionVersion) { req.user.sessionVersion = crypto.randomBytes(16).toString("hex"); req.user.updatedAt = new Date().toISOString(); await saveUsers(); }
const rawToken = `sgt_${crypto.randomBytes(32).toString("base64url")}`;
const record = storage.createApiToken({ id: crypto.randomUUID(), name, tokenHash: hashApiToken(rawToken), prefix: rawToken.slice(0, 8), ownerUserId: req.user.id, scope, sessionVersion: req.user.sessionVersion, expiresAt });
recordActivity(`Security: API token \u201c${name}\u201d issued for \u201c${req.user.username}\u201d.`);
res.status(201).json({ ...record, ownerUsername: req.user.username, revoked: false, token: rawToken });
} catch (error) { next(error); }
});
app.delete("/api/tokens/:id", (req, res, next) => {
try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
const existing = storage.listApiTokens().find(token => token.id === req.params.id);
if (!existing) return res.status(404).json({ error: "API token not found." });
if (!storage.revokeApiToken(req.params.id)) return res.status(409).json({ error: "That API token has already been revoked." });
recordActivity(`Security: API token \u201c${existing.name}\u201d revoked.`);
res.status(204).end();
} catch (error) { next(error); }
});
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 364 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;
}
let invalidateSessions = nextStatus !== "active";
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);
invalidateSessions = true;
}
// A password reset or a deactivation must also drop this user's API tokens.
if (invalidateSessions) rotateSessionVersion(user);
user.updatedAt = new Date().toISOString(); await saveUsers();
if (invalidateSessions && user.id === req.user.id) issueSessionCookie(res, user); 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 = [];
rotateSessionVersion(user);
await saveUsers();
if (user.id === req.user.id) issueSessionCookie(res, user);
recordActivity(`Administrator “${req.user.username}” disabled two-factor authentication for “${user.username}”. That users sessions and API tokens were signed out.`, "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();
const certificates = await certificateInventory();
const [dashboard, readiness] = await Promise.all([dashboardSnapshot(certificates), domainReadiness(certificates)]);
res.json({ dashboard, certificates, readiness });
}
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();
const readiness = await domainReadiness(certificateReport);
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, 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 --------------------------------
// --- Docker: list running containers for the target picker -----------------------------------
app.get("/api/docker/containers", async (req, res, next) => {
try {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
if (!dockerSocketMounted) return res.status(409).json({ error: "Docker socket not detected \u2014 mount /var/run/docker.sock into this container to enable container selection." });
if (settings.dockerIntegration?.enabled !== true) return res.status(409).json({ error: "Docker integration is turned off. Enable it in Administration \u2192 Gateway defaults." });
res.json({ checkedAt: new Date().toISOString(), containers: await dockerContainerOptions() });
} catch (error) { next(error); }
});
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 }); }
const percentiles = storage.performancePercentiles(0.95);
const topPaths = storage.performanceTopPaths(10);
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, hourBytes: row.hourBytes || 0, hourVisitors: row.hourVisitors || 0, dayRequests: row.dayRequests || 0, dayErrors: row.dayErrors || 0, dayAvgMs: row.dayAvgMs != null ? Math.round(row.dayAvgMs) : null, dayBytes: row.dayBytes || 0, dayVisitors: row.dayVisitors || 0, hourP95Ms: percentiles[row.host]?.hourP95 ?? null, dayP95Ms: percentiles[row.host]?.dayP95 ?? null, topPaths: topPaths[row.host] || [], errorBreakdown: breakdownByHost.get(row.host) || [] })),
trend: storage.performanceTrend(host, hours, bucketMinutes),
slowest: storage.performanceSlowest(host, hours, 20),
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 -----------------------------------------------------------
// --- "View Caddy config": the annotated Caddyfile block for one route -------------------------
// Streaming hosts are deliberately excluded: they are raw TCP/UDP forwards handled by
// startStream() through net/dgram, and never appear in the Caddyfile at all.
app.get("/api/:kind/:id/caddy-config", (req, res, next) => {
try {
const kind = String(req.params.kind);
if (!["sites", "proxies", "redirects"].includes(kind)) return res.status(400).json({ error: "A Caddy configuration view is only available for hosted sites, proxy hosts, and redirect hosts. Streaming hosts forward raw TCP/UDP traffic and are not routed through Caddy." });
const collection = kind === "sites" ? sites : kind === "proxies" ? proxies : redirects;
const item = collection.find(value => value.id === req.params.id);
if (!item) return res.status(404).json({ error: "That route no longer exists." });
res.json({ kind, id: item.id, name: item.name || item.domain || item.id, config: prettyCaddyConfig(kind, item) });
} catch (error) { next(error); }
});
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 {
if (req.params.kind === "tokens") {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
const existing = storage.listApiTokens().find(token => token.id === req.params.id);
if (!existing) return res.status(404).json({ error: "API token not found." });
let updated;
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." });
updated = storage.setApiTokenIcon(req.params.id, { icon: url, iconSlug: null });
recordActivity(`Icon URL updated for “${entryLabel(existing)}”.`);
} else {
const slug = String(req.body.slug || "").trim();
const icon = slug ? await cacheIcon(slug) : null;
updated = storage.setApiTokenIcon(req.params.id, { icon, iconSlug: slug || null });
recordActivity(`${slug ? "Icon updated" : "Icon reset"} for “${entryLabel(existing)}”.`);
}
return res.json({ ...updated, ownerUsername: users.find(item => item.id === updated.ownerUserId)?.username || "unknown", revoked: Boolean(updated.revokedAt) });
}
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 {
if (req.params.kind === "tokens") {
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
const existing = storage.listApiTokens().find(token => token.id === req.params.id);
if (!existing) return res.status(404).json({ error: "API token 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}-${existing.id}.${extension}`;
await fsp.rename(req.file.path, path.join(iconsDir, filename));
const updated = storage.setApiTokenIcon(req.params.id, { icon: `/site-icons/${filename}`, iconSlug: null });
recordActivity(`Custom icon uploaded for “${entryLabel(existing)}”.`);
return res.json({ ...updated, ownerUsername: users.find(item => item.id === updated.ownerUserId)?.username || "unknown", revoked: Boolean(updated.revokedAt) });
}
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 before = { name: site.name, tls: site.tls, hsts: site.hsts, accessListId: site.accessListId, healthEnabled: site.healthEnabled, healthDetail: JSON.stringify([site.compression, site.hstsSubdomains, site.requestHeaders, site.responseHeaders, site.customConfig, site.healthPath, site.healthMethod, site.healthExpected, site.healthTimeoutSeconds, site.healthRetries]) };
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();
const afterHealthDetail = JSON.stringify([site.compression, site.hstsSubdomains, site.requestHeaders, site.responseHeaders, site.customConfig, site.healthPath, site.healthMethod, site.healthExpected, site.healthTimeoutSeconds, site.healthRetries]);
summarizeUpdate("Hosted site", site.name, [
before.name !== site.name ? `renamed from “${before.name}` : null,
describeDomainsChange(previousDomains, domains),
before.tls !== site.tls ? `TLS set to ${site.tls}` : null,
before.hsts !== site.hsts ? `HSTS ${site.hsts ? "enabled" : "disabled"}` : null,
before.accessListId !== site.accessListId ? (site.accessListId ? "Access List assigned" : "Access List removed") : null,
before.healthEnabled !== site.healthEnabled ? `health checks ${site.healthEnabled === false ? "disabled" : "enabled"}` : null,
before.healthDetail !== afterHealthDetail ? "advanced settings updated" : null
]);
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);
const before = { name: proxy.name, target: proxy.target, tls: proxy.tls, hsts: proxy.hsts, accessListId: proxy.accessListId, healthEnabled: proxy.healthEnabled, healthDetail: JSON.stringify([proxy.upstreams, proxy.lbPolicy, proxy.compression, proxy.hstsSubdomains, proxy.requestHeaders, proxy.responseHeaders, proxy.upstreamTlsServerName, proxy.upstreamTlsInsecure, proxy.customConfig, proxy.healthPath, proxy.healthMethod, proxy.healthExpected, proxy.healthTimeoutSeconds, proxy.healthRetries]) };
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();
const afterHealthDetail = JSON.stringify([proxy.upstreams, proxy.lbPolicy, proxy.compression, proxy.hstsSubdomains, proxy.requestHeaders, proxy.responseHeaders, proxy.upstreamTlsServerName, proxy.upstreamTlsInsecure, proxy.customConfig, proxy.healthPath, proxy.healthMethod, proxy.healthExpected, proxy.healthTimeoutSeconds, proxy.healthRetries]);
summarizeUpdate("Proxy host", proxy.name, [
before.name !== proxy.name ? `renamed from “${before.name}` : null,
describeDomainsChange(previousDomains, normalizeDomains(proxy.domain, proxy.domains)),
before.target !== proxy.target ? `target changed to ${proxy.target}` : null,
before.tls !== proxy.tls ? `TLS set to ${proxy.tls}` : null,
before.hsts !== proxy.hsts ? `HSTS ${proxy.hsts ? "enabled" : "disabled"}` : null,
before.accessListId !== proxy.accessListId ? (proxy.accessListId ? "Access List assigned" : "Access List removed") : null,
before.healthEnabled !== proxy.healthEnabled ? `health checks ${proxy.healthEnabled === false ? "disabled" : "enabled"}` : null,
before.healthDetail !== afterHealthDetail ? "advanced settings updated" : null
]);
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);
const before = { name: item.name, target: item.target, accessListId: item.accessListId, enabled: item.enabled, code: item.code, preservePath: item.preservePath, tls: item.tls, hsts: item.hsts };
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();
summarizeUpdate("Redirect Host", item.name, [
before.name !== item.name ? `renamed from “${before.name}` : null,
describeDomainsChange(previousDomains, normalizeDomains(item.domain, item.domains)),
before.target !== item.target ? `destination changed to ${item.target}` : null,
before.code !== item.code ? `redirect code changed to ${item.code}` : null,
before.preservePath !== item.preservePath ? `path preservation ${item.preservePath ? "enabled" : "disabled"}` : null,
before.tls !== item.tls ? `TLS set to ${item.tls}` : null,
before.hsts !== item.hsts ? `HSTS ${item.hsts ? "enabled" : "disabled"}` : null,
before.accessListId !== item.accessListId ? (item.accessListId ? "Access List assigned" : "Access List removed") : null,
before.enabled !== item.enabled ? (item.enabled ? "enabled" : "disabled") : null
]);
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 before = { name: stream.name, port: stream.port, target: stream.target, tcp: stream.tcp, udp: stream.udp, healthEnabled: stream.healthEnabled, enabled: stream.enabled };
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();
const protocolLabel = (tcp, udp) => tcp && udp ? "TCP+UDP" : tcp ? "TCP" : "UDP";
summarizeUpdate("Streaming host", stream.name, [
before.name !== stream.name ? `renamed from “${before.name}` : null,
before.port !== stream.port ? `port changed from ${before.port} to ${stream.port}` : null,
before.target !== stream.target ? `target changed to ${stream.target}` : null,
(before.tcp !== stream.tcp || before.udp !== stream.udp) ? `protocol changed to ${protocolLabel(stream.tcp, stream.udp)}` : null,
before.healthEnabled !== stream.healthEnabled ? `health checks ${stream.healthEnabled === false ? "disabled" : "enabled"}` : null,
before.enabled !== stream.enabled ? (stream.enabled ? "enabled" : "disabled") : null
]);
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.dockerIntegration) settings.dockerIntegration = { enabled: req.body.dockerIntegration.enabled === true && dockerSocketMounted };
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 };
}
if (req.body.defaultSite) await syncCaddy();
await saveSettings();
recordActivity("Administration settings updated."); res.json({ ...settings, backupDirectory: backupsDir });
} catch (error) { next(error); }
});
// Pre-prune snapshots (pre-prune-*.sqlite) are safety copies taken before every log prune, scheduled or
// manual. They are not real backups: they never appear in the Backup & Restore list and can't be
// deleted from there (only .sgbackup files can). Without cleanup they accumulate forever and silently
// inflate the Storage breakdown on the System tab. Keep only the most recent few after each prune.
async function cleanupOldPruneSnapshots(keep = 3) {
try {
const names = (await fsp.readdir(backupsDir)).filter(name => name.startsWith("pre-prune-") && name.endsWith(".sqlite"));
const withStats = await Promise.all(names.map(async name => ({ name, mtime: (await fsp.stat(path.join(backupsDir, name))).mtimeMs })));
withStats.sort((a, b) => b.mtime - a.mtime);
for (const item of withStats.slice(keep)) await fsp.rm(path.join(backupsDir, item.name), { force: true });
} catch (error) { console.warn("Could not clean up old pre-prune snapshots:", error.message); }
}
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(); await cleanupOldPruneSnapshots(); 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.get("/api/backups/history", (req, res, next) => { try { res.json(storage.listBackupEvents(500)); } 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.`);
try { storage.recordBackupEvent({ type: "imported", filename, backupType: manifest.type || "unknown", sizeBytes: (await fsp.stat(path.join(backupsDir, filename))).size, actorUserId: req.user.id, status: "success" }); } catch (error) { console.warn("Could not record backup history event:", error.message); }
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." });
const existing = (await listBackups()).find(item => item.filename === filename);
await fsp.rm(path.join(backupsDir, filename));
recordActivity(`Backup ${filename} deleted.`);
try { storage.recordBackupEvent({ type: "deleted", filename, backupType: existing?.type || (filename.startsWith("pre-restore") ? "safety" : "unknown"), sizeBytes: existing?.size ?? null, actorUserId: req.user.id, status: "success" }); } catch (error) { console.warn("Could not record backup history event:", error.message); }
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().then(() => { lastUpstreamCheckAt = new Date().toISOString(); }).catch(error => console.warn("Initial upstream checks failed:", error.message)), 1500).unref();
setTimeout(() => cleanupOldPruneSnapshots().catch(error => console.warn("Startup pre-prune snapshot cleanup failed:", error.message)), 2000).unref();
setInterval(() => checkAllProxies().then(() => { lastUpstreamCheckAt = new Date().toISOString(); }).catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref();
setTimeout(() => checkConfigDrift().catch(error => console.warn("Config drift check failed:", error.message)), 10000).unref();
setInterval(() => checkConfigDrift().catch(error => console.warn("Config drift check failed:", error.message)), 10 * 60000).unref();
// Runs the (expensive, synchronous, whole-server-blocking) database integrity scan once shortly
// after boot and then every 30 minutes in the background, rather than on every dashboard fetch.
setTimeout(refreshDatabaseIntegrityCache, 5000).unref();
setInterval(refreshDatabaseIntegrityCache, 30 * 60000).unref();
setTimeout(refreshDataDirSizeCache, 5000).unref();
setInterval(refreshDataDirSizeCache, 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(); await cleanupOldPruneSnapshots(); 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().then(() => { lastAccessLogImportAt = new Date().toISOString(); }).catch(error => console.warn("Access-log import failed:", error.message)), 8000).unref();
setInterval(() => importAccessLogsToSqlite().then(() => { lastAccessLogImportAt = new Date().toISOString(); }).catch(error => console.warn("Access-log import failed:", error.message)), 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);