diff --git a/package.json b/package.json
index f2c1609..8b236ea 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "site-gateway",
- "version": "0.11.31",
+ "version": "0.11.32",
"private": true,
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
"type": "module",
diff --git a/src/public/app.js b/src/public/app.js
index ab39a82..eeb1f8f 100644
--- a/src/public/app.js
+++ b/src/public/app.js
@@ -51,6 +51,10 @@ function formatTime(value) {
function certificateStatusLabel(status) { return ({ healthy:"Healthy", warning:"Renewal due soon", critical:"Renewal required urgently", expired:"Expired", pending:"Awaiting Caddy / ACME certificate", mismatch:"Certificate does not cover this domain" }[status] || String(status || "Unknown")).replaceAll("-", " "); }
function parseHeaderLines(value) { return String(value || "").split("\n").map(line => { const index = line.indexOf(":"); return index > 0 ? { name:line.slice(0,index).trim(), value:line.slice(index+1).trim() } : null; }).filter(Boolean); }
function monitoringChecked(form, kind) { const scope = kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; return Boolean(form.querySelector(`${scope} [name="healthEnabled"]`)?.checked); }
+// event.submitter is null on implicit form submission (e.g. pressing Enter in a field instead of
+// clicking the button), which previously crashed every save handler below on `button.disabled = true`
+// and silently dropped the whole save. Fall back to the form's actual submit button.
+function resolveSubmitter(event) { return event.submitter || event.target.querySelector('button:not([type="button"])'); }
function scopedValue(form, scope, name, fallback = "") { return form.querySelector(`${scope} [name="${name}"]`)?.value || fallback; }
function advancedFormBody(form, body) {
body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean);
@@ -66,7 +70,7 @@ function advancedFormBody(form, body) {
document.addEventListener("submit", async event => {
if (event.target?.id !== "settings-form" || !state.editing) return;
event.preventDefault(); event.stopImmediatePropagation();
- const form = new FormData(event.target), button = event.submitter;
+ const form = new FormData(event.target), button = resolveSubmitter(event);
let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile;
if (state.editing.kind === "proxy") body = advancedFormBody(form, body);
else { const scope = "#settings-hosted-advanced"; body = { domain: body.domain, tls: body.tls, hsts: form.has("hsts"), accessListId: scopedValue(event.target, scope, "accessListId"), healthEnabled: monitoringChecked(event.target, "site"), healthPath: scopedValue(event.target, scope, "healthPath", "/"), healthMethod: scopedValue(event.target, scope, "healthMethod", "GET"), healthExpected: scopedValue(event.target, scope, "healthExpected", "200-499"), healthTimeoutSeconds: Number(scopedValue(event.target, scope, "healthTimeoutSeconds", "4")), healthRetries: Number(scopedValue(event.target, scope, "healthRetries", "0")), compression: scopedValue(event.target, scope, "compression", "automatic"), requestHeaders: parseHeaderLines(scopedValue(event.target, scope, "requestHeadersText")), responseHeaders: parseHeaderLines(scopedValue(event.target, scope, "responseHeadersText")), hstsSubdomains: event.target.querySelector(`${scope} [name="hstsSubdomains"]`)?.checked === true, customConfig: scopedValue(event.target, scope, "customConfig") }; }
@@ -253,6 +257,7 @@ function render() {
const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1];
$("#open-create").textContent = state.view === "administration" ? "+ Create user" : state.view === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host";
+ if (state.view === "redirects") $("#redirect-empty").classList.toggle("hidden", !state.loaded || state.redirects.length > 0);
if (state.view === "redirects") { const items = state.redirects; const running = items.filter(item => item.enabled !== false).length, disabled = items.length - running; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = 0; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = "status-dot inactive"; $(".port-note").classList.add("hidden"); }
if (state.view === "certificates") renderCertificates(); else if (state.view === "administration") renderUsers(); else if (state.view === "logs") renderLogs();
return;
@@ -332,8 +337,8 @@ document.addEventListener("click", event => { if (event.target.closest(".create-
document.addEventListener("keydown", event => { if (event.key === "Escape") closeMenus(); });
document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); }));
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message)));
-$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = event.submitter; button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
-$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = event.submitter; button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
+$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
+$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
function ensureHostedHealthFields() { [document.querySelector("#create-form details"), document.querySelector("#settings-hosted-advanced")].forEach(details => { if (!details || details.querySelector("[name=healthEnabled]")) return; const access = details.querySelector("[name=accessListId]")?.closest("label"); if (!access) return; access.insertAdjacentHTML("afterend", 'Health-check path Health-check methodGET — retrieve a response HEAD — headers only Expected statusExamples: 200, 200,204, or 200-399. Timeout in seconds Retries Monitor this site '); }); }
setInterval(ensureHostedHealthFields, 300);
@@ -348,7 +353,7 @@ function openSettings(kind, id) {
$("#settings-error").textContent = ""; if (kind === "proxy" && form.elements.domainsText) form.elements.domainsText.value = (item.domains || []).filter(domain => domain !== item.domain).join("\n"); $("#settings-dialog").showModal();
document.querySelector("#settings-form .custom-certificate-fields")?.classList.toggle("custom-certificate-visible", kind === "proxy" && form.elements.tls.value === "custom");
}
-$("#settings-form").addEventListener("submit", async event => { event.preventDefault(); const button = event.submitter; button.disabled = true; button.textContent = "Applying…"; $("#settings-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"); let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile; body = state.editing.kind === "proxy" ? advancedFormBody(form, body) : { domain:body.domain, tls:body.tls, hsts:form.has("hsts") }; const uploadCustom = state.editing.kind === "proxy" && body.tls === "custom" && certificate?.size && privateKey?.size; if (state.editing.kind === "proxy" && body.tls === "custom" && !uploadCustom) { const existing = state.proxies.find(item => item.id === state.editing.id); if (!existing?.certificatePath) { $("#settings-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Save & apply"; return; } } try { const base = state.editing.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method:"POST", body:files }); } $("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); } catch (error) { $("#settings-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Save & apply"; } });
+$("#settings-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Applying…"; $("#settings-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"); let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile; body = state.editing.kind === "proxy" ? advancedFormBody(form, body) : { domain:body.domain, tls:body.tls, hsts:form.has("hsts") }; const uploadCustom = state.editing.kind === "proxy" && body.tls === "custom" && certificate?.size && privateKey?.size; if (state.editing.kind === "proxy" && body.tls === "custom" && !uploadCustom) { const existing = state.proxies.find(item => item.id === state.editing.id); if (!existing?.certificatePath) { $("#settings-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Save & apply"; return; } } try { const base = state.editing.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (uploadCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${state.editing.id}/certificate`, { method:"POST", body:files }); } $("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); } catch (error) { $("#settings-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Save & apply"; } });
$("#site-grid").addEventListener("click", async event => {
const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind;
@@ -406,7 +411,7 @@ $("#save-icon-url").addEventListener("click", async () => {
catch (error) { $("#icon-error").textContent = error.message; }
});
$("#user-form").addEventListener("submit", async event => {
- event.preventDefault(); const button = event.submitter; button.disabled = true; $("#user-error").textContent = "";
+ event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#user-error").textContent = "";
try {
await api("/api/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) });
$("#user-dialog").close(); await loadFeatureView(); toast("User created.");
@@ -435,7 +440,7 @@ $("#user-list").addEventListener("click", async event => {
finally { button.disabled = false; }
});
$("#password-form").addEventListener("submit", async event => {
- event.preventDefault(); const button = event.submitter; button.disabled = true; $("#password-error").textContent = "";
+ event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; $("#password-error").textContent = "";
try {
await api(`/api/users/${state.passwordTarget}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: new FormData(event.target).get("password") }) });
$("#password-dialog").close(); state.passwordTarget = null; await loadFeatureView(); toast("Password reset.");
@@ -470,4 +475,4 @@ document.addEventListener("click", event => { if (event.target.closest(".create-
document.addEventListener("click", event => { const trigger = event.target.closest("[data-action=settings],[data-card-action=settings]"); if (!trigger) return; setTimeout(() => { const item = (state.editing?.kind === "proxy" ? state.proxies : state.sites).find(value => value.id === state.editing?.id); if (!item) return; const scope = state.editing.kind === "proxy" ? "#settings-advanced" : "#settings-hosted-advanced"; const checkbox = document.querySelector(`${scope} [name="healthEnabled"]`); if (checkbox) checkbox.checked = !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false"); }, 0); });
setInterval(() => { if (state.view !== 'access') return; const items = state.accessLists || []; const enabled = items.filter(item => item.enabled !== false).length; const disabled = items.length - enabled; $('#running-count').textContent = enabled; $('#disabled-count').textContent = disabled; $('#error-count').textContent = 0; $('#running-label').textContent = enabled ? 'Enabled' : 'None enabled'; $('#disabled-label').textContent = disabled ? 'Disabled' : 'None disabled'; $('#error-label').textContent = 'No issues'; $('#running-dot').className = `status-dot ${enabled ? 'running' : 'inactive'}`; $('#disabled-dot').className = `status-dot ${disabled ? 'disabled' : 'inactive'}`; $('#error-dot').className = 'status-dot inactive'; $('.port-note').classList.add('hidden'); }, 500);
function renderDashboardJobsSafe(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"); if (!columns) return; const health = document.querySelector("[data-dashboard-health]") || columns.querySelector(".health-list")?.closest("section"); if (health) { health.dataset.dashboardHealth = "true"; if (health.parentElement === columns) columns.parentElement.insertBefore(health, columns); } let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } panel.innerHTML = `
${(system.jobs || []).map(job => `
${escapeHtml(job.name)} ${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}
`).join("")}
`; }
-document.addEventListener("submit", async event => { if (event.target?.id !== "settings-form" || state.editing?.kind !== "site") return; event.preventDefault(); event.stopImmediatePropagation(); const button = event.submitter; button.disabled = true; const form = new FormData(event.target); try { await api(`/api/sites/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ domain: form.get("domain"), domains: String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean), tls: form.get("tls"), hsts: form.has("hsts"), accessListId: form.get("accessListId") || "", healthEnabled: form.has("healthEnabled"), healthPath: form.get("healthPath") || "/", healthMethod: form.get("healthMethod") || "GET", healthExpected: form.get("healthExpected") || "200-499", healthTimeoutSeconds: Number(form.get("healthTimeoutSeconds") || 4), healthRetries: Number(form.get("healthRetries") || 0), compression: form.get("compression") || "automatic", requestHeaders: parseHeaderLines(form.get("requestHeadersText")), responseHeaders: parseHeaderLines(form.get("responseHeadersText")), hstsSubdomains: form.has("hstsSubdomains"), customConfig: form.get("customConfig") || "" }) }); document.querySelector("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); } catch (error) { document.querySelector("#settings-error").textContent = error.message; } finally { button.disabled = false; } }, true);
+document.addEventListener("submit", async event => { if (event.target?.id !== "settings-form" || state.editing?.kind !== "site") return; event.preventDefault(); event.stopImmediatePropagation(); const button = resolveSubmitter(event); button.disabled = true; const form = new FormData(event.target); try { await api(`/api/sites/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ domain: form.get("domain"), domains: String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean), tls: form.get("tls"), hsts: form.has("hsts"), accessListId: form.get("accessListId") || "", healthEnabled: form.has("healthEnabled"), healthPath: form.get("healthPath") || "/", healthMethod: form.get("healthMethod") || "GET", healthExpected: form.get("healthExpected") || "200-499", healthTimeoutSeconds: Number(form.get("healthTimeoutSeconds") || 4), healthRetries: Number(form.get("healthRetries") || 0), compression: form.get("compression") || "automatic", requestHeaders: parseHeaderLines(form.get("requestHeadersText")), responseHeaders: parseHeaderLines(form.get("responseHeadersText")), hstsSubdomains: form.has("hstsSubdomains"), customConfig: form.get("customConfig") || "" }) }); document.querySelector("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); } catch (error) { document.querySelector("#settings-error").textContent = error.message; } finally { button.disabled = false; } }, true);
diff --git a/src/public/index.html b/src/public/index.html
index 235bcf9..17285e5 100644
--- a/src/public/index.html
+++ b/src/public/index.html
@@ -144,7 +144,7 @@
Security, health & updates Configuration safety Site Gateway validates generated Caddy configuration before every reload and retains the active configuration when validation fails. Container updates Updates are installed by pulling a new pinned image. Create a backup before changing versions.
Troubleshooting & support
Gateway diagnostics Run checks and download a redacted report when you need to investigate a gateway issue.
Download support report
The report includes version, configuration health, certificate readiness, upstream checks, and recent events. Passwords, private keys, session secrets, cookies, and certificate contents are excluded.
Danger Zone These actions can permanently remove Site Gateway data. Review each warning carefully before continuing.
Restore defaults
Reset gateway preferences Restore default site behavior, backup scheduling, certificate thresholds, and interface preferences. Your users, routes, certificates, logs, and backups remain intact.
Restore default settings Permanent action
Factory reset Deletes all Site Gateway data under /data, including users, routes, certificates, logs, backups, and settings. Docker-mounted files outside /data are not affected. The container restarts at first-install setup.
-
↪
Create your first redirect Send an old domain to a new destination while preserving its path if you choose.
Create a redirect host
+
↪
Create your first redirect Send an old domain to a new destination while preserving its path if you choose.
Create a redirect host
Site Gateway manual
Simple routing for homelabs and small teams A complete guide to publishing sites, routing applications, securing domains, and recovering safely. Start with the defaults, then use the advanced controls when you understand the trade-offs.
Search the complete manual Searches purpose, fields, examples, troubleshooting, and expert notes.
Contents
Introduction Dashboard Hosted Sites Proxy Hosts Redirect Hosts Certificates Logs Access Lists Administration Backup & Restore Danger Zone Common Controls
Introduction
Why Site Gateway exists Reverse proxies often expose powerful settings without explaining what they change. Site Gateway provides a visual, Caddy-powered control plane for static sites, proxy routes, redirects, HTTPS, health checks, access control, and recovery.
Novice path Create one route, test it locally, then add a domain and TLS. Keep defaults until you have a reason to change them.
Expert note Configuration is stored in SQLite under /data and generated Caddy configuration is validated before reload.
Example Publish a ZIP on a direct port first, then add www.example.com after DNS and port forwarding are ready.