Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 823a116d09 | |||
| fd414ddaf4 | |||
| 89cabf4baf | |||
| f39d405de8 | |||
| 2bb875eb98 | |||
| d73f0cea3f | |||
| 72b2dc7458 |
+1
-1
@@ -5,7 +5,7 @@ FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libcap-setcap su-exec tini && corepack enable
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
RUN pnpm install --prod --no-frozen-lockfile
|
||||
COPY src ./src
|
||||
COPY --from=caddy /usr/bin/caddy /usr/bin/caddy
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "site-gateway",
|
||||
"version": "0.11.74",
|
||||
"version": "0.11.81",
|
||||
"private": true,
|
||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||
"type": "module",
|
||||
@@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"adm-zip": "0.5.16",
|
||||
"express": "5.1.0",
|
||||
"multer": "2.0.2"
|
||||
"multer": "2.0.2",
|
||||
"qrcode": "1.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
+98
-5
@@ -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."); }
|
||||
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 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; }
|
||||
@@ -315,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); });
|
||||
}
|
||||
|
||||
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() {
|
||||
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(); }
|
||||
@@ -332,7 +345,7 @@ function render() {
|
||||
$("#dashboard-view").classList.toggle("hidden", !overview);
|
||||
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"));
|
||||
$("#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)); }
|
||||
$("#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";
|
||||
@@ -344,7 +357,7 @@ function render() {
|
||||
return;
|
||||
}
|
||||
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];
|
||||
$("#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);
|
||||
@@ -352,7 +365,7 @@ function render() {
|
||||
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(); 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;
|
||||
}
|
||||
const items = state.view === "hosted" ? state.sites : state.proxies;
|
||||
@@ -402,7 +415,9 @@ async function boot() {
|
||||
if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message)); }, 30000);
|
||||
}
|
||||
|
||||
$("#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(); 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(); 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-dialog").addEventListener("cancel", event => event.preventDefault());
|
||||
$("#logout").addEventListener("click", async () => { await fetch("/api/logout", { method: "POST" }); showLogin(); });
|
||||
@@ -582,3 +597,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); });
|
||||
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>`; }
|
||||
|
||||
$("#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); }
|
||||
});
|
||||
|
||||
+78
-4
@@ -7,7 +7,7 @@
|
||||
<title>Site Gateway</title>
|
||||
<meta name="description" content="Host sites, proxy services, and manage HTTPS from one simple dashboard.">
|
||||
<link rel="icon" type="image/png" href="/site-gateway-icon-approved.png">
|
||||
<link rel="stylesheet" href="/styles.css?v=0.11.73">
|
||||
<link rel="stylesheet" href="/styles.css?v=0.11.81">
|
||||
</head>
|
||||
<body>
|
||||
<div id="login" class="login-shell hidden">
|
||||
@@ -24,6 +24,19 @@
|
||||
<p id="login-error" class="error" role="alert"></p>
|
||||
<button class="button primary wide">Sign in</button>
|
||||
</form>
|
||||
<form id="mfa-login-form" class="login-card hidden">
|
||||
<div class="brand-stack">
|
||||
<img class="brand-icon-lg" src="/site-gateway-icon-approved.png" alt="">
|
||||
<img class="brand-wordmark-lg" src="/site-gateway-wordmark-approved.png" alt="Site Gateway">
|
||||
</div>
|
||||
<p class="eyebrow">Two-factor authentication</p>
|
||||
<h1>Enter your code</h1>
|
||||
<p class="muted">Enter the 6-digit code from your authenticator app, or one of your recovery codes.</p>
|
||||
<label>Code<input name="code" autocomplete="one-time-code" inputmode="numeric" maxlength="11" required autofocus></label>
|
||||
<p id="mfa-login-error" class="error" role="alert"></p>
|
||||
<button class="button primary wide">Verify</button>
|
||||
<button type="button" id="mfa-login-cancel" class="button secondary wide">Back to sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<dialog id="setup-dialog" class="setup-dialog">
|
||||
@@ -58,7 +71,7 @@
|
||||
<button data-view="performance">Performance</button>
|
||||
<button data-view="logs">Logs</button>
|
||||
</nav>
|
||||
<div class="aside-utilities"><button class="admin-only" data-view="administration">Administration</button><button data-view="documentation">Documentation</button></div>
|
||||
<div class="aside-utilities"><button class="admin-only" data-view="administration">Administration</button><button data-view="account">My Account</button><button data-view="documentation">Documentation</button></div>
|
||||
<div class="aside-footer"><span>Installed version</span><strong id="version-label">v—</strong></div>
|
||||
</aside>
|
||||
<main>
|
||||
@@ -74,7 +87,7 @@
|
||||
<button data-view="certificates">TLS</button>
|
||||
<button data-view="performance">Perf</button>
|
||||
<button data-view="logs">Logs</button>
|
||||
<button class="admin-only" data-view="administration">Admin</button><button data-view="documentation">Docs</button>
|
||||
<button class="admin-only" data-view="administration">Admin</button><button data-view="account">Account</button><button data-view="documentation">Docs</button>
|
||||
</nav>
|
||||
<header>
|
||||
<div><p class="eyebrow">Gateway control</p><h1 id="page-title">Dashboard</h1><p id="page-subtitle" class="muted">Health, activity, and system status at a glance.</p></div>
|
||||
@@ -179,6 +192,67 @@
|
||||
<section data-admin-panel="security" class="hidden settings-panel"><h2>Security, health & updates</h2><section class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Reliability</p><h2>Configuration safety & updates</h2></div></div><dl class="system-grid"><div class="system-tile"><dt>Configuration safety</dt><dd>Validates generated Caddy configuration before every reload and retains the active configuration when validation fails.</dd></div><div class="system-tile"><dt>Container updates</dt><dd>Installed by pulling a new pinned image. Create a backup before changing versions.</dd></div></dl></section><section class="dashboard-panel"><div class="panel-heading"><div><p class="eyebrow">Troubleshooting & support</p><h2>Gateway diagnostics</h2><p class="muted">Run checks and download a redacted report when you need to investigate a gateway issue.</p></div><button id="download-support" class="button secondary admin-only">Download support report</button></div><p class="muted feature-note">The report includes version, configuration health, certificate readiness, upstream checks, and recent events. Passwords, private keys, session secrets, cookies, and certificate contents are excluded.</p></section><form id="health-settings-form" class="settings-form"><label>Renewing-soon warning<input name="warningDays" type="number" min="8" max="120" value="30"><small>Days remaining before a certificate is highlighted.</small></label><label>Critical warning<input name="criticalDays" type="number" min="1" max="119" value="7"><small>Must be lower than the renewing-soon threshold.</small></label><label>Stale health data<input name="staleMinutes" type="number" min="2" max="1440" value="10"><small>Minutes before a displayed check is considered old.</small></label><div class="dialog-actions"><button class="button primary">Save health settings</button></div></form></section>
|
||||
<section data-admin-panel="danger" class="hidden settings-panel danger-zone"><h2>Danger Zone</h2><p class="muted">These actions can permanently remove Site Gateway data. Review each warning carefully before continuing.</p><div class="danger-card"><p class="eyebrow">Restore defaults</p><h3>Reset gateway preferences</h3><p>Restore default site behavior, backup scheduling, certificate thresholds, and interface preferences. Your users, routes, certificates, logs, and backups remain intact.</p><button id="restore-defaults" class="button secondary">Restore default settings</button></div><div class="danger-card destructive"><p class="eyebrow">Permanent action</p><h3>Factory reset</h3><p>Deletes all Site Gateway data under <code>/data</code>, including users, routes, certificates, logs, backups, and settings. Docker-mounted files outside <code>/data</code> are not affected. The container restarts at first-install setup.</p><form id="factory-reset-form" class="danger-form"><label>Administrator username<input name="username" autocomplete="username" required></label><label>Administrator password<input name="password" type="password" autocomplete="current-password" required></label><label>Type <strong>FACTORY RESET</strong> to confirm<input name="confirmation" required autocomplete="off"></label><p id="factory-reset-error" class="error"></p><div class="danger-actions"><button class="button secondary" type="button" id="factory-reset-cancel">Cancel</button><button class="button danger" type="submit">Erase all data and reset</button></div></form></div></section>
|
||||
</section>
|
||||
<section id="account-view" class="feature-view hidden">
|
||||
<section class="dashboard-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">Profile</p><h2>Your account</h2></div></div>
|
||||
<dl class="system-grid">
|
||||
<div class="system-tile"><dt>Display name</dt><dd id="account-display-name">—</dd></div>
|
||||
<div class="system-tile"><dt>Username</dt><dd id="account-username">—</dd></div>
|
||||
<div class="system-tile"><dt>Role</dt><dd id="account-role">—</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="dashboard-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">Password</p><h2>Change your password</h2></div></div>
|
||||
<form id="account-password-form" class="settings-form">
|
||||
<label>Current password<input name="currentPassword" type="password" autocomplete="current-password" required></label>
|
||||
<label>New password<input name="newPassword" type="password" minlength="8" autocomplete="new-password" required></label>
|
||||
<label style="grid-column:1/-1;max-width:calc(50% - 9px)">Confirm new password<input name="confirmPassword" type="password" minlength="8" autocomplete="new-password" required></label>
|
||||
<p id="account-password-error" class="error" role="alert"></p>
|
||||
<div class="dialog-actions"><button class="button primary" type="submit">Change password</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="dashboard-panel">
|
||||
<div class="panel-heading"><div><p class="eyebrow">Security</p><h2>Two-factor authentication</h2></div><span id="account-mfa-status" class="status-pill"><span class="status-dot inactive"></span>Off</span></div>
|
||||
<p class="muted feature-note">Require a 6-digit code from an authenticator app, in addition to your password, when signing in.</p>
|
||||
<div id="account-mfa-actions" class="dialog-actions">
|
||||
<button id="account-mfa-enable" class="button primary">Enable two-factor authentication</button>
|
||||
<button id="account-mfa-disable" class="button secondary danger-text hidden">Disable two-factor authentication</button>
|
||||
<button id="account-mfa-recovery" class="button secondary hidden">Regenerate recovery codes</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<dialog id="mfa-setup-dialog" class="setup-dialog">
|
||||
<div class="dialog-card">
|
||||
<p class="eyebrow">Two-factor authentication</p>
|
||||
<h1>Scan this code</h1>
|
||||
<p class="muted">Scan with an authenticator app (Google Authenticator, 1Password, Authy, etc.), or enter the key manually.</p>
|
||||
<div id="mfa-setup-qr" class="mfa-qr"></div>
|
||||
<p class="muted">Manual entry key: <code id="mfa-setup-secret"></code></p>
|
||||
<form id="mfa-setup-confirm-form">
|
||||
<label>Enter the 6-digit code to confirm<input name="code" autocomplete="one-time-code" inputmode="numeric" maxlength="6" required></label>
|
||||
<p id="mfa-setup-error" class="error" role="alert"></p>
|
||||
<div class="dialog-actions"><button type="button" id="mfa-setup-cancel" class="button secondary">Cancel</button><button class="button primary" type="submit">Confirm and enable</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="mfa-password-dialog" class="setup-dialog">
|
||||
<form id="mfa-password-form" class="dialog-card">
|
||||
<p class="eyebrow" id="mfa-password-title">Confirm your password</p>
|
||||
<h1 id="mfa-password-heading">Confirm it's you</h1>
|
||||
<label>Current password<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<p id="mfa-password-error" class="error" role="alert"></p>
|
||||
<div class="dialog-actions"><button type="button" id="mfa-password-cancel" class="button secondary">Cancel</button><button class="button primary" type="submit">Continue</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="mfa-recovery-dialog" class="setup-dialog">
|
||||
<div class="dialog-card">
|
||||
<p class="eyebrow">Save these now</p>
|
||||
<h1>Your recovery codes</h1>
|
||||
<p class="muted">Each code can be used once to sign in if you lose access to your authenticator app. Store them somewhere safe — they won't be shown again.</p>
|
||||
<pre id="mfa-recovery-codes" class="mfa-recovery-codes"></pre>
|
||||
<div class="dialog-actions"><button type="button" id="mfa-recovery-done" class="button primary">I've saved these codes</button></div>
|
||||
</div>
|
||||
</dialog>
|
||||
<section id="management-summary" class="summary hidden" aria-label="Site summary"><div><span id="running-dot" class="status-dot inactive"></span><strong id="running-count">0</strong><span id="running-label">No sites running</span></div><div><span id="disabled-dot" class="status-dot inactive"></span><strong id="disabled-count">0</strong><span id="disabled-label">No disabled sites</span></div><div><span id="error-dot" class="status-dot inactive"></span><strong id="error-count">0</strong><span id="error-label">No issues</span></div><div class="port-note">Ports <strong id="port-range">9000–9099</strong></div></section>
|
||||
<section id="streaming-view" class="feature-view hidden"><div id="stream-list" class="site-grid"></div><section id="stream-empty" class="empty hidden"><div class="empty-icon">⇄</div><h2>Create your first streaming host</h2><p>Forward raw TCP or UDP traffic on a specific port straight to another host and port — no domain, no HTTPS.</p><button class="button primary create-trigger">Create a streaming host</button></section></section>
|
||||
<section id="redirects-view" class="feature-view hidden"><div id="redirect-list" class="site-grid"></div><section id="redirect-empty" class="empty hidden"><div class="empty-icon">↪</div><h2>Create your first redirect</h2><p>Send an old domain to a new destination while preserving its path if you choose.</p><button class="button primary create-trigger">Create a redirect host</button></section></section>
|
||||
@@ -283,6 +357,6 @@
|
||||
</dialog>
|
||||
<input id="replace-files" type="file" accept=".zip,.html,text/html,application/zip" hidden>
|
||||
<div id="toast" class="toast" role="status"></div>
|
||||
<script src="/app.js?v=0.11.28" defer></script><script src="/features.js?v=0.11.28" defer></script>
|
||||
<script src="/app.js?v=0.11.80" defer></script><script src="/features.js?v=0.11.77" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+120
-10
@@ -12,7 +12,9 @@ import { promisify } from "node:util";
|
||||
import AdmZip from "adm-zip";
|
||||
import express from "express";
|
||||
import multer from "multer";
|
||||
import QRCode from "qrcode";
|
||||
import { LOCAL_INSTANCE_ID, openStorage } from "./storage.js";
|
||||
import { generateTotpSecret, verifyTotp, otpauthUri, generateRecoveryCodes } from "./totp.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageMetadata = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
|
||||
@@ -111,8 +113,8 @@ async function passwordMatches(password, record) {
|
||||
}
|
||||
|
||||
function publicUser(user) {
|
||||
const { password, sessionVersion, ...safe } = user;
|
||||
return safe;
|
||||
const { password, sessionVersion, mfaSecret, mfaPendingSecret, mfaRecoveryCodes, ...safe } = user;
|
||||
return { ...safe, mfaEnabled: Boolean(user.mfaEnabled) };
|
||||
}
|
||||
|
||||
function activeAdministrators() {
|
||||
@@ -193,6 +195,8 @@ async function loadSites() {
|
||||
for (const user of users) {
|
||||
if (user.setupRequired === undefined) { user.setupRequired = false; usersChanged = true; }
|
||||
if (!user.sessionVersion) { user.sessionVersion = crypto.randomBytes(16).toString("hex"); usersChanged = true; }
|
||||
if (user.mfaEnabled === undefined) { user.mfaEnabled = false; usersChanged = true; }
|
||||
if (!Array.isArray(user.mfaRecoveryCodes)) { user.mfaRecoveryCodes = []; usersChanged = true; }
|
||||
}
|
||||
if (usersChanged) await saveUsers();
|
||||
redirects = storage.loadCollection("redirects");
|
||||
@@ -393,7 +397,7 @@ function renderCaddyfile() {
|
||||
else if (defaultSite.mode === "redirect" && defaultSite.redirectUrl) lines.push(` redir ${caddyQuote(`${defaultSite.redirectUrl}${defaultSite.preservePath ? "{uri}" : ""}`)} ${[301, 302, 307, 308].includes(Number(defaultSite.redirectCode)) ? Number(defaultSite.redirectCode) : 302}`);
|
||||
else lines.push(` root * ${defaultSiteDir}`, " rewrite * /index.html", ` file_server {`, ` status ${defaultSite.mode === "welcome" ? 200 : 404}`, " }");
|
||||
lines.push("}");
|
||||
for (const site of sites.filter(item => item.enabled && item.domain)) {
|
||||
for (const site of sites.filter(item => item.enabled && normalizeDomains(item.domain, item.domains).length)) {
|
||||
lines.push("", `${caddySiteAddress(site)} {`, ...logging, ...commonHostDirectives(site), ` root * ${path.join(sitesDir, site.id)}`, " file_server");
|
||||
lines.push("}");
|
||||
}
|
||||
@@ -1045,11 +1049,34 @@ app.get("/api/session", (req, res) => {
|
||||
const user = sessionUser(req);
|
||||
res.json({ authenticated: Boolean(user), setupRequired: Boolean(user?.setupRequired), installationSetupPending: users.some(item => item.setupRequired), user: user ? publicUser(user) : null, username: user?.username || null });
|
||||
});
|
||||
function checkLoginRateLimit(key) {
|
||||
const attempt = loginAttempts.get(key) || { count: 0, resetAt: Date.now() + 15 * 60 * 1000 };
|
||||
if (attempt.resetAt <= Date.now()) { attempt.count = 0; attempt.resetAt = Date.now() + 15 * 60 * 1000; }
|
||||
return attempt;
|
||||
}
|
||||
function issueSessionCookie(res, user) {
|
||||
if (!user.sessionVersion) user.sessionVersion = crypto.randomBytes(16).toString("hex");
|
||||
const expires = String(Date.now() + 12 * 60 * 60 * 1000);
|
||||
const value = `${user.id}.${expires}.${user.sessionVersion}`;
|
||||
res.setHeader("Set-Cookie", [`webserver_session=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`, "pending_mfa=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"]);
|
||||
}
|
||||
function issuePendingMfaCookie(res, user) {
|
||||
const expires = String(Date.now() + 5 * 60 * 1000);
|
||||
const value = `${user.id}.${expires}.mfa`;
|
||||
res.setHeader("Set-Cookie", `pending_mfa=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=300`);
|
||||
}
|
||||
function pendingMfaUser(req) {
|
||||
const token = cookieMap(req.headers.cookie).pending_mfa;
|
||||
if (!token) return null;
|
||||
const [userId, expires, marker, signature] = token.split(".");
|
||||
const user = users.find(item => item.id === userId && item.status === "active");
|
||||
if (!user || marker !== "mfa" || !expires || Number(expires) <= Date.now() || !safeEqual(signature || "", sign(`${userId}.${expires}.${marker}`))) return null;
|
||||
return user;
|
||||
}
|
||||
app.post("/api/login", async (req, res, next) => {
|
||||
try {
|
||||
const key = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const attempt = loginAttempts.get(key) || { count: 0, resetAt: Date.now() + 15 * 60 * 1000 };
|
||||
if (attempt.resetAt <= Date.now()) { attempt.count = 0; attempt.resetAt = Date.now() + 15 * 60 * 1000; }
|
||||
const attempt = checkLoginRateLimit(key);
|
||||
if (attempt.count >= 8) { recordActivity(`Security: sign-in rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in attempts. Try again in 15 minutes." }); }
|
||||
const username = String(req.body.username || "").trim().toLowerCase();
|
||||
const user = users.find(item => item.username === username);
|
||||
@@ -1058,11 +1085,36 @@ app.post("/api/login", async (req, res, next) => {
|
||||
return res.status(401).json({ error: "Incorrect username or password." });
|
||||
}
|
||||
loginAttempts.delete(key);
|
||||
if (user.mfaEnabled) { issuePendingMfaCookie(res, user); return res.json({ mfaRequired: true }); }
|
||||
user.lastLoginAt = new Date().toISOString(); user.updatedAt = user.lastLoginAt; await saveUsers();
|
||||
if (!user.sessionVersion) user.sessionVersion = crypto.randomBytes(16).toString("hex");
|
||||
const expires = String(Date.now() + 12 * 60 * 60 * 1000);
|
||||
const value = `${user.id}.${expires}.${user.sessionVersion}`;
|
||||
res.setHeader("Set-Cookie", `webserver_session=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`);
|
||||
issueSessionCookie(res, user);
|
||||
res.json({ ok: true, user: publicUser(user) });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.post("/api/login/mfa", async (req, res, next) => {
|
||||
try {
|
||||
const key = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const attempt = checkLoginRateLimit(key);
|
||||
if (attempt.count >= 8) { recordActivity(`Security: sign-in rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in attempts. Try again in 15 minutes." }); }
|
||||
const user = pendingMfaUser(req);
|
||||
if (!user || !user.mfaEnabled) { attempt.count += 1; loginAttempts.set(key, attempt); return res.status(401).json({ error: "Your sign-in session expired. Please sign in again." }); }
|
||||
const code = String(req.body.code || "").trim();
|
||||
let matchedRecoveryCode = null;
|
||||
const isValidTotp = verifyTotp(user.mfaSecret, code);
|
||||
if (!isValidTotp) {
|
||||
for (const entry of user.mfaRecoveryCodes || []) {
|
||||
if (entry.usedAt) continue;
|
||||
if (await passwordMatches(code, entry.hash)) { matchedRecoveryCode = entry; break; }
|
||||
}
|
||||
}
|
||||
if (!isValidTotp && !matchedRecoveryCode) {
|
||||
attempt.count += 1; loginAttempts.set(key, attempt); recordActivity(`Security: failed two-factor code for “${user.username}”.`, "error");
|
||||
return res.status(401).json({ error: "That code didn't match. Try again." });
|
||||
}
|
||||
loginAttempts.delete(key);
|
||||
if (matchedRecoveryCode) { matchedRecoveryCode.usedAt = new Date().toISOString(); recordActivity(`User “${user.username}” signed in using a two-factor recovery code.`); }
|
||||
user.lastLoginAt = new Date().toISOString(); user.updatedAt = user.lastLoginAt; await saveUsers();
|
||||
issueSessionCookie(res, user);
|
||||
res.json({ ok: true, user: publicUser(user) });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
@@ -1124,7 +1176,65 @@ app.post("/api/setup/admin", async (req, res, next) => {
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.use("/api", (req, res, next) => { currentAuditActor = req.user?.id || null; return req.user.setupRequired ? res.status(428).json({ error: "Complete the initial administrator setup before continuing." }) : next(); });
|
||||
app.use("/api", (req, res, next) => { if (req.method === "GET" || req.user.role === "administrator") return next(); const operational = /^\/(sites|proxies|redirects|streams|access-lists)(\/|$)/.test(req.path); if (req.user.role === "standard" && operational) return next(); return res.status(403).json({ error: "Administrator access is required for this action." }); });
|
||||
app.use("/api", (req, res, next) => { if (req.path.startsWith("/account/")) return next(); if (req.method === "GET" || req.user.role === "administrator") return next(); const operational = /^\/(sites|proxies|redirects|streams|access-lists)(\/|$)/.test(req.path); if (req.user.role === "standard" && operational) return next(); return res.status(403).json({ error: "Administrator access is required for this action." }); });
|
||||
app.post("/api/account/password", async (req, res, next) => {
|
||||
try {
|
||||
const currentPassword = String(req.body.currentPassword || "");
|
||||
const newPassword = String(req.body.newPassword || "");
|
||||
if (!await passwordMatches(currentPassword, req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." });
|
||||
if (newPassword.length < 8) return res.status(400).json({ error: "New password must contain at least 8 characters." });
|
||||
req.user.password = await passwordRecord(newPassword);
|
||||
req.user.updatedAt = new Date().toISOString();
|
||||
await saveUsers(); recordActivity(`User “${req.user.username}” changed their password.`);
|
||||
issueSessionCookie(res, req.user);
|
||||
res.json({ ok: true });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.post("/api/account/mfa/setup", async (req, res, next) => {
|
||||
try {
|
||||
if (req.user.mfaEnabled) return res.status(409).json({ error: "Two-factor authentication is already enabled. Disable it first to start over." });
|
||||
const secret = generateTotpSecret();
|
||||
req.user.mfaPendingSecret = secret;
|
||||
await saveUsers();
|
||||
const uri = otpauthUri({ secret, username: req.user.username });
|
||||
const qrSvg = await QRCode.toString(uri, { type: "svg", margin: 1, width: 220 });
|
||||
res.json({ secret, otpauthUri: uri, qrSvg });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.post("/api/account/mfa/confirm", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.user.mfaPendingSecret) return res.status(400).json({ error: "Start two-factor setup before confirming a code." });
|
||||
if (!verifyTotp(req.user.mfaPendingSecret, req.body.code)) return res.status(400).json({ error: "That code didn't match. Try again." });
|
||||
req.user.mfaSecret = req.user.mfaPendingSecret;
|
||||
req.user.mfaPendingSecret = null;
|
||||
req.user.mfaEnabled = true;
|
||||
const codes = generateRecoveryCodes(10);
|
||||
req.user.mfaRecoveryCodes = await Promise.all(codes.map(async code => ({ hash: await passwordRecord(code), usedAt: null })));
|
||||
req.user.updatedAt = new Date().toISOString();
|
||||
await saveUsers(); recordActivity(`User “${req.user.username}” enabled two-factor authentication.`);
|
||||
res.json({ ok: true, recoveryCodes: codes });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.post("/api/account/mfa/disable", async (req, res, next) => {
|
||||
try {
|
||||
if (!await passwordMatches(req.body.password || "", req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." });
|
||||
req.user.mfaEnabled = false; req.user.mfaSecret = null; req.user.mfaPendingSecret = null; req.user.mfaRecoveryCodes = [];
|
||||
req.user.updatedAt = new Date().toISOString();
|
||||
await saveUsers(); recordActivity(`User “${req.user.username}” disabled two-factor authentication.`, "warning");
|
||||
res.json({ ok: true });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.post("/api/account/mfa/recovery-codes", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.user.mfaEnabled) return res.status(400).json({ error: "Two-factor authentication isn't enabled." });
|
||||
if (!await passwordMatches(req.body.password || "", req.user.password)) return res.status(400).json({ error: "Your current password is incorrect." });
|
||||
const codes = generateRecoveryCodes(10);
|
||||
req.user.mfaRecoveryCodes = await Promise.all(codes.map(async code => ({ hash: await passwordRecord(code), usedAt: null })));
|
||||
req.user.updatedAt = new Date().toISOString();
|
||||
await saveUsers(); recordActivity(`User “${req.user.username}” regenerated two-factor recovery codes.`);
|
||||
res.json({ ok: true, recoveryCodes: codes });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError } }));
|
||||
app.get("/api/users", (req, res) => req.user.role === "administrator" ? res.json(users.map(publicUser)) : res.status(403).json({ error: "Administrator access is required." }));
|
||||
app.get("/api/audit", (req, res) => req.user.role === "administrator" ? res.json(storage.listAudit({ user: req.query.user, action: req.query.action, status: req.query.status }).map(item => ({ ...item, actor: users.find(user => user.id === item.actor_id)?.username || "System" }))) : res.status(403).json({ error: "Administrator access is required." }));
|
||||
|
||||
+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