diff --git a/README.md b/README.md index 4845c77..c6255df 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Docker Architectures Caddy - Version + Version

Why Site Gateway · diff --git a/ROADMAP.md b/ROADMAP.md index bed9bf8..dfe9132 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,23 @@ `v0.13.0` is a real batch under that same convention, not a targeted fix, even though none of it changes what the app *does*: the in-app light theme has been removed entirely (the app is dark-only now, including the two visitor-facing themed pages -- the default-site 404/welcome/custom-HTML page and the Access-List sign-in page, both previously following the visitor's OS light/dark preference and now fixed dark for consistency with the rest of the app), the full color/spacing/radius design-token system begun in v0.12.0 has been completed (zero hardcoded color literals remain anywhere outside the token definitions), and `styles.css` has been restructured into commented, page-aligned sections matching the convention already used in `app.js`/`features.js`/`server.js`. A handful of small pre-existing bugs (a duplicate CSS custom property, some dead/duplicate rules, a decorative background glow that rendered incorrectly at certain aspect ratios) were also found and fixed along the way. +`v0.14.0` adds config-drift detection, a backup-encryption readiness check, and an app-wide dialog cleanup, and scopes out (but does not yet ship) a larger set of previously-discussed features tracked below as follow-up work. + +- **Configuration drift detection** — a background check every 10 minutes compares Caddy's live running configuration (via its admin API `/config/` endpoint) against what Site Gateway's saved routes would currently generate (via `/adapt`). If they disagree \u2014 for example after a manual edit to the Caddyfile outside the app, or a Caddy restart that didn't pick up the latest reload \u2014 a "Configuration drift" item appears in the dashboard's Needs Attention list and a "Resync now" callout appears under Administration \u2192 Default site, both driven by a new `POST /api/gateway/resync` route that re-runs the normal Caddy sync and clears the flag. +- **Backup-encryption readiness** — the "Encrypt scheduled backups" checkbox no longer lets you configure something that will silently fail later. `/api/config` now reports whether the `BACKUP_PASSWORD` environment variable is actually set; the checkbox is disabled with an explanatory message when it isn't, and if it was previously saved as enabled and `BACKUP_PASSWORD` has since been removed, it shows a distinct warning instead of failing quietly at the next scheduled run. +- **Dialog cleanup** \u2014 every themed popout dialog's redundant "\u00d7" close button (in the dialog-heading row) has been removed app-wide; each dialog already has a working Cancel/Close button in its actions row, so this is pure de-duplication with no loss of function. New dialogs are expected to follow this pattern going forward. + +### Follow-up work carried from this release's planning + +A larger feature set was scoped for `v0.14.0` and intentionally deferred rather than shipped partially-verified. These remain on the roadmap for a future release: + +- **REST API with issuable tokens** \u2014 admin-issued bearer tokens (full or read-only scope) for scripting against the Site Gateway API outside the browser session, bound to the issuing user's session version so a password reset/deactivation revokes them automatically. +- **Backup/restore history** \u2014 a durable, database-backed history of every backup, restore, and deletion (including failed attempts), shown as a human-readable timeline that never displays raw backup filenames. +- **Docker container picker** \u2014 an opt-in integration (gated on the Docker socket being mounted and readable) that lets Proxy/Streaming targets be picked from the host's running containers instead of typed by hand, using each container's Docker DNS name. +- **"View Caddy config" popout** \u2014 a read-only, prettified view of the exact Caddy configuration block generated for a given site, proxy, or redirect, built from the same code path that generates the real deployed config so it can never drift from it. +- **Performance screen overhaul** \u2014 clock-aligned time-axis labels, a y-axis unit, hover tooltips with error counts, p95 latency, bandwidth and unique-visitor columns, a 4xx/5xx-colored error breakdown, top-paths-per-host, and a slowest-requests panel. +- **Dashboard tile color unification** \u2014 normalizing all "normal count" tiles to a shared green baseline that reacts to warning/danger states the same way the existing Needs Attention tile does. + ## Product direction Site Gateway stays simpler than a general-purpose proxy manager: one dashboard, clear health reporting, and guided setup instead of exposing raw server configuration. **Caddy** remains the managed gateway — Site Gateway stores a small route model and generates/validates Caddy configuration rather than reimplementing certificate and proxy behavior itself. diff --git a/package.json b/package.json index 34c69d0..afaa34d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "site-gateway", - "version": "0.13.0", + "version": "0.14.0", "private": true, "description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.", "type": "module", diff --git a/src/public/features.js b/src/public/features.js index 531d694..e7b4e14 100644 --- a/src/public/features.js +++ b/src/public/features.js @@ -260,9 +260,9 @@ document.querySelector("#access-list")?.addEventListener("change", async event = // --- Groups admin panel (dynamically inserted "Groups" tab) ---------------------- function renderGroups() { const tabs = document.querySelector(".admin-tabs"); const usersPanel = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !usersPanel) return; let tab = tabs.querySelector('[data-admin-tab="groups"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "groups"; tab.textContent = "Groups"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="groups"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "groups"; panel.className = "settings-panel hidden"; usersPanel.parentElement.insertBefore(panel, usersPanel.nextElementSibling); } panel.innerHTML = '

Groups

Organize users for Access List permissions.

' + (state.groups.length ? '
' + state.groups.map(group => '
GR

' + extendedEscape(group.name) + '

' + (group.members?.length || 0) + ' members

').join("") + '
' : '

No groups yet. Create one to organize users.

'); } -function openGroupEditor(group) { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Administration

Edit group

Select Site Gateway users who should belong to this group.

' + (state.users || []).filter(user => user.status !== "disabled").map(user => '').join("") + '

'; dialog.querySelector('[name="name"]').value = group.name; dialog.querySelectorAll('[name="members"]').forEach(input => { input.checked = (group.memberIds || group.members || []).includes(input.value) || (group.members || []).some(value => value === state.users?.find(user => user.id === input.value)?.username); }); dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups/" + group.id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:form.get("name"), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group updated."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); } +function openGroupEditor(group) { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Administration

Edit group

Select Site Gateway users who should belong to this group.

' + (state.users || []).filter(user => user.status !== "disabled").map(user => '').join("") + '

'; dialog.querySelector('[name="name"]').value = group.name; dialog.querySelectorAll('[name="members"]').forEach(input => { input.checked = (group.memberIds || group.members || []).includes(input.value) || (group.members || []).some(value => value === state.users?.find(user => user.id === input.value)?.username); }); dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups/" + group.id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:form.get("name"), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group updated."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); } document.addEventListener("click", event => { const button = event.target.closest('[data-admin-panel="groups"] .group-card .menu-button'); if (!button) return; const card = button.closest(".group-card"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll('[data-admin-panel="groups"] .group-card.menu-open').forEach(item => { item.classList.remove("menu-open"); item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); card.classList.toggle("menu-open", opening); button.setAttribute("aria-expanded", String(opening)); event.preventDefault(); event.stopImmediatePropagation(); }, true); -function openNewGroupEditor() { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Administration

Create group

Select Site Gateway users who should belong to this group.

' + (state.users || []).filter(user => user.status !== "disabled").map(user => '').join("") + '

'; dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:String(form.get("name") || "").trim(), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group created."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); } +function openNewGroupEditor() { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Administration

Create group

Select Site Gateway users who should belong to this group.

' + (state.users || []).filter(user => user.status !== "disabled").map(user => '').join("") + '

'; dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:String(form.get("name") || "").trim(), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group created."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); } document.addEventListener("click", async event => { if (event.target.id === "create-group") { openNewGroupEditor(); return; } const button = event.target.closest("[data-group-action]"); if (!button) return; const id = button.dataset.groupId; const group = state.groups.find(value => value.id === id); if (button.dataset.groupAction === "edit") { if (group) openGroupEditor(group); return; } if (button.dataset.groupAction === "icon") return; if (button.dataset.groupAction === "delete" && !confirm("Delete this group?")) return; const isToggle = button.dataset.groupAction === "toggle", wasOn = button.classList.contains("on"); if (isToggle && wasOn) { const assignedLists = (state.accessLists || []).filter(list => (list.groups || []).includes(id) && list.enabled !== false); if (assignedLists.length) { const names = assignedLists.map(list => extendedEscape(list.name)).join(", "); if (!(await themedAccessDialog("Disable group?", `Disabling “${extendedEscape(group?.name || "this group")}” will immediately stop its members from signing in through: ${names}. Continue?`, "Disable", true, "Groups"))) return; } } if (isToggle) { button.classList.toggle("on", !wasOn); button.disabled = true; } try { if (button.dataset.groupAction === "delete") await api("/api/groups/" + id, { method:"DELETE" }); else await api("/api/groups/" + id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled: isToggle ? !wasOn : button.textContent.trim() === "Enable" }) }); await refresh(); toast("Group updated."); } catch (error) { if (isToggle) { button.classList.toggle("on", wasOn); button.disabled = false; } toast(error.message); } }); function renderAccessGroupSelector(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary || !state.groups) return; let field = summary.querySelector(".access-group-selector"); if (!field) { field = document.createElement("section"); field.className = "access-group-selector"; summary.prepend(field); } const selected = state.accessLists.find(item => item.id === accessListId)?.groups || []; field.innerHTML = "Allowed groups Optional

Members of enabled groups can sign in with their Site Gateway credentials.

" + (state.groups.length ? "
" + state.groups.map(group => "").join("") + "
" : "

No groups have been created yet.

"); } document.addEventListener("change", async event => { const option = event.target.closest("[data-group-option]"); if (!option) return; const accessListId = document.querySelector("#access-form")?.dataset.editing; if (!accessListId) return; const groups = [...document.querySelectorAll("#access-assignment-summary [data-group-option]:checked")].map(input => input.dataset.groupOption); try { await api("/api/access-lists/" + accessListId + "/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ groups }) }); const item = state.accessLists.find(value => value.id === accessListId); if (item) item.groups = groups; renderAccessLists(); decorateAccessGroups(); toast("Access List groups saved."); } catch (error) { option.checked = !option.checked; toast(error.message); } }, true); @@ -284,11 +284,20 @@ if (backupPasswordInput) { backupPasswordInput.placeholder = "Optional — enter a password"; backupPasswordInput.closest(".backup-password-field")?.querySelector(".optional")?.remove(); } -const encryptionToggle = document.querySelector('#backup-settings-form .encryption-toggle'); -if (encryptionToggle) { +function renderEncryptionToggle() { + const encryptionToggle = document.querySelector('#backup-settings-form .encryption-toggle'); + if (!encryptionToggle) return; + const available = Boolean(state.config && state.config.backup && state.config.backup.encryptionAvailable); + const savedEncrypt = Boolean(state.settings && state.settings.backups && state.settings.backups.encrypt); encryptionToggle.className = "encryption-toggle"; - encryptionToggle.innerHTML = 'Encrypt scheduled backupsUses the container’s BACKUP_PASSWORD value. Enable only after configuring that value.'; + let message = "Uses the container\u2019s BACKUP_PASSWORD value. Enable only after configuring that value."; + if (!available && savedEncrypt) message = "This is enabled but BACKUP_PASSWORD is no longer configured \u2014 encrypted scheduled backups will fail until it\u2019s set again."; + else if (!available) message = "BACKUP_PASSWORD not configured \u2014 set it in the container\u2019s environment to enable encrypted scheduled backups."; + encryptionToggle.innerHTML = 'Encrypt scheduled backups' + message + ''; } +renderEncryptionToggle(); +setInterval(renderEncryptionToggle, 1000); + if (backupPasswordInput && !document.querySelector("#backup-password-toggle")) { backupPasswordInput.insertAdjacentHTML("afterend", ''); const toggle = document.querySelector("#backup-password-toggle"); @@ -296,7 +305,7 @@ if (backupPasswordInput && !document.querySelector("#backup-password-toggle")) { } // Manual "Create backup" button. -document.querySelector("#create-backup")?.addEventListener("click", async event => { event.preventDefault(); event.stopImmediatePropagation(); let dialog = document.querySelector("#create-backup-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "create-backup-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Backup & restore

Create a backup

Choose what to include. Site Gateway saves a copy in /data/backups and downloads a copy to your computer.

Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups do not include uploaded Hosted Site files.If provided, this password is required to restore the downloaded archive.
'; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), {once:true})); if (result !== "confirm") return; const form = dialog.querySelector("form"), type = form.elements.type.value, password = form.elements.password.value; try { await api("/api/backups", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked === true,password})}); state.backups = await api("/api/backups"); renderBackups(); toast("Backup created. Use Download in the list below to save it."); } catch (error) { toast(error.message); } }, true); +document.querySelector("#create-backup")?.addEventListener("click", async event => { event.preventDefault(); event.stopImmediatePropagation(); let dialog = document.querySelector("#create-backup-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "create-backup-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Backup & restore

Create a backup

Choose what to include. Site Gateway saves a copy in /data/backups and downloads a copy to your computer.

Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups do not include uploaded Hosted Site files.If provided, this password is required to restore the downloaded archive.
'; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), {once:true})); if (result !== "confirm") return; const form = dialog.querySelector("form"), type = form.elements.type.value, password = form.elements.password.value; try { await api("/api/backups", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked === true,password})}); state.backups = await api("/api/backups"); renderBackups(); toast("Backup created. Use Download in the list below to save it."); } catch (error) { toast(error.message); } }, true); // --- Logs & Retention admin panel (Administration > Logs & Retention) ------------ @@ -316,3 +325,23 @@ setInterval(renderRetentionRunStatus, 500); setInterval(renderRetentionPreview, document.addEventListener("click", async event => { const button = event.target.closest('[data-retention-action="prune"]'); if (!button) return; try { const response = await api("/api/logs/prune", { method: "POST" }); const total = Object.values(response.counts || {}).reduce((sum, value) => sum + value, 0); toast(`Pruning completed. ${total} record${total === 1 ? "" : "s"} removed.`); } catch (error) { toast(error.message); } }); document.addEventListener("click", event => { const button = event.target.closest('[data-retention-action="download"]'); if (!button) return; window.location.href = "/api/logs/download"; }); document.addEventListener("click", async event => { const button = event.target.closest('[data-retention-action="prune"]'); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); try { const preview = await api("/api/logs/prune/preview"); const counts = preview.counts || {}; const total = Object.values(counts).reduce((sum, value) => sum + value, 0); let dialog = document.querySelector("#retention-prune-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "retention-prune-dialog"; document.body.append(dialog); } dialog.innerHTML = `

Log Retention

Confirm pruning

This will remove records older than your saved retention periods.

Access: ${counts.access || 0} · Activity: ${counts.activity || 0} · Certificates: ${counts.certificate || 0} · Security: ${counts.security || 0} · Audit: ${counts.audit || 0}

`; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once: true })); if (result !== "confirm") return; const response = await api("/api/logs/prune", { method: "POST" }); toast(`Pruning completed. ${Object.values(response.counts || {}).reduce((sum, value) => sum + value, 0)} record${total === 1 ? "" : "s"} removed.`); } catch (error) { toast(error.message); } }, true); + +// --- Config drift: show a resync callout in Administration > Default site when drift is detected -------- +function renderConfigDriftCallout() { + const callout = document.querySelector("#config-drift-callout"); + if (!callout) return; + const drift = (state.dashboard?.attention || []).some(item => item.kind === "drift"); + callout.hidden = !drift; +} +setInterval(renderConfigDriftCallout, 1000); +document.addEventListener("click", async event => { + const button = event.target.closest("#config-resync"); + if (!button) return; + button.disabled = true; + try { + await api("/api/gateway/resync", { method: "POST" }); + toast("Gateway configuration re-synced."); + await refresh(); + renderConfigDriftCallout(); + } catch (error) { toast(error.message); } finally { button.disabled = false; } +}); diff --git a/src/public/index.html b/src/public/index.html index 06ee24d..ab190c4 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -7,7 +7,7 @@ Site Gateway - +