Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4050b004bd | |||
| 1aee27b539 | |||
| a6060627d9 | |||
| 13ff035fae | |||
| 69677fa9a8 | |||
| 98c4fd331d | |||
| 60f36dbfb7 | |||
| fb961ae96d | |||
| d98642cbc2 | |||
| 56e0583b2d | |||
| 8e430a9127 | |||
| 4576845604 | |||
| 987cf48b6c | |||
| e62f0cc988 | |||
| 823a116d09 | |||
| fd414ddaf4 | |||
| 89cabf4baf | |||
| f39d405de8 | |||
| 2bb875eb98 | |||
| d73f0cea3f | |||
| 72b2dc7458 | |||
| b17fce9adb | |||
| 1ff8b6691d | |||
| 97d62a1c3b | |||
| 94a651e4e5 |
+1
-1
@@ -5,7 +5,7 @@ FROM node:22-alpine
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache libcap-setcap su-exec tini && corepack enable
|
RUN apk add --no-cache libcap-setcap su-exec tini && corepack enable
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN pnpm install --prod --frozen-lockfile
|
RUN pnpm install --prod --no-frozen-lockfile
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "site-gateway",
|
"name": "site-gateway",
|
||||||
"version": "0.11.70",
|
"version": "0.11.96",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.5.16",
|
"adm-zip": "0.5.16",
|
||||||
"express": "5.1.0",
|
"express": "5.1.0",
|
||||||
"multer": "2.0.2"
|
"multer": "2.0.2",
|
||||||
|
"qrcode": "1.5.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
|
|||||||
+132
-19
@@ -1,5 +1,5 @@
|
|||||||
const $ = selector => document.querySelector(selector);
|
const $ = selector => document.querySelector(selector);
|
||||||
const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null };
|
const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false };
|
||||||
document.querySelector("#create-form [name=domain]")?.closest("label")?.childNodes[0] && (document.querySelector("#create-form [name=domain]").closest("label").childNodes[0].textContent = "Primary domain ");
|
document.querySelector("#create-form [name=domain]")?.closest("label")?.childNodes[0] && (document.querySelector("#create-form [name=domain]").closest("label").childNodes[0].textContent = "Primary domain ");
|
||||||
if (!document.querySelector("#create-form [name=accessListId]")) { const anchor = document.querySelector("#create-form [name=tls]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this hosted site and all of its domains.</small>'; anchor.before(label); } }
|
if (!document.querySelector("#create-form [name=accessListId]")) { const anchor = document.querySelector("#create-form [name=tls]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this hosted site and all of its domains.</small>'; anchor.before(label); } }
|
||||||
if (!document.querySelector("#settings-access-list")) { const anchor = document.querySelector("#settings-form [name=domain]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select id="settings-access-list" name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this route and all of its domains.</small>'; anchor.after(label); } }
|
if (!document.querySelector("#settings-access-list")) { const anchor = document.querySelector("#settings-form [name=domain]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select id="settings-access-list" name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this route and all of its domains.</small>'; anchor.after(label); } }
|
||||||
@@ -24,7 +24,7 @@ async function api(url, options = {}) {
|
|||||||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || "Request failed."); }
|
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || "Request failed."); }
|
||||||
return response.status === 204 ? null : response.json();
|
return response.status === 204 ? null : response.json();
|
||||||
}
|
}
|
||||||
function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; }
|
function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); }
|
||||||
function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); }
|
function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); }
|
||||||
function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); }
|
function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); }
|
||||||
function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; }
|
function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; }
|
||||||
@@ -79,11 +79,11 @@ function advancedFormBody(form, body, scoped) {
|
|||||||
const read = (name, fallback = "") => scoped ? scopedValue(scoped.formEl, scoped.scope, name, fallback) : (form.get(name) || fallback);
|
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);
|
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.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.hsts = form.has("hsts"); body.hstsSubdomains = checked("hstsSubdomains"); body.healthEnabled = checked("healthEnabled"); body.upstreamTlsInsecure = checked("upstreamTlsInsecure"); body.blockCommonExploits = checked("blockCommonExploits");
|
||||||
body.accessListId = read("accessListId", body.accessListId || "");
|
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.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);
|
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);
|
||||||
body.upstreams = String(form.get("upstreamsText") || "").split("\n").map(value => value.trim()).filter(Boolean);
|
body.upstreams = String(form.get("upstreamsText") || "").split("\n").map(value => value.trim()).filter(Boolean); body.lbPolicy = read("lbPolicy", "random");
|
||||||
body.healthPath = read("healthPath", "/"); body.healthMethod = read("healthMethod", "GET"); body.healthExpected = read("healthExpected", "200-499"); body.healthTimeoutSeconds = Number(read("healthTimeoutSeconds", "4")); body.healthRetries = Number(read("healthRetries", "0"));
|
body.healthPath = read("healthPath", "/"); body.healthMethod = read("healthMethod", "GET"); body.healthExpected = read("healthExpected", "200-499"); body.healthTimeoutSeconds = Number(read("healthTimeoutSeconds", "4")); body.healthRetries = Number(read("healthRetries", "0"));
|
||||||
delete body.requestHeadersText; delete body.responseHeadersText; delete body.customLocationsText;
|
delete body.requestHeadersText; delete body.responseHeadersText; delete body.customLocationsText;
|
||||||
return body;
|
return body;
|
||||||
@@ -93,11 +93,21 @@ document.addEventListener("submit", async event => {
|
|||||||
if (event.target?.id !== "settings-form" || !state.editing) return;
|
if (event.target?.id !== "settings-form" || !state.editing) return;
|
||||||
event.preventDefault(); event.stopImmediatePropagation();
|
event.preventDefault(); event.stopImmediatePropagation();
|
||||||
const form = new FormData(event.target), button = resolveSubmitter(event);
|
const form = new FormData(event.target), button = resolveSubmitter(event);
|
||||||
|
const certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile");
|
||||||
let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile;
|
let body = Object.fromEntries(form); delete body.certificateFile; delete body.privateKeyFile;
|
||||||
if (state.editing.kind === "proxy") body = advancedFormBody(form, body, { scope: "#settings-advanced", formEl: event.target });
|
if (state.editing.kind === "proxy") body = advancedFormBody(form, body, { scope: "#settings-advanced", formEl: event.target });
|
||||||
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") }; }
|
else { const scope = "#settings-hosted-advanced"; body = { domain: body.domain, domains: String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean), 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") }; }
|
||||||
|
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."; return; }
|
||||||
|
}
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try { await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${state.editing.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); $("#settings-dialog").close(); await refresh(); toast("Gateway settings applied."); }
|
try {
|
||||||
|
await api(`/api/${state.editing.kind === "proxy" ? "proxies" : "sites"}/${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; }
|
catch (error) { $("#settings-error").textContent = error.message; }
|
||||||
finally { button.disabled = false; }
|
finally { button.disabled = false; }
|
||||||
}, true);
|
}, true);
|
||||||
@@ -305,6 +315,19 @@ function renderUsers() {
|
|||||||
document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message); } }); old.replaceWith(select); });
|
document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message); } }); old.replaceWith(select); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAccount() {
|
||||||
|
if (!state.user) return;
|
||||||
|
$("#account-display-name").textContent = state.user.displayName || "—";
|
||||||
|
$("#account-username").textContent = state.user.username || "—";
|
||||||
|
$("#account-role").textContent = state.user.role === "administrator" ? "Administrator" : state.user.role === "viewer" ? "Viewer" : "Standard User";
|
||||||
|
const enabled = Boolean(state.user.mfaEnabled);
|
||||||
|
const pill = $("#account-mfa-status");
|
||||||
|
pill.innerHTML = `<span class="status-dot ${enabled ? "running" : "inactive"}"></span>${enabled ? "On" : "Off"}`;
|
||||||
|
$("#account-mfa-enable").classList.toggle("hidden", enabled);
|
||||||
|
$("#account-mfa-disable").classList.toggle("hidden", !enabled);
|
||||||
|
$("#account-mfa-recovery").classList.toggle("hidden", !enabled);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFeatureView() {
|
async function loadFeatureView() {
|
||||||
if (state.view === "certificates") { [state.certificates, state.readiness] = await Promise.all([api("/api/certificates"), api("/api/readiness")]); renderCertificates(); }
|
if (state.view === "certificates") { [state.certificates, state.readiness] = await Promise.all([api("/api/certificates"), api("/api/readiness")]); renderCertificates(); }
|
||||||
if (state.view === "logs") { state.logs = await api(`/api/logs?host=${encodeURIComponent($("#log-host").value)}`); renderLogs(); }
|
if (state.view === "logs") { state.logs = await api(`/api/logs?host=${encodeURIComponent($("#log-host").value)}`); renderLogs(); }
|
||||||
@@ -322,7 +345,7 @@ function render() {
|
|||||||
$("#dashboard-view").classList.toggle("hidden", !overview);
|
$("#dashboard-view").classList.toggle("hidden", !overview);
|
||||||
const management = state.view === "hosted" || state.view === "proxies";
|
const management = state.view === "hosted" || state.view === "proxies";
|
||||||
$("#management-view").classList.toggle("hidden", !management); $("#management-summary").classList.toggle("hidden", !(management || state.view === "streaming" || state.view === "redirects" || state.view === "access"));
|
$("#management-view").classList.toggle("hidden", !management); $("#management-summary").classList.toggle("hidden", !(management || state.view === "streaming" || state.view === "redirects" || state.view === "access"));
|
||||||
$("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#performance-view").classList.toggle("hidden", state.view !== "performance"); $("#users-view").classList.toggle("hidden", state.view !== "administration");
|
$("#certificates-view").classList.toggle("hidden", state.view !== "certificates"); $("#logs-view").classList.toggle("hidden", state.view !== "logs"); $("#performance-view").classList.toggle("hidden", state.view !== "performance"); $("#users-view").classList.toggle("hidden", state.view !== "administration"); $("#account-view").classList.toggle("hidden", state.view !== "account");
|
||||||
if (state.view === "administration") { const adminTab = state.adminTab || "users"; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item.dataset.adminTab === adminTab)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== adminTab)); }
|
if (state.view === "administration") { const adminTab = state.adminTab || "users"; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item.dataset.adminTab === adminTab)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== adminTab)); }
|
||||||
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
|
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
|
||||||
const adminUsersActive = state.view === "administration" && document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab === "users";
|
const adminUsersActive = state.view === "administration" && document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab === "users";
|
||||||
@@ -334,7 +357,7 @@ function render() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!management) {
|
if (!management) {
|
||||||
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, security, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."] };
|
const headings = { certificates:["Certificates","Expiration, issuer, and certificate-detection status for automatic HTTPS."], logs:["Access Logs & Gateway Events","Recent requests, upstream responses, and gateway health events served through Caddy."], performance:["Performance","Live and historical request throughput across your gateway."], administration:["Administration","Users, gateway defaults, backups, security, and updates."], streaming:["Streaming hosts","Forward raw TCP/UDP traffic on a specific port straight to another host and port."], redirects:["Redirect hosts","Send domains to a new destination with clear, predictable rules."], access:["Access Lists","Create reusable network and login protection for your hosts."], documentation:["Documentation","Plain-language guidance and real-world Site Gateway examples."], account:["My Account","Manage your profile, password, and two-factor authentication."] };
|
||||||
const heading = headings[state.view] || ["Site Gateway",""]; $("#page-title").textContent = heading[0]; $("#page-subtitle").textContent = heading[1];
|
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 === "streaming" ? "+ New streaming host" : state.view === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
|
$("#open-create").textContent = state.view === "administration" ? "+ Create user" : state.view === "streaming" ? "+ New streaming host" : state.view === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
|
||||||
if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0);
|
if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0);
|
||||||
@@ -342,7 +365,7 @@ function render() {
|
|||||||
if (state.view === "redirects") $("#redirect-empty .create-trigger").textContent = "Create a redirect host";
|
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") $("#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 === "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(); else if (state.view === "performance") renderPerformance();
|
if (state.view === "certificates") renderCertificates(); else if (state.view === "administration") renderUsers(); else if (state.view === "logs") renderLogs(); else if (state.view === "performance") renderPerformance(); else if (state.view === "account") renderAccount();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const items = state.view === "hosted" ? state.sites : state.proxies;
|
const items = state.view === "hosted" ? state.sites : state.proxies;
|
||||||
@@ -387,12 +410,23 @@ async function boot() {
|
|||||||
if (session.setupRequired) { $("#login").classList.add("hidden"); $("#dashboard").classList.add("hidden"); $("#setup-form [name=username]").value = session.user.username; if (!$("#setup-dialog").open) $("#setup-dialog").showModal(); return; }
|
if (session.setupRequired) { $("#login").classList.add("hidden"); $("#dashboard").classList.add("hidden"); $("#setup-form [name=username]").value = session.user.username; if (!$("#setup-dialog").open) $("#setup-dialog").showModal(); return; }
|
||||||
state.view = location.hash.slice(1) || "overview"; state.users = []; showDashboard(); state.user = session.user; $("#user-label").textContent = session.user?.displayName || session.username; document.querySelectorAll(".admin-only").forEach(element => element.classList.toggle("hidden", !canAdmin())); render(); state.config = await api("/api/config");
|
state.view = location.hash.slice(1) || "overview"; state.users = []; showDashboard(); state.user = session.user; $("#user-label").textContent = session.user?.displayName || session.username; document.querySelectorAll(".admin-only").forEach(element => element.classList.toggle("hidden", !canAdmin())); render(); state.config = await api("/api/config");
|
||||||
$("#version-label").textContent = `v${state.config.version || "unknown"}`;
|
$("#version-label").textContent = `v${state.config.version || "unknown"}`;
|
||||||
|
if (!state.loadedVersion) state.loadedVersion = state.config.version;
|
||||||
$("#port-range").textContent = `${state.config.minPort}–${state.config.maxPort}`; $("#port-help").textContent = `Direct LAN access range: ${state.config.minPort}–${state.config.maxPort}`;
|
$("#port-range").textContent = `${state.config.minPort}–${state.config.maxPort}`; $("#port-help").textContent = `Direct LAN access range: ${state.config.minPort}–${state.config.maxPort}`;
|
||||||
$("#create-form [name=port]").min = state.config.minPort; $("#create-form [name=port]").max = state.config.maxPort; await refresh(); if (state.view !== "overview") await loadFeatureView();
|
$("#create-form [name=port]").min = state.config.minPort; $("#create-form [name=port]").max = state.config.maxPort; await refresh(); if (state.view !== "overview") await loadFeatureView();
|
||||||
if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message)); }, 30000);
|
if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message)); }, 30000);
|
||||||
|
if (!state.updateCheckTimer) state.updateCheckTimer = setInterval(() => { if (!$("#dashboard").classList.contains("hidden")) checkForUpdate().catch(() => {}); }, 60000);
|
||||||
}
|
}
|
||||||
|
async function checkForUpdate() {
|
||||||
|
if (state.updateAvailable || !state.loadedVersion) return;
|
||||||
|
const config = await api("/api/config");
|
||||||
|
if (config.version && config.version !== state.loadedVersion) { state.updateAvailable = true; $("#update-banner").classList.remove("hidden"); }
|
||||||
|
}
|
||||||
|
$("#update-banner-refresh").addEventListener("click", () => location.reload());
|
||||||
|
$("#update-banner-dismiss").addEventListener("click", () => { $("#update-banner").classList.add("hidden"); state.updateAvailable = false; });
|
||||||
|
|
||||||
$("#login-form").addEventListener("submit", async event => { event.preventDefault(); $("#login-error").textContent = ""; try { await api("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); event.target.reset(); await boot(); } catch (error) { $("#login-error").textContent = error.message; } });
|
$("#login-form").addEventListener("submit", async event => { event.preventDefault(); $("#login-error").textContent = ""; try { const result = await api("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); if (result?.mfaRequired) { $("#login-form").classList.add("hidden"); $("#mfa-login-form").classList.remove("hidden"); $("#mfa-login-form [name=code]").focus(); return; } event.target.reset(); history.replaceState(null, "", `${location.pathname}${location.search}`); await boot(); } catch (error) { $("#login-error").textContent = error.message; } });
|
||||||
|
$("#mfa-login-form").addEventListener("submit", async event => { event.preventDefault(); $("#mfa-login-error").textContent = ""; try { const response = await fetch("/api/login/mfa", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || "That code didn't match. Try again."); event.target.reset(); history.replaceState(null, "", `${location.pathname}${location.search}`); await boot(); } catch (error) { $("#mfa-login-error").textContent = error.message; } });
|
||||||
|
$("#mfa-login-cancel").addEventListener("click", () => { $("#mfa-login-form").reset(); $("#mfa-login-error").textContent = ""; $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#login-form").elements.password.value = ""; setTimeout(() => $("#login-form").elements.password.focus(), 0); });
|
||||||
$("#setup-form").addEventListener("submit", async event => { event.preventDefault(); $("#setup-error").textContent = ""; try { await api("/api/setup/admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); $("#setup-dialog").close(); event.target.reset(); await boot(); showLogin("Administrator account saved. Sign in with your finalized credentials."); } catch (error) { $("#setup-error").textContent = error.message; } });
|
$("#setup-form").addEventListener("submit", async event => { event.preventDefault(); $("#setup-error").textContent = ""; try { await api("/api/setup/admin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); $("#setup-dialog").close(); event.target.reset(); await boot(); showLogin("Administrator account saved. Sign in with your finalized credentials."); } catch (error) { $("#setup-error").textContent = error.message; } });
|
||||||
$("#setup-dialog").addEventListener("cancel", event => event.preventDefault());
|
$("#setup-dialog").addEventListener("cancel", event => event.preventDefault());
|
||||||
$("#logout").addEventListener("click", async () => { await fetch("/api/logout", { method: "POST" }); showLogin(); });
|
$("#logout").addEventListener("click", async () => { await fetch("/api/logout", { method: "POST" }); showLogin(); });
|
||||||
@@ -400,7 +434,7 @@ $("#check-health").addEventListener("click", async event => { const button = eve
|
|||||||
$("#download-support").addEventListener("click", () => { location.href = "/api/support-report"; });
|
$("#download-support").addEventListener("click", () => { location.href = "/api/support-report"; });
|
||||||
$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { state.view = target; render(); loadFeatureView().catch(error => toast(error.message)); } });
|
$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { state.view = target; render(); loadFeatureView().catch(error => toast(error.message)); } });
|
||||||
function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); }
|
function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); }
|
||||||
document.querySelectorAll("nav, .aside-utilities").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); } }));
|
document.querySelectorAll("nav, .aside-utilities, .brand").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); } }));
|
||||||
$("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); });
|
$("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); });
|
||||||
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message)));
|
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message)));
|
||||||
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
|
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
|
||||||
@@ -414,8 +448,8 @@ function openCreate() {
|
|||||||
if (state.view === "streaming") { $("#stream-form").reset(); delete $("#stream-form").dataset.editing; $("#stream-title").textContent = "Create a streaming host"; $("#stream-form .button.primary").textContent = "Create streaming host"; $("#stream-error").textContent = ""; return $("#stream-dialog").showModal(); }
|
if (state.view === "streaming") { $("#stream-form").reset(); delete $("#stream-form").dataset.editing; $("#stream-title").textContent = "Create a streaming host"; $("#stream-form .button.primary").textContent = "Create streaming host"; $("#stream-error").textContent = ""; return $("#stream-dialog").showModal(); }
|
||||||
if (state.view === "redirects") { $("#redirect-form").reset(); delete $("#redirect-form").dataset.editing; $("#redirect-error").textContent = ""; return $("#redirect-dialog").showModal(); }
|
if (state.view === "redirects") { $("#redirect-form").reset(); delete $("#redirect-form").dataset.editing; $("#redirect-error").textContent = ""; return $("#redirect-dialog").showModal(); }
|
||||||
if (state.view === "access") { $("#access-form").reset(); delete $("#access-form").dataset.editing; $("#access-error").textContent = ""; $("#access-form .access-create-guidance")?.remove(); const assignmentSummary = $("#access-assignment-summary"); assignmentSummary?.classList.add("hidden"); if (assignmentSummary) assignmentSummary.innerHTML = ""; window.renderCredentialEditor?.([]); return $("#access-dialog").showModal(); }
|
if (state.view === "access") { $("#access-form").reset(); delete $("#access-form").dataset.editing; $("#access-error").textContent = ""; $("#access-form .access-create-guidance")?.remove(); const assignmentSummary = $("#access-assignment-summary"); assignmentSummary?.classList.add("hidden"); if (assignmentSummary) assignmentSummary.innerHTML = ""; window.renderCredentialEditor?.([]); return $("#access-dialog").showModal(); }
|
||||||
if (state.view === "proxies") { $("#proxy-form").reset(); $("#custom-certificate-fields").classList.remove("custom-certificate-visible"); $("#proxy-error").textContent = ""; return $("#proxy-dialog").showModal(); }
|
if (state.view === "proxies") { $("#proxy-form").reset(); $("#proxy-form").querySelectorAll("details").forEach(details => details.open = false); $("#custom-certificate-fields").classList.remove("custom-certificate-visible"); $("#proxy-error").textContent = ""; return $("#proxy-dialog").showModal(); }
|
||||||
$("#create-form").reset(); $("#create-error").textContent = ""; const used = new Set(state.sites.map(site => site.port)); let port = state.config.minPort; while (used.has(port)) port++; $("#create-form [name=port]").value = port; $("#create-dialog").showModal();
|
$("#create-form").reset(); $("#create-form").querySelectorAll("details").forEach(details => details.open = false); $("#create-error").textContent = ""; const used = new Set(state.sites.map(site => site.port)); let port = state.config.minPort; while (used.has(port)) port++; $("#create-form [name=port]").value = port; $("#create-dialog").showModal();
|
||||||
}
|
}
|
||||||
$("#open-create").addEventListener("click", openCreate);
|
$("#open-create").addEventListener("click", openCreate);
|
||||||
document.addEventListener("click", event => { if (event.target.closest(".create-trigger")) openCreate(); if (event.target.closest(".close-dialog")) event.target.closest("dialog").close(); if (!event.target.closest(".menu-wrap")) closeMenus(); });
|
document.addEventListener("click", event => { if (event.target.closest(".create-trigger")) openCreate(); if (event.target.closest(".close-dialog")) event.target.closest("dialog").close(); if (!event.target.closest(".menu-wrap")) closeMenus(); });
|
||||||
@@ -429,24 +463,23 @@ function ensureHostedHealthFields() { [document.querySelector("#create-form deta
|
|||||||
setInterval(ensureHostedHealthFields, 300);
|
setInterval(ensureHostedHealthFields, 300);
|
||||||
function openSettings(kind, id) {
|
function openSettings(kind, id) {
|
||||||
if (kind === "hosted") kind = "site";
|
if (kind === "hosted") kind = "site";
|
||||||
const item = (kind === "proxy" ? state.proxies : state.sites).find(value => value.id === id); if (!item) return; state.editing = { kind, id }; const form = $("#settings-form"); form.reset();
|
const item = (kind === "proxy" ? state.proxies : state.sites).find(value => value.id === id); if (!item) return; state.editing = { kind, id }; const form = $("#settings-form"); form.reset(); form.querySelectorAll("details").forEach(details => details.open = false);
|
||||||
$("#settings-title").textContent = kind === "proxy" ? "Edit proxy host" : "Domain & TLS"; $("#settings-name-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-target-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-advanced").classList.toggle("hidden", kind !== "proxy"); $("#settings-hosted-advanced").classList.toggle("hidden", kind !== "site");
|
$("#settings-title").textContent = kind === "proxy" ? "Edit proxy host" : "Domain & TLS"; $("#settings-name-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-target-wrap").classList.toggle("hidden", kind !== "proxy"); $("#settings-advanced").classList.toggle("hidden", kind !== "proxy"); $("#settings-hosted-advanced").classList.toggle("hidden", kind !== "site");
|
||||||
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 || "";
|
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") {
|
if (kind === "proxy") {
|
||||||
const scope = "#settings-advanced";
|
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, "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));
|
||||||
form.elements.customLocationsText.value = (item.locations || []).map(location => `${location.path} | ${location.target} | ${location.stripPrefix ? "strip" : "preserve"}`).join("\n");
|
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"));
|
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.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");
|
||||||
}
|
}
|
||||||
if (kind === "site") {
|
if (kind === "site") {
|
||||||
const scope = "#settings-hosted-advanced";
|
const scope = "#settings-hosted-advanced";
|
||||||
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, "healthRetries", item.healthRetries || 0); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "compression", item.compression || "automatic"); 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")); setScoped(form, scope, "hstsSubdomains", Boolean(item.hstsSubdomains)); setScoped(form, scope, "customConfig", item.customConfig || "");
|
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, "healthRetries", item.healthRetries || 0); setScoped(form, scope, "healthEnabled", item.healthEnabled !== false); setScoped(form, scope, "accessListId", item.accessListId || ""); setScoped(form, scope, "compression", item.compression || "automatic"); 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")); setScoped(form, scope, "hstsSubdomains", Boolean(item.hstsSubdomains)); setScoped(form, scope, "customConfig", item.customConfig || "");
|
||||||
}
|
}
|
||||||
$("#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();
|
$("#settings-error").textContent = ""; if (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");
|
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 = 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 => {
|
$("#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;
|
const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind;
|
||||||
@@ -565,6 +598,9 @@ function syncUpstreamTlsControls(form) {
|
|||||||
if (tlsSkip && !https) tlsSkip.checked = false;
|
if (tlsSkip && !https) tlsSkip.checked = false;
|
||||||
const help = tlsSkip?.closest("label")?.querySelector("small");
|
const help = tlsSkip?.closest("label")?.querySelector("small");
|
||||||
if (help) help.textContent = https ? "Use only for a trusted internal HTTPS service with a self-signed or hostname-mismatched certificate." : "Available only when the upstream uses HTTPS.";
|
if (help) help.textContent = https ? "Use only for a trusted internal HTTPS service with a self-signed or hostname-mismatched certificate." : "Available only when the upstream uses HTTPS.";
|
||||||
|
|
||||||
|
const lbPolicy = form.elements.lbPolicy;
|
||||||
|
if (lbPolicy) { const poolTargets = String(form.elements.upstreamsText?.value || "").split("\n").map(value => value.trim()).filter(Boolean); const multi = poolTargets.length > 1; lbPolicy.disabled = !multi; lbPolicy.closest("label")?.classList.toggle("control-disabled", !multi); if (!multi) lbPolicy.value = "random"; }
|
||||||
}
|
}
|
||||||
document.addEventListener("input", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); });
|
document.addEventListener("input", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); });
|
||||||
document.addEventListener("change", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); });
|
document.addEventListener("change", event => { if (event.target.matches('#proxy-form [name="target"],#proxy-form [name="upstreamsText"],#settings-form [name="target"],#settings-form [name="upstreamsText"]')) syncUpstreamTlsControls(event.target.form); });
|
||||||
@@ -573,4 +609,81 @@ 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); });
|
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);
|
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 slot = document.querySelector("#dashboard-jobs-slot"); if (!slot) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; slot.appendChild(panel); } panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="health-grid">${(system.jobs || []).map(job => `<div class="health-tile"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
|
function renderDashboardJobsSafe(system) { const slot = document.querySelector("#dashboard-jobs-slot"); if (!slot) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; slot.appendChild(panel); } panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="health-grid">${(system.jobs || []).map(job => `<div class="health-tile"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span class="health-tile-copy"><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
|
||||||
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);
|
|
||||||
|
$("#account-password-form").addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
$("#account-password-error").textContent = "";
|
||||||
|
const form = event.target;
|
||||||
|
const body = Object.fromEntries(new FormData(form));
|
||||||
|
if (String(body.newPassword) !== String(body.confirmPassword)) { $("#account-password-error").textContent = "The new passwords do not match."; return; }
|
||||||
|
try {
|
||||||
|
await api("/api/account/password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ currentPassword: body.currentPassword, newPassword: body.newPassword }) });
|
||||||
|
form.reset(); toast("Password changed.");
|
||||||
|
} catch (error) { $("#account-password-error").textContent = error.message; }
|
||||||
|
});
|
||||||
|
|
||||||
|
let mfaPasswordResolve = null;
|
||||||
|
function requestMfaPassword(title, heading) {
|
||||||
|
$("#mfa-password-title").textContent = title;
|
||||||
|
$("#mfa-password-heading").textContent = heading;
|
||||||
|
$("#mfa-password-error").textContent = "";
|
||||||
|
$("#mfa-password-form").reset();
|
||||||
|
$("#mfa-password-dialog").showModal();
|
||||||
|
return new Promise(resolve => { mfaPasswordResolve = resolve; });
|
||||||
|
}
|
||||||
|
$("#mfa-password-form").addEventListener("submit", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const password = new FormData(event.target).get("password");
|
||||||
|
$("#mfa-password-dialog").close();
|
||||||
|
mfaPasswordResolve?.(password);
|
||||||
|
mfaPasswordResolve = null;
|
||||||
|
});
|
||||||
|
$("#mfa-password-cancel").addEventListener("click", () => { $("#mfa-password-dialog").close(); mfaPasswordResolve?.(null); mfaPasswordResolve = null; });
|
||||||
|
|
||||||
|
$("#account-mfa-enable").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const result = await api("/api/account/mfa/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
||||||
|
$("#mfa-setup-qr").innerHTML = result.qrSvg;
|
||||||
|
$("#mfa-setup-secret").textContent = result.secret;
|
||||||
|
$("#mfa-setup-error").textContent = "";
|
||||||
|
$("#mfa-setup-confirm-form").reset();
|
||||||
|
$("#mfa-setup-dialog").showModal();
|
||||||
|
} catch (error) { toast(error.message); }
|
||||||
|
});
|
||||||
|
$("#mfa-setup-cancel").addEventListener("click", () => { $("#mfa-setup-dialog").close(); });
|
||||||
|
$("#mfa-setup-confirm-form").addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
$("#mfa-setup-error").textContent = "";
|
||||||
|
try {
|
||||||
|
const code = new FormData(event.target).get("code");
|
||||||
|
const result = await api("/api/account/mfa/confirm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }) });
|
||||||
|
$("#mfa-setup-dialog").close();
|
||||||
|
state.user.mfaEnabled = true;
|
||||||
|
renderAccount();
|
||||||
|
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
|
||||||
|
$("#mfa-recovery-dialog").showModal();
|
||||||
|
toast("Two-factor authentication enabled.");
|
||||||
|
} catch (error) { $("#mfa-setup-error").textContent = error.message; }
|
||||||
|
});
|
||||||
|
$("#mfa-recovery-done").addEventListener("click", () => { $("#mfa-recovery-dialog").close(); });
|
||||||
|
|
||||||
|
$("#account-mfa-disable").addEventListener("click", async () => {
|
||||||
|
const password = await requestMfaPassword("Disable two-factor authentication", "Confirm your password to continue");
|
||||||
|
if (!password) return;
|
||||||
|
try {
|
||||||
|
await api("/api/account/mfa/disable", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });
|
||||||
|
state.user.mfaEnabled = false;
|
||||||
|
renderAccount();
|
||||||
|
toast("Two-factor authentication disabled.");
|
||||||
|
} catch (error) { toast(error.message); }
|
||||||
|
});
|
||||||
|
$("#account-mfa-recovery").addEventListener("click", async () => {
|
||||||
|
const password = await requestMfaPassword("Regenerate recovery codes", "Confirm your password to continue");
|
||||||
|
if (!password) return;
|
||||||
|
try {
|
||||||
|
const result = await api("/api/account/mfa/recovery-codes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });
|
||||||
|
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
|
||||||
|
$("#mfa-recovery-dialog").showModal();
|
||||||
|
toast("Recovery codes regenerated. Your old codes no longer work.");
|
||||||
|
} catch (error) { toast(error.message); }
|
||||||
|
});
|
||||||
|
|||||||
+50
-9
File diff suppressed because one or more lines are too long
+107
-14
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 589 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 805 KiB |
File diff suppressed because one or more lines are too long
+143
-14
File diff suppressed because one or more lines are too long
+74
@@ -0,0 +1,74 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
// Minimal RFC 4648 base32 (no padding), and RFC 6238 TOTP on top of RFC 4226 HOTP.
|
||||||
|
// Implemented against Node's built-in crypto only — no third-party dependency.
|
||||||
|
|
||||||
|
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
|
||||||
|
export function base32Encode(buffer) {
|
||||||
|
let bits = "";
|
||||||
|
for (const byte of buffer) bits += byte.toString(2).padStart(8, "0");
|
||||||
|
let output = "";
|
||||||
|
for (let i = 0; i + 5 <= bits.length; i += 5) output += BASE32_ALPHABET[parseInt(bits.slice(i, i + 5), 2)];
|
||||||
|
const remainder = bits.length % 5;
|
||||||
|
if (remainder) output += BASE32_ALPHABET[parseInt(bits.slice(bits.length - remainder).padEnd(5, "0"), 2)];
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base32Decode(value) {
|
||||||
|
const cleaned = String(value || "").toUpperCase().replace(/[^A-Z2-7]/g, "");
|
||||||
|
let bits = "";
|
||||||
|
for (const char of cleaned) {
|
||||||
|
const index = BASE32_ALPHABET.indexOf(char);
|
||||||
|
if (index < 0) continue;
|
||||||
|
bits += index.toString(2).padStart(5, "0");
|
||||||
|
}
|
||||||
|
const bytes = [];
|
||||||
|
for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2));
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateTotpSecret() {
|
||||||
|
return base32Encode(crypto.randomBytes(20)); // 160-bit key, standard for authenticator apps
|
||||||
|
}
|
||||||
|
|
||||||
|
function hotp(secretBuffer, counter, digits = 6) {
|
||||||
|
const counterBuffer = Buffer.alloc(8);
|
||||||
|
counterBuffer.writeBigUInt64BE(BigInt(counter));
|
||||||
|
const hmac = crypto.createHmac("sha1", secretBuffer).update(counterBuffer).digest();
|
||||||
|
const offset = hmac[hmac.length - 1] & 0x0f;
|
||||||
|
const binary = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);
|
||||||
|
return String(binary % 10 ** digits).padStart(digits, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totpAt(base32Secret, forTime = Date.now(), step = 30, digits = 6) {
|
||||||
|
const counter = Math.floor(forTime / 1000 / step);
|
||||||
|
return hotp(base32Decode(base32Secret), counter, digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepts a code from the current step or one step on either side, to tolerate normal clock drift.
|
||||||
|
export function verifyTotp(base32Secret, code, { step = 30, digits = 6, window = 1, forTime = Date.now() } = {}) {
|
||||||
|
const candidate = String(code || "").trim().replace(/\s+/g, "");
|
||||||
|
if (!/^\d{6,8}$/.test(candidate)) return false;
|
||||||
|
const secretBuffer = base32Decode(base32Secret);
|
||||||
|
const baseCounter = Math.floor(forTime / 1000 / step);
|
||||||
|
for (let offset = -window; offset <= window; offset++) {
|
||||||
|
const expected = hotp(secretBuffer, baseCounter + offset, digits);
|
||||||
|
if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate.padStart(digits, "0")))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function otpauthUri({ secret, username, issuer = "Site Gateway" }) {
|
||||||
|
const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(username)}`;
|
||||||
|
return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRecoveryCodes(count = 10) {
|
||||||
|
const codes = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const raw = crypto.randomBytes(5).toString("hex").toUpperCase(); // 10 hex chars
|
||||||
|
codes.push(`${raw.slice(0, 5)}-${raw.slice(5, 10)}`);
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user