Fix header layout regression, remove exploit-block toggle and perf logging, cache disk-usage check

This commit is contained in:
marvin
2026-09-20 00:29:37 -04:00
parent 6cd51a8a3f
commit 7b11730c8e
7 changed files with 29 additions and 62 deletions
+2 -2
View File
@@ -94,7 +94,7 @@ function advancedFormBody(form, body, scoped) {
const read = (name, fallback = "") => scoped ? scopedValue(scoped.formEl, scoped.scope, name, fallback) : (form.get(name) || fallback);
const checked = (name) => scoped ? Boolean(scoped.formEl.querySelector(`${scoped.scope} [name="${name}"]`)?.checked) : form.has(name);
body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean);
body.hsts = form.has("hsts"); body.hstsSubdomains = checked("hstsSubdomains"); body.healthEnabled = checked("healthEnabled"); body.upstreamTlsInsecure = checked("upstreamTlsInsecure"); body.blockCommonExploits = checked("blockCommonExploits");
body.hsts = form.has("hsts"); body.hstsSubdomains = checked("hstsSubdomains"); body.healthEnabled = checked("healthEnabled"); body.upstreamTlsInsecure = checked("upstreamTlsInsecure");
body.accessListId = read("accessListId", body.accessListId || "");
body.requestHeaders = parseHeaderLines(read("requestHeadersText")); body.responseHeaders = parseHeaderLines(read("responseHeadersText")); body.compression = read("compression", "automatic"); body.customConfig = read("customConfig");
body.locations = String(form.get("customLocationsText") || "").split("\n").map(line => { const [path, target, behavior] = line.split("|").map(value => value.trim()); return path && target ? { path, target, stripPrefix:behavior.toLowerCase() === "strip" } : null; }).filter(Boolean);
@@ -785,7 +785,7 @@ function openSettings(kind, id) {
form.elements.name.value = item.name || ""; form.elements.domain.value = item.domain || ""; form.elements.target.value = item.target || ""; form.elements.tls.value = item.tls || "automatic"; form.elements.hsts.checked = Boolean(item.hsts); if (form.elements.settingsAccessListId) form.elements.settingsAccessListId.value = item.accessListId || "";
if (kind === "proxy") {
const scope = "#settings-advanced";
setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "healthPath", item.healthPath || "/"); setScoped(form, scope, "healthMethod", item.healthMethod || "GET"); setScoped(form, scope, "healthExpected", item.healthExpected || "200-499"); setScoped(form, scope, "healthTimeoutSeconds", item.healthTimeoutSeconds || 4); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "compression", item.compression || "automatic"); setScoped(form, scope, "blockCommonExploits", Boolean(item.blockCommonExploits));
setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "healthPath", item.healthPath || "/"); setScoped(form, scope, "healthMethod", item.healthMethod || "GET"); setScoped(form, scope, "healthExpected", item.healthExpected || "200-499"); setScoped(form, scope, "healthTimeoutSeconds", item.healthTimeoutSeconds || 4); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "compression", item.compression || "automatic");
form.elements.customLocationsText.value = (item.locations || []).map(location => `${location.path} | ${location.target} | ${location.stripPrefix ? "strip" : "preserve"}`).join("\n");
setScoped(form, scope, "requestHeadersText", (item.requestHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n")); setScoped(form, scope, "responseHeadersText", (item.responseHeaders || []).map(header => `${header.name}: ${header.value}`).join("\n"));
form.elements.upstreamTlsServerName.value = item.upstreamTlsServerName || ""; setScoped(form, scope, "upstreamTlsInsecure", Boolean(item.upstreamTlsInsecure)); setScoped(form, scope, "hstsSubdomains", Boolean(item.hstsSubdomains)); setScoped(form, scope, "customConfig", item.customConfig || ""); form.elements.upstreamsText.value = (item.upstreams || []).join("\n"); setScoped(form, scope, "lbPolicy", item.lbPolicy || "random");
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -65,7 +65,7 @@ h2{letter-spacing:-.025em}
.status-pill{display:flex;align-items:center;gap:var(--space-2);text-transform:capitalize;font-size:var(--font-size-sm);color:var(--muted)}
.icon-button,.launch{width:34px;height:34px;border-radius:var(--radius-sm);border:1px solid var(--line);display:grid;place-items:center;background:var(--icon-button-bg);color:var(--muted);cursor:pointer;text-decoration:none}
.page-refresh{width:44px;height:44px;flex:0 0 auto}
header .page-refresh{margin-left:8px}
.header-actions{display:flex;align-items:center;gap:var(--space-5)}
.menu-wrap{position:relative}
.menu{display:none;position:absolute;right:0;top:var(--space-7);width:145px;background:var(--surface-raised);border:1px solid var(--line);border-radius:var(--radius-2xs);padding:6px;box-shadow:var(--shadow);z-index:3}
.menu-open .menu{display:block}
+16 -49
View File
@@ -193,22 +193,31 @@ async function sampleNetworkInterfaces() {
}
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, appUsedBytes] = await Promise.all([
const [cpu, memory, swap, disk] = await Promise.all([
cgroupCpuPercent(),
cgroupMemory(),
cgroupSwap(),
fsp.statfs(dataDir).catch(() => null),
// Only walk /data (the same directorySize() the storage breakdown below already uses) when
// an assigned limit is actually configured -- it's the one case that needs it, and the walk
// isn't free, so skip it when the panel is just going to show whole-volume stats anyway.
assignedLimitBytes !== null ? directorySize(dataDir) : Promise.resolve(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,
@@ -544,7 +553,6 @@ function applyAdvancedSettings(item, body) {
if (body.accessListId !== undefined) item.accessListId = String(body.accessListId || "");
if (body.compression !== undefined) item.compression = ["off", "gzip", "automatic"].includes(body.compression) ? body.compression : "automatic";
if (body.hstsSubdomains !== undefined) item.hstsSubdomains = Boolean(body.hstsSubdomains);
if (body.blockCommonExploits !== undefined) item.blockCommonExploits = Boolean(body.blockCommonExploits);
if (body.requestHeaders !== undefined) item.requestHeaders = cleanHeaders(body.requestHeaders);
if (body.responseHeaders !== undefined) item.responseHeaders = cleanHeaders(body.responseHeaders);
if (body.upstreamTlsServerName !== undefined) item.upstreamTlsServerName = String(body.upstreamTlsServerName || "").trim().slice(0, 253);
@@ -603,19 +611,8 @@ function accessDirectives(accessListId) {
return output;
}
// Static, general-purpose ruleset for the "Block common exploits" toggle — not a full WAF. Rejects
// requests whose path matches common exploit-probe patterns before they reach the upstream: directory
// traversal, WordPress/PHP admin and scanner paths, dotfile exposure attempts, and SQL-injection-style
// query strings. One named matcher + one respond directive per host, so it's cheap to add or remove.
const COMMON_EXPLOIT_PATTERN = String.raw`(?i)(\.\./|\.\.\\|/etc/passwd|/wp-login\.php|/wp-admin(?:/|$)|/xmlrpc\.php|/\.env(?:$|\?)|/\.git/|/\.aws/|/vendor/phpunit|/phpunit(?:/|$)|eval\(|base64_decode\(|union(?:\s|%20|\+)+select|<script)`;
function exploitBlockDirectives(id) {
return [` @blocked-exploit-${id} {`, ` path_regexp ${caddyQuote(COMMON_EXPLOIT_PATTERN)}`, " }", ` respond @blocked-exploit-${id} 403`];
}
function commonHostDirectives(item) {
const output = [...accessDirectives(item.accessListId)];
if (item.blockCommonExploits) output.push(...exploitBlockDirectives(item.id));
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" : ""}`)}`);
@@ -1037,30 +1034,12 @@ async function readAccessLogs(limit = 100, host = "") {
return entries;
}
// Diagnostic timing (v0.16.44): this job reads Caddy's access-log files, JSON.parses up to 5000
// lines, hashes each one, and batch-inserts them -- all synchronous work that runs on Node's single
// thread and therefore blocks every other request in the app for its full duration, every time it
// runs (every 30 seconds). A user reported requests as simple as GET /api/sites randomly taking
// 6-14 seconds with a Network-tab Timing capture showing nearly all of it as server-side "Waiting"
// (TTFB) rather than connection/DNS time -- consistent with getting stuck behind a job like this
// one. Logging real durations here (only when a run takes long enough to plausibly explain that)
// gives proof of whether this is the actual cause before changing how it works, rather than
// shipping a fourth guess.
async function importAccessLogsToSqlite() {
if (!storage?.recordAccessEvents) return;
const startedAt = performance.now();
try {
const readStarted = performance.now();
const entries = await readAccessLogs(5000);
const readMs = Math.round(performance.now() - readStarted);
const hashStarted = performance.now();
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") }));
const hashMs = Math.round(performance.now() - hashStarted);
const insertStarted = performance.now();
storage.recordAccessEvents(events);
const insertMs = Math.round(performance.now() - insertStarted);
const totalMs = Math.round(performance.now() - startedAt);
if (totalMs > 500) console.warn(`[perf] importAccessLogsToSqlite took ${totalMs}ms for ${entries.length} entries (read ${readMs}ms, hash ${hashMs}ms, insert ${insertMs}ms) -- this blocks every other request while it runs.`);
} catch (error) { console.warn("Could not import access logs into SQLite:", error.message); }
}
@@ -1531,20 +1510,6 @@ 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.
// ============================================================================================
// Diagnostic timing (v0.16.44): logs any request that takes noticeably long to answer, alongside
// the importAccessLogsToSqlite instrumentation above -- together these should show, in the
// container's own logs, whether a slow page load lines up with a background job's run window or
// is a slow request in its own right. Placed first so it wraps the full request, including any
// auth/body-parsing work below it. Remove once the real cause behind reported multi-second page
// loads is confirmed and fixed; this is a temporary aid, not a permanent feature.
app.use((req, res, next) => {
const startedAt = performance.now();
res.on("finish", () => {
const durationMs = Math.round(performance.now() - startedAt);
if (durationMs > 1000) console.warn(`[perf] ${req.method} ${req.originalUrl} took ${durationMs}ms`);
});
next();
});
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.get(["/", "/index.html"], (req, res) => {
@@ -2634,6 +2599,8 @@ setInterval(() => checkConfigDrift().catch(error => console.warn("Config drift c
// 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 ---------------------------------