<a href="#why-site-gateway">Why Site Gateway</a> ·
@@ -44,9 +44,10 @@ It's intentionally narrower than a general-purpose proxy manager. You describe *
- **Access Lists** — reusable login/network policies combining accounts, groups, and IP/CIDR rules across any host.
- **Two-factor authentication** — TOTP-based MFA for administrator and user accounts, with recovery codes, plus an administrator-side override to disable a locked-out user's 2FA when they've lost their authenticator and used up their recovery codes.
- **Users, groups, and roles** — Administrator and Standard User roles, with account lifecycle controls.
- **API access tokens** — issue scoped (full-access or read-only), optionally expiring bearer tokens for scripts and integrations, revocable at any time.
- **Backups** — configuration or complete `.sgbackup` archives, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted.
- **Certificates page** — issuer, expiration, days remaining, and renewal health for every managed and uploaded certificate.
- **Performance and logs** — request throughput, response times, and rotating access/activity logs per host.
- **Performance and logs** — per-domain request throughput, response times, and rotating access/activity logs, including a System page with environment/integration status, gateway sync, scheduled jobs, and storage usage.
- **SQLite-backed persistence** — no external database container; everything lives under one `/data` volume.
Hosted uploads remain static-only (HTML, CSS, JS, images, fonts, downloads). Dynamic applications are connected as Proxy Hosts instead — Site Gateway does not execute uploaded PHP, Node, Python, or database code.
`v0.16.16` ships a batch of fixes found in live use: the Backup type picker (in both the scheduled-backup form and the manual "Create a backup" dialog) no longer shows a long wrapped sentence as the selected value -- it now shows a short "Complete (Recommended)" / "Configuration only" label with the detail moved into the helper text beneath it, and the in-app documentation now explicitly names the "Backup type" field so it's easy to find by search. The Performance page's "Outliers / Slowest requests" section has been removed, along with the per-row error-count badge in the "Throughput by domain" table -- both added noise without being worth the space for most setups. That table's column headers now stay pinned while scrolling instead of scrolling out of view. Rows for domains with no matching Hosted Site, Proxy Host, or Redirect Host are now badged "Not configured" -- that table is built from Caddy's raw access log, so it always included every hostname a request was ever seen for (including scanner/bot traffic hitting made-up subdomains that fall through to the Default Site handler), not just domains you've actually configured; the badge makes that distinction visible instead of leaving it to guesswork. Finally, the Administration Users tab no longer flashes "No users found." for a moment before the user list has actually loaded.
`v0.16.17` fixes the Backup type helper text showing both the Complete and Configuration-only explanations stacked on top of each other on page load or refresh, instead of just the one matching the currently selected option. Root cause: the help text only ever updated on the select's `change` event -- but `renderBackups()` sets the select's value from saved settings on every render without firing a `change` event, so the static placeholder text (which briefly held both sentences as a v0.16.16 authoring mistake) never got replaced until you manually touched the dropdown. Factored the text-selection logic into its own function and call it both on `change` and every time `renderBackups()` runs, so it always matches the select's actual current value.
`v0.16.18` reworks the API Access tab to match the Users and Groups tabs' layout instead of the old plain data-row list: tokens are now shown as tiles in the same card grid Hosted Sites/Users/Groups use, and a stat bar above them breaks down Active/Revoked and Full access/Read-only counts at a glance. No behavior changed -- Revoke still works the same way it always has (a one-way action; there is no re-enable, since a revoked token's secret is treated as compromised). An earlier idea of adding an enable/disable toggle was dropped once it became clear that would require adding real token-reactivation support on the backend, a deliberate security-posture change rather than a layout fix.
`v0.16.19` finishes the API Access tab's alignment with Users and Groups: the "Create token" button now lives in the shared top-right header button used by every other create action instead of its own row inside the panel, and the panel-heading text ("Programmatic access / API access tokens / Issue bearer tokens...") has been removed the same way it was for Groups in v0.16.15, since the tab button's own label already says what the section is -- the stat bar is now the first thing in the panel. Also walked the in-app Documentation view and brought it current with everything shipped since it was last substantively updated: added a full API Access section (creating a token, scope, expiry, the one-time reveal, revoking, and automatic revocation when an issuing administrator's password changes or account is disabled), corrected the Performance section's per-route table description to drop the removed per-row error-count badge and instead document the pinned column headers and the "Not configured" chip added in v0.16.16, and added an API Access entry to the documentation sidebar's contents list.
`v0.16.20` audits role enforcement across the app after a run of Administration changes and fixes three places where the frontend showed a control the backend would actually reject for Standard Users and Viewers: the Dashboard's "Resync now" button (Needs Attention drift tile) and the Certificates page's "Run certificate check" button are now hidden for anyone who isn't an administrator, since both call administrator-only endpoints. The Access List editor's "Allowed groups" section -- previously always rendered with an empty `state.groups`, so a Standard User just saw a false "No groups have been created yet." -- now shows an accurate note pointing to an administrator instead, both when creating a new Access List and editing an existing one. Also corrected the in-app documentation: the Users & Groups role summary previously said Viewer "can inspect everything," which wasn't true -- Administration (System, Users, Groups, Backups, API Access, Logs & Retention, Danger Zone) is completely invisible to Viewer, the same as Standard, not merely read-only. The role summary, the Access Lists doc's Groups field, the Certificates doc's Check now section, and the Dashboard doc's Resync now section all now say plainly which actions are administrator-only.
$("#page-subtitle").textContent="Health, activity, and system status at a glance.";
@@ -512,7 +512,7 @@ function render() {
if(!management){
constheadings={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, 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."]};
$("#open-create").textContent=state.view==="administration"?(adminGroupsActive?"+ Create group":"+ 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"?(adminGroupsActive?"+ Create group":adminApiActive?"+ Create token":"+ Create user"):state.view==="streaming"?"+ New streaming host":state.view==="redirects"?"+ New redirect host":state.view==="access"?"+ New Access List":$("#open-create").textContent;
functionopenNewGroupEditor(){letdialog=document.querySelector("#group-dialog");if(!dialog){dialog=document.createElement("dialog");dialog.id="group-dialog";document.body.append(dialog);}dialog.innerHTML='<form class="dialog-card group-editor"><div class="dialog-heading"><div><p class="eyebrow">Administration</p><h2>Create group</h2></div></div><label>Group name<input name="name" required maxlength="80" placeholder="Home users"></label><label>Members <span class="optional">Optional</span></label><p class="muted">Select Site Gateway users who should belong to this group.</p><div class="group-member-options">'+(state.users||[]).filter(user=>user.status!=="disabled").map(user=>'<label class="check-control"><input type="checkbox" name="members" value="'+user.id+'"><span>'+extendedEscape(user.username)+' <small>'+extendedEscape(user.role||"Standard User")+'</small></span></label>').join("")+'</div><p class="error" data-group-error></p><div class="dialog-actions"><button type="button" class="button secondary close-group-dialog">Cancel</button><button class="button primary">Create group</button></div></form>';dialog.querySelectorAll(".close-group-dialog").forEach(button=>button.addEventListener("click",()=>dialog.close()));dialog.querySelector("form").addEventListener("submit",asyncevent=>{event.preventDefault();constform=newFormData(event.target);try{awaitapi("/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();awaitrefresh();toast("Group created.");}catch(error){dialog.querySelector("[data-group-error]").textContent=error.message;}});dialog.showModal();}
document.addEventListener("click",asyncevent=>{constbutton=event.target.closest("[data-group-action]");if(!button)return;constid=button.dataset.groupId;constgroup=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;constisToggle=button.dataset.groupAction==="toggle",wasOn=button.classList.contains("on");if(isToggle&&wasOn){constassignedLists=(state.accessLists||[]).filter(list=>(list.groups||[]).includes(id)&&list.enabled!==false);if(assignedLists.length){constnames=assignedLists.map(list=>extendedEscape(list.name)).join(", ");if(!(awaitthemedAccessDialog("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")awaitapi("/api/groups/"+id,{method:"DELETE"});elseawaitapi("/api/groups/"+id,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:isToggle?!wasOn:button.textContent.trim()==="Enable"})});awaitrefresh();toast("Group updated.");}catch(error){if(isToggle){button.classList.toggle("on",wasOn);button.disabled=false;}toast(error.message);}});
functionrenderAccessGroupSelector(accessListId){constsummary=document.querySelector("#access-assignment-summary");if(!summary||!state.groups)return;letfield=summary.querySelector(".access-group-selector");if(!field){field=document.createElement("section");field.className="access-group-selector";summary.prepend(field);}constselected=state.accessLists.find(item=>item.id===accessListId)?.groups||[];field.innerHTML="<strong>Allowed groups <span class=\"optional\">Optional</span></strong><p class=\"access-group-help\">Members of enabled groups can sign in with their Site Gateway credentials.</p>"+(state.groups.length?"<div class=\"access-group-options\">"+state.groups.map(group=>"<label class=\"check-control access-group-option\"><input type=\"checkbox\" data-group-option=\""+group.id+"\""+(selected.includes(group.id)?" checked":"")+"><span>"+extendedEscape(group.name)+" <small>"+(group.members?.length||0)+" members"+(group.enabled===false?" · Disabled":"")+"</small></span></label>").join("")+"</div>":"<p class=\"access-group-empty\">No groups have been created yet.</p>");}
functionrenderAccessGroupSelector(accessListId){constsummary=document.querySelector("#access-assignment-summary");if(!summary)return;letfield=summary.querySelector(".access-group-selector");if(!field){field=document.createElement("section");field.className="access-group-selector";summary.prepend(field);}if(state.user?.role!=="administrator"){field.innerHTML='<strong>Allowed groups</strong><p class="access-group-help">Group-based access is managed by an administrator, under Administration \u2192 Groups.</p>';return;}if(!state.groups)return;constselected=state.accessLists.find(item=>item.id===accessListId)?.groups||[];field.innerHTML="<strong>Allowed groups <span class=\"optional\">Optional</span></strong><p class=\"access-group-help\">Members of enabled groups can sign in with their Site Gateway credentials.</p>"+(state.groups.length?"<div class=\"access-group-options\">"+state.groups.map(group=>"<label class=\"check-control access-group-option\"><input type=\"checkbox\" data-group-option=\""+group.id+"\""+(selected.includes(group.id)?" checked":"")+"><span>"+extendedEscape(group.name)+" <small>"+(group.members?.length||0)+" members"+(group.enabled===false?" · Disabled":"")+"</small></span></label>").join("")+"</div>":"<p class=\"access-group-empty\">No groups have been created yet.</p>");}
document.addEventListener("change",asyncevent=>{constoption=event.target.closest("[data-group-option]");if(!option)return;constaccessListId=document.querySelector("#access-form")?.dataset.editing;if(!accessListId)return;constgroups=[...document.querySelectorAll("#access-assignment-summary [data-group-option]:checked")].map(input=>input.dataset.groupOption);try{awaitapi("/api/access-lists/"+accessListId+"/groups",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({groups})});constitem=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);
functionrenderNewAccessGuidance(){constform=document.querySelector("#access-form");if(!form||form.dataset.editing||form.querySelector(".access-create-guidance"))return;constassignmentSummary=document.querySelector("#access-assignment-summary");if(assignmentSummary){assignmentSummary.classList.add("hidden");assignmentSummary.innerHTML="";}constguidance=document.createElement("p");guidance.className="access-create-guidance";guidance.textContent="After saving, edit this Access List to assign protected hosts. Allowed groups can be selected now or changed later.";document.querySelector("#access-credential-editor")?.after(guidance);constgroupField=document.createElement("section");groupField.id="access-create-groups";groupField.className="access-create-groups";groupField.innerHTML=`<strong>Allowed groups <span class="optional">Optional</span></strong><p class="access-group-help">Members of enabled groups can sign in with their Site Gateway credentials.</p>${state.groups?.length?`<div class="access-group-options">${state.groups.filter(group=>group.enabled!==false).map(group=>`<label class="check-control access-group-option"><input type="checkbox" data-create-group="${extendedEscape(group.id)}"><span>${extendedEscape(group.name)} <small>${group.members?.length||0} members</small></span></label>`).join("")}</div>`:'<p class="access-group-empty">No groups have been created yet. Create one under Administration → Groups.</p>'}`;guidance.after(groupField);}
functionrenderNewAccessGuidance(){constform=document.querySelector("#access-form");if(!form||form.dataset.editing||form.querySelector(".access-create-guidance"))return;constisAdmin=state.user?.role==="administrator";constassignmentSummary=document.querySelector("#access-assignment-summary");if(assignmentSummary){assignmentSummary.classList.add("hidden");assignmentSummary.innerHTML="";}constguidance=document.createElement("p");guidance.className="access-create-guidance";guidance.textContent=isAdmin?"After saving, edit this Access List to assign protected hosts. Allowed groups can be selected now or changed later.":"After saving, edit this Access List to assign protected hosts.";document.querySelector("#access-credential-editor")?.after(guidance);constgroupField=document.createElement("section");groupField.id="access-create-groups";groupField.className="access-create-groups";groupField.innerHTML=!isAdmin?'<strong>Allowed groups</strong><p class="access-group-help">Group-based access is managed by an administrator, under Administration → Groups.</p>':`<strong>Allowed groups <span class="optional">Optional</span></strong><p class="access-group-help">Members of enabled groups can sign in with their Site Gateway credentials.</p>${state.groups?.length?`<div class="access-group-options">${state.groups.filter(group=>group.enabled!==false).map(group=>`<label class="check-control access-group-option"><input type="checkbox" data-create-group="${extendedEscape(group.id)}"><span>${extendedEscape(group.name)} <small>${group.members?.length||0} members</small></span></label>`).join("")}</div>`:'<p class="access-group-empty">No groups have been created yet. Create one under Administration → Groups.</p>'}`;guidance.after(groupField);}
panel.innerHTML='<div class="panel-heading"><div><p class="eyebrow">Programmatic access</p><h2>API access tokens</h2><p class="muted">Issue bearer tokens for scripts and integrations. A token acts as the administrator who issued it, and is shown in full only once. Changing that administrator’s password, or disabling their account, revokes every token they issued.</p></div><div class="row-actions"><button id="create-api-token" class="button primary" type="button">Create token</button></div></div><div id="api-token-list" class="data-list"><p class="quiet-state padded">Open this tab to load API tokens.</p></div>';
panel.innerHTML='<div id="api-token-summary" class="summary"></div><div id="api-token-list" class="user-grid"><p class="quiet-state padded">Open this tab to load API tokens.</p></div>';
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.