<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.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-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>';
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>';
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user
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.