Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46360a2453 | |||
| a7201fbb8c | |||
| 44777072d5 | |||
| 575c1816c3 | |||
| 9a4f91d40d | |||
| dbc8f3490b |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "site-gateway",
|
||||
"version": "0.11.34",
|
||||
"version": "0.11.40",
|
||||
"private": true,
|
||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||
"type": "module",
|
||||
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# apply-patch.sh — picks up the newest Claude-generated .patch file from
|
||||
# ~/Downloads/Claude outputs, copies it into this repo, applies it, and
|
||||
# cleans up both copies. Run from anywhere; it finds the repo root itself.
|
||||
#
|
||||
# Usage: ./scripts/apply-patch.sh
|
||||
set -euo pipefail
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SOURCE_DIR="$HOME/Downloads/Claude outputs"
|
||||
|
||||
if [ ! -d "$SOURCE_DIR" ]; then
|
||||
echo "Can't find $SOURCE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PATCH=$(ls -t "$SOURCE_DIR"/*.patch 2>/dev/null | head -n1 || true)
|
||||
if [ -z "$PATCH" ]; then
|
||||
echo "No .patch file found in $SOURCE_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NAME=$(basename "$PATCH")
|
||||
DEST="$REPO_ROOT/$NAME"
|
||||
echo "Found patch: $NAME"
|
||||
cp "$PATCH" "$DEST"
|
||||
|
||||
echo "Checking that it applies cleanly..."
|
||||
if ! git apply --check "$DEST" 2>/tmp/apply-patch-check.log; then
|
||||
echo
|
||||
echo "Patch does NOT apply cleanly against the current branch. Nothing was changed."
|
||||
echo "Details:"
|
||||
cat /tmp/apply-patch-check.log
|
||||
echo
|
||||
echo "The copied patch is left at: $DEST"
|
||||
echo "The original is untouched at: $PATCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Applying..."
|
||||
git apply "$DEST"
|
||||
|
||||
echo "Cleaning up..."
|
||||
rm -f "$DEST"
|
||||
rm -f "$PATCH"
|
||||
|
||||
echo
|
||||
echo "Applied and cleaned up ($NAME removed from both this repo and Claude outputs)."
|
||||
echo "Changed files:"
|
||||
git status --short
|
||||
echo
|
||||
echo "Review with: git diff --stat"
|
||||
echo "Then commit and push yourself when ready."
|
||||
+23
-24
@@ -1,8 +1,5 @@
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const summaryBar = document.querySelector("#management-summary");
|
||||
const redirectView = document.querySelector("#redirects-view");
|
||||
if (summaryBar && redirectView) redirectView.parentElement.insertBefore(summaryBar, redirectView);
|
||||
const state = { sites: [], proxies: [], redirects: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null };
|
||||
const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null };
|
||||
document.querySelector("#create-form [name=domain]")?.closest("label")?.childNodes[0] && (document.querySelector("#create-form [name=domain]").closest("label").childNodes[0].textContent = "Primary domain ");
|
||||
if (!document.querySelector("#create-form [name=accessListId]")) { const anchor = document.querySelector("#create-form [name=tls]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this hosted site and all of its domains.</small>'; anchor.before(label); } }
|
||||
if (!document.querySelector("#settings-access-list")) { const anchor = document.querySelector("#settings-form [name=domain]")?.closest("label"); if (anchor) { const label = document.createElement("label"); label.innerHTML = '<span>Access List <span class="optional">Optional</span></span><select id="settings-access-list" name="accessListId"><option value="">Public — no Access List</option></select><small>Protect this route and all of its domains.</small>'; anchor.after(label); } }
|
||||
@@ -246,17 +243,17 @@ async function loadFeatureView() {
|
||||
function render() {
|
||||
const viewHash = state.view === "administration" ? `administration/${state.adminTab || "users"}` : state.view;
|
||||
if (location.hash !== `#${viewHash}`) history.replaceState(null, "", `${location.pathname}${location.search}#${viewHash}`);
|
||||
$("#hosted-count").textContent = state.sites.length; $("#proxy-count").textContent = state.proxies.length; $("#streaming-count").textContent = "0"; $("#redirect-count").textContent = state.redirects.length; $("#access-count").textContent = state.accessLists.length; $("#certificate-count").textContent = state.certificates?.summary.total || 0;
|
||||
$("#hosted-count").textContent = state.sites.length; $("#proxy-count").textContent = state.proxies.length; $("#streaming-count").textContent = state.streams.length; $("#redirect-count").textContent = state.redirects.length; $("#access-count").textContent = state.accessLists.length; $("#certificate-count").textContent = state.certificates?.summary.total || 0;
|
||||
document.querySelectorAll("nav [data-view], .aside-utilities [data-view]").forEach(button => button.classList.toggle("nav-active", button.dataset.view === state.view));
|
||||
const overview = state.view === "overview";
|
||||
$("#dashboard-view").classList.toggle("hidden", !overview);
|
||||
const management = state.view === "hosted" || state.view === "proxies" || state.view === "streaming";
|
||||
$("#management-view").classList.toggle("hidden", !management); $("#management-summary").classList.toggle("hidden", !(management || state.view === "redirects" || state.view === "access"));
|
||||
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"); $("#users-view").classList.toggle("hidden", state.view !== "administration");
|
||||
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)); }
|
||||
$("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
|
||||
$("#streaming-view").classList.toggle("hidden", state.view !== "streaming"); $("#redirects-view").classList.toggle("hidden", state.view !== "redirects"); $("#access-view").classList.toggle("hidden", state.view !== "access"); $("#documentation-view").classList.toggle("hidden", state.view !== "documentation");
|
||||
const adminUsersActive = state.view === "administration" && document.querySelector("[data-admin-tab].tab-active")?.dataset.adminTab === "users";
|
||||
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || ["redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
|
||||
$("#open-create").classList.toggle("hidden", !(management || adminUsersActive || ["streaming","redirects","access"].includes(state.view)) || !canManage()); $("#check-health").classList.toggle("hidden", state.view !== "certificates"); $("#refresh-logs").classList.toggle("hidden", state.view !== "logs");
|
||||
if (overview) {
|
||||
$("#page-title").textContent = "Dashboard";
|
||||
$("#page-subtitle").textContent = "Health, activity, and system status at a glance.";
|
||||
@@ -264,33 +261,35 @@ 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."], administration:["Administration","Users, gateway defaults, backups, security, and updates."], 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."], 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 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 === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
|
||||
$("#open-create").textContent = state.view === "administration" ? "+ Create user" : state.view === "streaming" ? "+ New streaming host" : state.view === "redirects" ? "+ New redirect host" : state.view === "access" ? "+ New Access List" : $("#open-create").textContent;
|
||||
if (state.view === "streaming") $("#stream-empty").classList.toggle("hidden", !state.loaded || state.streams.length > 0);
|
||||
if (state.view === "streaming") { const items = state.streams; const running = items.filter(item => item.status === "running").length, disabled = items.filter(item => item.status === "disabled").length, errors = items.filter(item => item.status === "error").length; $("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = errors; $("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues"; $("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`; $(".port-note").classList.add("hidden"); }
|
||||
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();
|
||||
return;
|
||||
}
|
||||
const items = state.view === "hosted" ? state.sites : state.view === "proxies" ? state.proxies : [];
|
||||
const items = state.view === "hosted" ? state.sites : state.proxies;
|
||||
$("#site-grid").innerHTML = items.map(state.view === "hosted" ? hostedCard : proxyCard).join("");
|
||||
$("#empty").classList.toggle("hidden", !state.loaded || items.length > 0);
|
||||
$("#empty h2").textContent = state.view === "hosted" ? "Publish your first site" : state.view === "proxies" ? "Create your first proxy host" : "Create your first streaming host";
|
||||
$("#empty p").textContent = state.view === "hosted" ? "Upload a ZIP and optionally connect a domain with automatic HTTPS." : state.view === "proxies" ? "Connect a domain to another container, application, or LAN service." : "Streaming host management is coming soon.";
|
||||
$("#page-title").textContent = state.view === "hosted" ? "Hosted sites" : state.view === "proxies" ? "Proxy hosts" : "Streaming hosts";
|
||||
$("#page-subtitle").textContent = state.view === "hosted" ? "Upload and publish websites on a port or domain." : state.view === "proxies" ? "Route domains securely to applications and containers." : "Prepare and monitor streaming services from one place.";
|
||||
$("#empty h2").textContent = state.view === "hosted" ? "Publish your first site" : "Create your first proxy host";
|
||||
$("#empty p").textContent = state.view === "hosted" ? "Upload a ZIP and optionally connect a domain with automatic HTTPS." : "Connect a domain to another container, application, or LAN service.";
|
||||
$("#page-title").textContent = state.view === "hosted" ? "Hosted sites" : "Proxy hosts";
|
||||
$("#page-subtitle").textContent = state.view === "hosted" ? "Upload and publish websites on a port or domain." : "Route domains securely to applications and containers.";
|
||||
$("#open-create").textContent = state.view === "hosted" ? "+ New hosted site" : "+ New proxy host";
|
||||
$("#open-create").classList.toggle("hidden", state.view === "streaming" || !canManage());
|
||||
$("#empty .create-trigger").textContent = state.view === "hosted" ? "Create a hosted site" : state.view === "proxies" ? "Create a proxy host" : "Streaming hosts coming soon";
|
||||
$("#empty .create-trigger").disabled = state.view === "streaming";
|
||||
$("#open-create").classList.toggle("hidden", !canManage());
|
||||
$("#empty .create-trigger").textContent = state.view === "hosted" ? "Create a hosted site" : "Create a proxy host";
|
||||
$("#empty .create-trigger").disabled = false;
|
||||
$(".port-note").classList.toggle("hidden", state.view === "proxies");
|
||||
const running = items.filter(item => item.status === "running").length, disabled = items.filter(item => item.status === "disabled").length, errors = items.filter(item => item.status === "error").length;
|
||||
$("#running-count").textContent = running; $("#disabled-count").textContent = disabled; $("#error-count").textContent = errors;
|
||||
$("#running-label").textContent = running ? "Running" : "None running"; $("#disabled-label").textContent = disabled ? "Disabled" : "None disabled"; $("#error-label").textContent = errors ? "Needs attention" : "No issues";
|
||||
$("#running-dot").className = `status-dot ${running ? "running" : "inactive"}`; $("#disabled-dot").className = `status-dot ${disabled ? "disabled" : "inactive"}`; $("#error-dot").className = `status-dot ${errors ? "error" : "inactive"}`;
|
||||
}
|
||||
async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), api("/api/access-lists"), canAdmin() ? api("/api/groups") : Promise.resolve([]), api("/api/dashboard"), api("/api/certificates")]; const results = await Promise.allSettled(requests); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const keys = ["sites", "proxies", "redirects", "accessLists", "groups", "dashboard", "certificates"]; state[keys[index]] = result.value; }); state.loaded = true; render(); window.renderExtendedViews?.(); const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id); if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); } }
|
||||
async function refresh() { const requests = [api("/api/sites"), api("/api/proxies"), api("/api/redirects"), api("/api/streams"), api("/api/access-lists"), canAdmin() ? api("/api/groups") : Promise.resolve([]), api("/api/dashboard"), api("/api/certificates")]; const results = await Promise.allSettled(requests); results.forEach((result, index) => { if (result.status !== "fulfilled") return; const keys = ["sites", "proxies", "redirects", "streams", "accessLists", "groups", "dashboard", "certificates"]; state[keys[index]] = result.value; }); state.loaded = true; render(); window.renderExtendedViews?.(); const pending = state.proxies.filter(proxy => proxy.enabled !== false && !proxy.upstream).map(proxy => proxy.id); if (pending.length && !state.pendingProxyRefresh) { state.pendingProxyRefresh = true; refreshPendingProxies(pending).finally(() => { state.pendingProxyRefresh = false; }); } }
|
||||
async function refreshPendingProxies(ids = []) {
|
||||
const pending = new Set(ids.map(String));
|
||||
for (const delay of [1000, 2000, 3000]) {
|
||||
@@ -336,8 +335,8 @@ $("#log-status").addEventListener("change", renderLogs);
|
||||
$("#event-severity").addEventListener("change", renderLogs);
|
||||
$("#event-category").addEventListener("change", renderLogs);
|
||||
function openCreate() {
|
||||
if (state.view === "streaming") return toast("Streaming host management is coming soon.");
|
||||
if (state.view === "administration") { $("#user-form").reset(); $("#user-error").textContent = ""; return $("#user-dialog").showModal(); }
|
||||
if (state.view === "streaming") { $("#stream-form").reset(); delete $("#stream-form").dataset.editing; $("#stream-title").textContent = "Create a streaming host"; $("#stream-form .button.primary").textContent = "Create streaming host"; $("#stream-error").textContent = ""; return $("#stream-dialog").showModal(); }
|
||||
if (state.view === "redirects") { $("#redirect-form").reset(); delete $("#redirect-form").dataset.editing; $("#redirect-error").textContent = ""; return $("#redirect-dialog").showModal(); }
|
||||
if (state.view === "access") { $("#access-form").reset(); delete $("#access-form").dataset.editing; $("#access-error").textContent = ""; $("#access-form .access-create-guidance")?.remove(); const assignmentSummary = $("#access-assignment-summary"); assignmentSummary?.classList.add("hidden"); if (assignmentSummary) assignmentSummary.innerHTML = ""; window.renderCredentialEditor?.([]); return $("#access-dialog").showModal(); }
|
||||
if (state.view === "proxies") { $("#proxy-form").reset(); $("#custom-certificate-fields").classList.remove("custom-certificate-visible"); $("#proxy-error").textContent = ""; return $("#proxy-dialog").showModal(); }
|
||||
@@ -408,7 +407,7 @@ $("#icon-search").addEventListener("input", event => {
|
||||
}, 280);
|
||||
});
|
||||
async function saveIcon(slug) {
|
||||
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
|
||||
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
|
||||
$("#icon-error").textContent = "";
|
||||
try {
|
||||
await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug }) });
|
||||
@@ -420,12 +419,12 @@ $("#reset-icon").addEventListener("click", event => { event.preventDefault(); sa
|
||||
$("#icon-upload").addEventListener("change", async event => {
|
||||
const file = event.target.files[0]; if (!file || !state.iconTarget) return;
|
||||
const data = new FormData(); data.append("icon", file); $("#icon-error").textContent = "";
|
||||
try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refresh(); toast("Custom icon saved locally."); }
|
||||
try { const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites"; await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "POST", body: data }); $("#icon-dialog").close(); await refresh(); toast("Custom icon saved locally."); }
|
||||
catch (error) { $("#icon-error").textContent = error.message; }
|
||||
});
|
||||
$("#save-icon-url").addEventListener("click", async () => {
|
||||
const value = $("#icon-url").value.trim(); if (!/^https:\/\//i.test(value)) { $("#icon-error").textContent = "Enter a trusted HTTPS image URL."; return; }
|
||||
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
|
||||
if (!state.iconTarget) return; const base = state.iconTarget.kind === "proxy" ? "proxies" : state.iconTarget.kind === "redirect" ? "redirects" : state.iconTarget.kind === "streams" ? "streams" : state.iconTarget.kind === "access" ? "access-lists" : state.iconTarget.kind === "users" ? "users" : "sites";
|
||||
try { await api(`/api/${base}/${state.iconTarget.id}/icon`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: value }) }); $("#icon-dialog").close(); await refresh(); toast("Icon URL saved."); }
|
||||
catch (error) { $("#icon-error").textContent = error.message; }
|
||||
});
|
||||
|
||||
+30
-2
@@ -3,6 +3,19 @@ function featureIcon(item, fallback) { return item.icon ? `<img src="${extendedE
|
||||
const backupDialogTextFix = new MutationObserver(() => { const dialog = document.querySelector("#create-backup-dialog"); if (dialog) dialog.querySelectorAll("p,small").forEach(node => { if (node.textContent.includes("Hosted Site files")) node.textContent = node.textContent.replaceAll("Hosted Site files", "uploaded hosted-site files"); }); });
|
||||
backupDialogTextFix.observe(document.body, { childList:true, subtree:true });
|
||||
|
||||
function renderStreams() {
|
||||
const list = document.querySelector("#stream-list"), empty = document.querySelector("#stream-empty");
|
||||
if (!state.loaded) return;
|
||||
empty.classList.toggle("hidden", !state.loaded || state.streams.length > 0);
|
||||
list.innerHTML = state.streams.map(item => {
|
||||
const status = item.status === "running" ? "running" : item.status === "error" ? "error" : "disabled";
|
||||
const upstream = item.enabled === false || item.upstream?.status === "unmonitored" ? "Monitoring paused" : !item.upstream || item.upstream.status === "pending" ? "Target check pending" : item.upstream.status === "healthy" ? `Target reachable · ${item.upstream.responseMs} ms` : `Target unreachable · ${extendedEscape(item.upstream.error || "check failed")}`;
|
||||
const protocols = [item.tcp !== false ? "TCP" : null, item.udp ? "UDP" : null].filter(Boolean).map(value => `<span class="chip">${value}</span>`).join("");
|
||||
const toggle = `<button class="toggle ${item.enabled === false ? "" : "on"}" data-stream-action="toggle" aria-label="${item.enabled === false ? "Enable" : "Disable"} ${extendedEscape(item.name)}"><span></span></button>`;
|
||||
return `<article class="site-card stream-card" data-stream-id="${item.id}" data-kind="stream"><div class="card-top"><div class="site-icon">${featureIcon(item,"SH")}</div><div class="menu-wrap"><button class="icon-button menu-button" aria-label="Streaming host options" aria-expanded="false">•••</button><div class="menu"><button data-stream-action="edit">Edit streaming host</button><button data-stream-action="icon">Change icon</button><button data-stream-action="delete" class="danger-text">Delete streaming host</button></div></div></div><h2>${extendedEscape(item.name)}</h2><p class="address">Port ${item.port}</p><p class="gateway-address">→ ${extendedEscape(item.target)}</p><p class="upstream-copy ${item.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}${protocols}</div></div></article>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderRedirects() {
|
||||
const list = document.querySelector("#redirect-list"), empty = document.querySelector("#redirect-empty");
|
||||
// Keep the existing cards or empty state mounted while the shared refresh is pending.
|
||||
@@ -43,12 +56,27 @@ function renderHealthSettings() {
|
||||
|
||||
function decorateAccessAssignments() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item || card.querySelector(".access-assignment-preview")) return; const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const preview = document.createElement("p"); preview.className = "access-assignment-preview"; preview.textContent = assigned.length ? `Protects: ${assigned.map(host => host.name || host.domain).join(" · ")}` : "Not assigned to a host"; card.querySelector(".card-footer")?.before(preview); }); }
|
||||
function renderAuditPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users || document.querySelector('[data-admin-panel="audit"]')) return; const tab = document.createElement("button"); tab.dataset.adminTab = "audit"; tab.textContent = "Audit log"; tabs.insertBefore(tab, tabs.children[1]); const panel = document.createElement("section"); panel.dataset.adminPanel = "audit"; panel.className = "settings-panel hidden"; panel.innerHTML = '<div class="panel-heading"><div><h2>Configuration audit log</h2><p class="muted">A history of Site Gateway configuration changes. Audit records cannot be edited or deleted.</p></div></div><div class="event-filters"><label>Search audit events<input id="audit-action" placeholder="Search by user, action, or target"></label><label>Result<select id="audit-status"><option value="">All results</option><option value="ok">Success</option><option value="error">Failed</option></select></label></div><div id="audit-list" class="dashboard-list event-list"><p class="quiet-state">Open this tab to load audit records.</p></div>'; users.parentElement.insertBefore(panel, users.nextElementSibling); const load = async () => { const records = await api(`/api/audit?action=${encodeURIComponent(document.querySelector("#audit-action").value)}&status=${encodeURIComponent(document.querySelector("#audit-status").value)}`); document.querySelector("#audit-list").innerHTML = records.length ? records.map(item => `<div class="event-row"><span class="activity-mark ${item.status === "error" ? "bad" : ""}">${item.status === "error" ? "!" : "✓"}</span><span><strong>${extendedEscape(item.action)}</strong><small>${extendedEscape(item.actor || "System")} · ${extendedEscape(item.status === "error" ? "Failed" : "Success")} · ${extendedEscape(formatTime(item.created_at))}</small></span></div>`).join("") : '<p class="quiet-state">No matching audit records.</p>'; }; let timer; tab.addEventListener("click", async () => { document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab)); document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel)); await load(); }); panel.querySelector("#audit-action").addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 300); }); panel.querySelector("#audit-status").addEventListener("change", load); }
|
||||
function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); }
|
||||
function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #stream-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], #stream-list [data-stream-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); }
|
||||
function renderRetentionPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="retention"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "retention"; tab.textContent = "Logs & retention"; tabs.append(tab); } let panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "retention"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); } const policy = state.settings?.logsRetention || { accessDays:30, activityDays:90, auditDays:365, certificateDays:365, securityDays:365, pruningEnabled:false }; panel.innerHTML = `<div class="panel-heading"><div><h2>Logs & retention</h2><p class="muted">Choose how long Site Gateway keeps operational and administrative records. Pruning is disabled until you enable it.</p></div></div><form class="settings-form retention-form"><label class="check-control"><input name="pruningEnabled" type="checkbox" ${policy.pruningEnabled ? "checked" : ""}><span>Enable automatic pruning</span></label><label>Access logs<input name="accessDays" type="number" min="7" max="3650" value="${policy.accessDays}"><small>High-volume request records.</small></label><label>Gateway activity<input name="activityDays" type="number" min="7" max="3650" value="${policy.activityDays}"><small>Operational and configuration events.</small></label><label>Audit logs<input name="auditDays" type="number" min="7" max="3650" value="${policy.auditDays}"><small>Administrative accountability records.</small></label><label>Certificate events<input name="certificateDays" type="number" min="7" max="3650" value="${policy.certificateDays}"></label><label>Security events<input name="securityDays" type="number" min="7" max="3650" value="${policy.securityDays}"></label><div class="dialog-actions"><button class="button primary">Save retention policy</button></div></form>`; panel.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { const updated = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ logsRetention:{ accessDays:Number(form.get("accessDays")), activityDays:Number(form.get("activityDays")), auditDays:Number(form.get("auditDays")), certificateDays:Number(form.get("certificateDays")), securityDays:Number(form.get("securityDays")), pruningEnabled:form.has("pruningEnabled") } }) }); state.settings = updated; toast("Log retention policy saved."); } catch (error) { toast(error.message); } }); }
|
||||
window.renderExtendedViews = function () { renderRedirects(); renderAccessLists(); decorateAccessAssignments(); decorateAccessGroups(); decorateAccessToggles(); renderBackups(); renderDefaultSettings(); renderHealthSettings(); renderGroups(); decorateGroupCards(); renderAuditPanel(); renderRetentionPanel(); const retentionPanel = document.querySelector('[data-admin-panel="retention"]'); const retentionHeading = retentionPanel?.querySelector('.panel-heading > div'); if (retentionHeading && !retentionHeading.querySelector('.retention-eyebrow')) retentionHeading.insertAdjacentHTML("afterbegin", '<p class="eyebrow retention-eyebrow">AUTOMATIC LOG PRUNING</p>'); const retentionActions = retentionPanel?.querySelector('.retention-actions'); if (retentionPanel && !retentionActions) { retentionPanel.querySelector('.panel-heading')?.insertAdjacentHTML('beforeend', '<div class="row-actions retention-actions"><button class="button secondary" type="button" data-retention-action="prune">Prune Now</button><button class="button secondary" type="button" data-retention-action="download">Download Logs</button></div>'); retentionPanel.querySelector('[data-retention-action="prune"]')?.addEventListener('click', () => toast('Pruning will run when automatic pruning is enabled and the policy is saved.')); retentionPanel.querySelector('[data-retention-action="download"]')?.addEventListener('click', () => toast('Log download is not available yet.')); } normalizeAdminTabOrder(); hideRestrictedControls(); };
|
||||
window.renderExtendedViews = function () { renderStreams(); renderRedirects(); renderAccessLists(); decorateAccessAssignments(); decorateAccessGroups(); decorateAccessToggles(); renderBackups(); renderDefaultSettings(); renderHealthSettings(); renderGroups(); decorateGroupCards(); renderAuditPanel(); renderRetentionPanel(); const retentionPanel = document.querySelector('[data-admin-panel="retention"]'); const retentionHeading = retentionPanel?.querySelector('.panel-heading > div'); if (retentionHeading && !retentionHeading.querySelector('.retention-eyebrow')) retentionHeading.insertAdjacentHTML("afterbegin", '<p class="eyebrow retention-eyebrow">AUTOMATIC LOG PRUNING</p>'); const retentionActions = retentionPanel?.querySelector('.retention-actions'); if (retentionPanel && !retentionActions) { retentionPanel.querySelector('.panel-heading')?.insertAdjacentHTML('beforeend', '<div class="row-actions retention-actions"><button class="button secondary" type="button" data-retention-action="prune">Prune Now</button><button class="button secondary" type="button" data-retention-action="download">Download Logs</button></div>'); retentionPanel.querySelector('[data-retention-action="prune"]')?.addEventListener('click', () => toast('Pruning will run when automatic pruning is enabled and the policy is saved.')); retentionPanel.querySelector('[data-retention-action="download"]')?.addEventListener('click', () => toast('Log download is not available yet.')); } normalizeAdminTabOrder(); hideRestrictedControls(); };
|
||||
|
||||
for (let hour = 0; hour < 24; hour++) document.querySelector('#backup-settings-form [name="hour"]').insertAdjacentHTML("beforeend", `<option value="${hour}">${String(hour).padStart(2,"0")}:00</option>`);
|
||||
|
||||
document.querySelector("#stream-form").addEventListener("submit", async event => {
|
||||
event.preventDefault(); const form = new FormData(event.target), body = { name: form.get("name"), port: Number(form.get("port")), target: form.get("target"), tcp: form.has("tcp"), udp: form.has("udp"), healthEnabled: form.has("healthEnabled") }; document.querySelector("#stream-error").textContent = "";
|
||||
try { const id = event.target.dataset.editing; await api(id ? `/api/streams/${id}` : "/api/streams", { method: id ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); delete event.target.dataset.editing; document.querySelector("#stream-dialog").close(); await refresh(); toast(`Streaming host ${id ? "updated" : "created"} and applied.`); } catch (error) { document.querySelector("#stream-error").textContent = error.message; }
|
||||
});
|
||||
document.querySelector("#stream-list").addEventListener("click", async event => {
|
||||
const button = event.target.closest("[data-stream-action]"), card = button?.closest("[data-stream-id]"); if (!button || !card) return; const item = state.streams.find(value => value.id === card.dataset.streamId); if (!item) return;
|
||||
try {
|
||||
if (button.dataset.streamAction === "edit") { const form = document.querySelector("#stream-form"); form.reset(); form.dataset.editing = item.id; form.elements.name.value = item.name || ""; form.elements.port.value = item.port; form.elements.target.value = item.target || ""; form.elements.tcp.checked = item.tcp !== false; form.elements.udp.checked = Boolean(item.udp); form.elements.healthEnabled.checked = item.healthEnabled !== false; document.querySelector("#stream-title").textContent = "Edit streaming host"; document.querySelector("#stream-form .button.primary").textContent = "Save & apply"; document.querySelector("#stream-error").textContent = ""; document.querySelector("#stream-dialog").showModal(); return; }
|
||||
if (button.dataset.streamAction === "icon") { card.classList.remove("menu-open"); return openIconPicker("streams", item.id); }
|
||||
if (button.dataset.streamAction === "delete") { if (!confirm(`Delete streaming host “${item.name}”?`)) return; await api(`/api/streams/${item.id}`, { method: "DELETE" }); await refresh(); toast("Streaming host deleted."); return; }
|
||||
await api(`/api/streams/${item.id}/toggle`, { method: "POST" }); await refresh(); toast("Streaming host updated.");
|
||||
} catch (error) { toast(error.message); }
|
||||
});
|
||||
document.querySelector("#stream-list").addEventListener("click", event => { if (event.target.closest(".menu-button")) { const card = event.target.closest("[data-stream-id]"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll("#stream-list .menu-open").forEach(item => item.classList.remove("menu-open")); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); } });
|
||||
|
||||
document.querySelector("#redirect-form").addEventListener("submit", async event => {
|
||||
event.preventDefault(); const form = new FormData(event.target), body = Object.fromEntries(form); body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); body.preservePath = form.has("preservePath"); document.querySelector("#redirect-error").textContent = "";
|
||||
try { const id = event.target.dataset.editing; await api(id ? `/api/redirects/${id}` : "/api/redirects", { method:id ? "PATCH" : "POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(body) }); delete event.target.dataset.editing; document.querySelector("#redirect-dialog").close(); await refresh(); toast(`Redirect Host ${id ? "updated" : "created"} and applied.`); } catch (error) { document.querySelector("#redirect-error").textContent = error.message; }
|
||||
|
||||
+4
-24
File diff suppressed because one or more lines are too long
@@ -130,9 +130,9 @@ dialog{max-height:calc(100vh - 28px);overflow:auto}
|
||||
#access-assignment-summary>strong{margin-top:20px;padding-top:16px;border-top:1px solid var(--line)}.access-create-guidance{margin-top:18px;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--panel2)}.access-create-guidance strong{display:block}.access-create-guidance p{margin:6px 0 0;color:var(--muted);line-height:1.45}
|
||||
|
||||
.danger-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.danger-actions .button{margin:0}
|
||||
.docs-intro{--text:#f4f7fb;--muted:#aebbd0;margin-bottom:22px;padding:24px;border:1px solid var(--line);border-radius:16px;background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95));color:var(--text)}.docs-intro h2{margin:0 0 10px}.docs-intro p:last-child{margin:0;color:var(--muted);max-width:850px;line-height:1.6}
|
||||
.docs-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:22px;align-items:start}.docs-nav{position:sticky;top:18px;height:calc(100vh - 170px);overflow:auto;display:grid;align-content:start;gap:6px;padding:14px;border:1px solid var(--line);border-radius:14px;background:rgba(16,26,43,.9)}.docs-nav .eyebrow{margin:4px 8px 8px}.docs-nav button{padding:9px 10px;border:0;border-radius:8px;background:transparent;color:var(--muted);text-align:left;cursor:pointer;font:inherit}.docs-nav button:hover{background:rgba(98,230,167,.1);color:var(--text)}.docs #docs-content{display:grid;gap:16px}.docs #docs-content article{scroll-margin-top:18px;padding:24px;border:1px solid var(--line);border-radius:16px;background:rgba(16,26,43,.76);line-height:1.6}.docs #docs-content article h2{margin:0 0 12px}.docs #docs-content article h3{margin:20px 0 5px;color:var(--green);font-size:.86rem}.docs #docs-content article p{color:var(--muted)}.docs .doc-search{display:block;margin-top:18px}.docs .doc-search input{margin-top:8px}@media(max-width:800px){.docs-layout{grid-template-columns:1fr}.docs-nav{position:static;display:flex;flex-wrap:wrap}.docs-nav .eyebrow{width:100%}}
|
||||
.docs-intro{padding:32px 34px}.docs-search-panel{max-width:880px;margin-top:24px;padding:18px 20px;border:1px solid var(--line);border-radius:14px;background:rgba(7,15,28,.42)}.docs-search-panel .doc-search{margin:0}.docs-search-panel .doc-search span{display:block;margin-bottom:8px;font-weight:750;color:var(--text)}.docs-search-panel small{display:block;margin-top:8px;color:var(--muted)}.docs #docs-content article ul{margin:10px 0 0;padding-left:22px;color:var(--muted)}.docs #docs-content article li{margin:8px 0}
|
||||
.docs-intro{margin-bottom:22px;padding:24px;border:1px solid var(--line);border-radius:16px;background:var(--panel);color:var(--text)}.docs-intro h2{margin:0 0 10px}.docs-intro p:last-child{margin:0;color:var(--muted);max-width:850px;line-height:1.6}
|
||||
.docs-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:22px;align-items:start}.docs-nav{position:sticky;top:18px;height:calc(100vh - 170px);overflow:auto;display:grid;align-content:start;gap:6px;padding:14px;border:1px solid var(--line);border-radius:14px;background:var(--panel)}.docs-nav .eyebrow{margin:4px 8px 8px}.docs-nav button{padding:9px 10px;border:0;border-radius:8px;background:transparent;color:var(--muted);text-align:left;cursor:pointer;font:inherit}.docs-nav button:hover{background:rgba(98,230,167,.1);color:var(--text)}.docs #docs-content{display:grid;gap:16px}.docs #docs-content article{scroll-margin-top:18px;padding:24px;border:1px solid var(--line);border-radius:16px;background:var(--panel);line-height:1.6}.docs #docs-content article h2{margin:0 0 12px}.docs #docs-content article h3{margin:20px 0 5px;color:var(--green);font-size:.86rem}.docs #docs-content article p{color:var(--muted)}.docs .doc-search{display:block;margin-top:18px}.docs .doc-search input{margin-top:8px}@media(max-width:800px){.docs-layout{grid-template-columns:1fr}.docs-nav{position:static;display:flex;flex-wrap:wrap}.docs-nav .eyebrow{width:100%}}
|
||||
.docs-intro{padding:32px 34px}.docs-search-panel{max-width:880px;margin-top:24px;padding:18px 20px;border:1px solid var(--line);border-radius:14px;background:var(--panel2)}.docs-search-panel .doc-search{margin:0}.docs-search-panel .doc-search span{display:block;margin-bottom:8px;font-weight:750;color:var(--text)}.docs-search-panel small{display:block;margin-top:8px;color:var(--muted)}.docs #docs-content article ul{margin:10px 0 0;padding-left:22px;color:var(--muted)}.docs #docs-content article li{margin:8px 0}
|
||||
.docs-intro{width:100%;box-sizing:border-box;text-align:center}.docs-intro>p:not(.eyebrow){margin-left:auto;margin-right:auto}.docs-search-panel{max-width:none;text-align:left}.docs-search-panel .doc-search input{width:100%;box-sizing:border-box}
|
||||
/* Manual uses a full-width header, then a two-column reading layout. */
|
||||
.docs{display:block}.docs-intro{grid-column:1/-1}.docs-layout{width:100%}
|
||||
@@ -240,7 +240,7 @@ select{appearance:none!important;-webkit-appearance:none!important;background-re
|
||||
:root .site-icon,:root[data-theme="light"] .site-icon,:root .site-card.proxy .site-icon,:root[data-theme="light"] .site-card.proxy .site-icon{background:var(--panel2);color:var(--green);border:1px solid var(--line)}
|
||||
:root .retention-load-more{display:block;margin:10px auto 0;padding:8px 14px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);color:var(--text);font-size:.72rem;font-weight:700}:root .retention-load-more:hover{border-color:var(--green);color:var(--green)}
|
||||
:root .retention-load-more{display:none!important}
|
||||
#create-dialog .dialog-heading .close-dialog,#settings-dialog .dialog-heading .close-dialog{display:none}
|
||||
#create-dialog .dialog-heading .close-dialog,#settings-dialog .dialog-heading .close-dialog,#proxy-dialog .dialog-heading .close-dialog,#redirect-dialog .dialog-heading .close-dialog,#stream-dialog .dialog-heading .close-dialog{display:none}
|
||||
#app .site-card.access-card .site-icon{background:#193d38;color:var(--green)}:root[data-theme="light"] #app .site-card.access-card .site-icon{background:#e2f5ed;color:#138a5b}
|
||||
#app .user-avatar{background:#193d38;color:var(--green)}:root[data-theme="light"] #app .user-avatar{background:#e2f5ed;color:#138a5b}
|
||||
#app .user-avatar img{filter:grayscale(1) sepia(1) saturate(5) hue-rotate(95deg) brightness(1.1)}
|
||||
|
||||
+208
-13
@@ -5,6 +5,7 @@ import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import dgram from "node:dgram";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
@@ -44,10 +45,12 @@ const adminPassword = process.env.ADMIN_PASSWORD || "change-this-password";
|
||||
const sessionSecret = process.env.SESSION_SECRET || crypto.createHash("sha256").update(`${adminUser}:${adminPassword}`).digest("hex");
|
||||
const scheduledBackupPassword = process.env.BACKUP_PASSWORD || "";
|
||||
const activeServers = new Map();
|
||||
const activeStreams = new Map();
|
||||
let sites = [];
|
||||
let proxies = [];
|
||||
let users = [];
|
||||
let redirects = [];
|
||||
let streams = [];
|
||||
let accessLists = [];
|
||||
let groups = [];
|
||||
let settings = {};
|
||||
@@ -141,6 +144,7 @@ const saveProxies = async () => storage.saveCollection("proxies", proxies);
|
||||
const saveUsers = async () => storage.saveCollection("users", users);
|
||||
const saveGroups = async () => storage.saveCollection("groups", groups);
|
||||
const saveRedirects = async () => storage.saveCollection("redirects", redirects);
|
||||
const saveStreams = async () => storage.saveCollection("streams", streams);
|
||||
const saveAccessLists = async () => storage.saveCollection("access_lists", accessLists);
|
||||
const saveSettings = async () => storage.saveSettings(settings);
|
||||
|
||||
@@ -191,6 +195,7 @@ async function loadSites() {
|
||||
}
|
||||
if (usersChanged) await saveUsers();
|
||||
redirects = storage.loadCollection("redirects");
|
||||
streams = storage.loadCollection("streams").map(item => ({ ...item, healthEnabled: !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false") }));
|
||||
accessLists = storage.loadCollection("access_lists");
|
||||
groups = storage.loadCollection("groups");
|
||||
const defaultSettings = {
|
||||
@@ -232,6 +237,27 @@ function validateTarget(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateStreamPort(value) {
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw Object.assign(new Error("Incoming port must be between 1 and 65535."), { status: 400 });
|
||||
return port;
|
||||
}
|
||||
|
||||
function validateStreamHostPort(value) {
|
||||
const raw = String(value || "").trim();
|
||||
const match = raw.match(/^\[?([^\s\]]+)\]?:(\d{1,5})$/);
|
||||
if (!match) throw Object.assign(new Error("Forward to must be host:port, such as 192.168.1.20:22."), { status: 400 });
|
||||
const port = Number(match[2]);
|
||||
if (!match[1] || port < 1 || port > 65535) throw Object.assign(new Error("Forward to must be host:port, such as 192.168.1.20:22."), { status: 400 });
|
||||
return `${match[1]}:${port}`;
|
||||
}
|
||||
|
||||
function streamPortConflict(port, exceptId) {
|
||||
if (port === adminPort || port === 80 || port === 443 || (port >= minPort && port <= maxPort)) return "That port is already reserved by the gateway.";
|
||||
if (streams.some(item => item.port === port && item.id !== exceptId)) return "That port is already used by another streaming host.";
|
||||
return null;
|
||||
}
|
||||
|
||||
function cleanHeaders(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.slice(0, 30).map(item => ({ name: String(item.name || "").trim(), value: String(item.value || "").trim() }))
|
||||
@@ -398,7 +424,7 @@ async function syncCaddy() {
|
||||
}
|
||||
if (previousDefaultPage !== null) await fsp.writeFile(path.join(defaultSiteDir, "index.html"), previousDefaultPage);
|
||||
try {
|
||||
sites = storage.loadCollection("sites"); proxies = storage.loadCollection("proxies"); redirects = storage.loadCollection("redirects"); accessLists = storage.loadCollection("access_lists"); settings = storage.loadSettings() || settings;
|
||||
sites = storage.loadCollection("sites"); proxies = storage.loadCollection("proxies"); redirects = storage.loadCollection("redirects"); streams = storage.loadCollection("streams"); accessLists = storage.loadCollection("access_lists"); settings = storage.loadSettings() || settings;
|
||||
} catch { /* Startup may not have completed database initialization yet. */ }
|
||||
gatewayError = rollbackSucceeded ? null : rejectedReason;
|
||||
const friendly = /upstream address scheme is HTTP but transport is configured for HTTP\+TLS/i.test(rejectedReason) ? "This host forwards to HTTP, but Ignore upstream TLS certificate errors is enabled. Turn that option off or change the upstream to HTTPS." : /upstream address scheme is HTTPS but transport is configured for plain HTTP/i.test(rejectedReason) ? "This host forwards to HTTPS, but its upstream transport is configured for plain HTTP. Use HTTPS transport settings or change the upstream to HTTP." : /duplicate.*address|already.*site address/i.test(rejectedReason) ? "This hostname or address is already used by another host. Choose a unique hostname and port." : /dial tcp|no such host|lookup .* no such host|upstream.*(invalid|malformed)/i.test(rejectedReason) ? "The upstream address could not be reached or is invalid. Check the hostname, IP address, and port." : /invalid hostname|host name.*invalid|malformed.*host/i.test(rejectedReason) ? "The hostname is not valid. Use a valid domain name without a protocol or path." : /unrecognized directive|unknown directive|parsing caddyfile tokens/i.test(rejectedReason) ? "The gateway configuration contains an unsupported or malformed directive. Check the selected host settings." : /certificate|tls.*(config|handshake)|no certificate/i.test(rejectedReason) ? "The TLS certificate configuration is invalid or unavailable. Check the certificate, key, and HTTPS settings." : "The gateway rejected this configuration. Check the host, upstream address, and TLS settings.";
|
||||
@@ -423,6 +449,10 @@ function publicProxy(proxy, includeAdvanced = false) {
|
||||
return { ...safe, domains: normalizeDomains(proxy.domain, proxy.domains), certificatePath: certificatePath ? "installed" : null, hasCustomCertificate: Boolean(certificatePath && keyPath), status: proxy.enabled ? (gatewayError ? "error" : "running") : "disabled", upstream: upstreamHealth.get(proxy.id) || null };
|
||||
}
|
||||
|
||||
function publicStream(stream) {
|
||||
return { ...stream, status: stream.enabled === false ? "disabled" : activeStreams.has(stream.id) ? "running" : "error", upstream: upstreamHealth.get(stream.id) || null };
|
||||
}
|
||||
|
||||
async function walkFiles(directory) {
|
||||
const output = [];
|
||||
for (const entry of await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error))) {
|
||||
@@ -524,7 +554,7 @@ async function checkProxy(proxy) {
|
||||
}
|
||||
|
||||
async function checkAllProxies() {
|
||||
await Promise.all([...proxies.map(checkProxy), ...sites.map(site => checkProxy({ ...site, target: `http://127.0.0.1:${site.port}`, healthPath: site.healthPath || "/", healthMethod: site.healthMethod || "GET", healthExpected: site.healthExpected || "200-499", healthTimeoutSeconds: site.healthTimeoutSeconds || 4, healthRetries: site.healthRetries || 0, healthEnabled: site.healthEnabled }))]);
|
||||
await Promise.all([...proxies.map(checkProxy), ...sites.map(site => checkProxy({ ...site, target: `http://127.0.0.1:${site.port}`, healthPath: site.healthPath || "/", healthMethod: site.healthMethod || "GET", healthExpected: site.healthExpected || "200-499", healthTimeoutSeconds: site.healthTimeoutSeconds || 4, healthRetries: site.healthRetries || 0, healthEnabled: site.healthEnabled })), ...streams.map(checkStream)]);
|
||||
return proxies.map(publicProxy);
|
||||
}
|
||||
|
||||
@@ -581,6 +611,18 @@ function tcpProbe(port, timeoutMs = 1000) {
|
||||
});
|
||||
}
|
||||
|
||||
function tcpProbeHost(host, port, timeoutMs = 4000) {
|
||||
return new Promise(resolve => {
|
||||
if (!host || !Number.isInteger(port) || port < 1 || port > 65535) return resolve(false);
|
||||
const socket = net.createConnection({ host, port });
|
||||
const finish = result => { socket.destroy(); resolve(result); };
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("timeout", () => finish(false));
|
||||
socket.once("error", () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
function stableProbe(name, responding) {
|
||||
if (responding) { probeFailures[name] = 0; return { status: "ready", healthy: true, responding: true }; }
|
||||
probeFailures[name] += 1;
|
||||
@@ -712,6 +754,90 @@ async function restartSite(site) {
|
||||
if (site.enabled) await startSite(site);
|
||||
}
|
||||
|
||||
// Streaming hosts relay raw TCP/UDP on a specific port straight to a host:port target — no domain, no HTTP,
|
||||
// no Caddy involvement. This is the same pattern as startSite()/stopSite() above: a dedicated listener Site
|
||||
// Gateway owns directly, just for a plain socket instead of an HTTP server.
|
||||
async function startStream(stream) {
|
||||
if (stream.enabled === false || activeStreams.has(stream.id)) return;
|
||||
const [targetHost, targetPortRaw] = String(stream.target || "").split(":");
|
||||
const targetPort = Number(targetPortRaw);
|
||||
const handle = { tcpServer: null, udpSocket: null, udpSessions: new Map() };
|
||||
try {
|
||||
if (stream.tcp !== false) {
|
||||
const tcpServer = net.createServer(socket => {
|
||||
const upstream = net.createConnection({ host: targetHost, port: targetPort });
|
||||
const destroyBoth = () => { socket.destroy(); upstream.destroy(); };
|
||||
socket.on("error", destroyBoth); upstream.on("error", destroyBoth);
|
||||
socket.on("close", () => upstream.destroy()); upstream.on("close", () => socket.destroy());
|
||||
socket.pipe(upstream); upstream.pipe(socket);
|
||||
});
|
||||
await new Promise((resolve, reject) => { tcpServer.once("error", reject); tcpServer.listen(stream.port, "0.0.0.0", resolve); });
|
||||
tcpServer.on("error", error => console.warn(`Streaming host “${stream.name}” TCP error:`, error.message));
|
||||
handle.tcpServer = tcpServer;
|
||||
}
|
||||
if (stream.udp) {
|
||||
const udpSocket = dgram.createSocket("udp4");
|
||||
udpSocket.on("message", (message, rinfo) => {
|
||||
const key = `${rinfo.address}:${rinfo.port}`;
|
||||
let session = handle.udpSessions.get(key);
|
||||
if (!session) {
|
||||
const outbound = dgram.createSocket("udp4");
|
||||
session = { outbound, timer: null, connected: false, pending: [] };
|
||||
outbound.on("message", reply => { try { udpSocket.send(reply, rinfo.port, rinfo.address); } catch { /* client socket may already be gone */ } });
|
||||
outbound.on("error", () => {});
|
||||
// connect() is asynchronous — sending before it completes silently drops the datagram, which would
|
||||
// lose the first packet of every new UDP session. Queue until the callback confirms it's connected.
|
||||
outbound.connect(targetPort, targetHost, () => { session.connected = true; for (const buffered of session.pending.splice(0)) { try { outbound.send(buffered); } catch { /* upstream may be unreachable */ } } });
|
||||
handle.udpSessions.set(key, session);
|
||||
}
|
||||
clearTimeout(session.timer);
|
||||
session.timer = setTimeout(() => { session.outbound.close(); handle.udpSessions.delete(key); }, 60000).unref();
|
||||
if (session.connected) { try { session.outbound.send(message); } catch { /* upstream may be unreachable; drop this datagram */ } }
|
||||
else session.pending.push(message);
|
||||
});
|
||||
await new Promise((resolve, reject) => { udpSocket.once("error", reject); udpSocket.bind(stream.port, "0.0.0.0", resolve); });
|
||||
udpSocket.on("error", error => console.warn(`Streaming host “${stream.name}” UDP error:`, error.message));
|
||||
handle.udpSocket = udpSocket;
|
||||
}
|
||||
} catch (error) {
|
||||
if (handle.tcpServer) await new Promise(resolve => handle.tcpServer.close(resolve));
|
||||
if (handle.udpSocket) handle.udpSocket.close();
|
||||
throw error;
|
||||
}
|
||||
activeStreams.set(stream.id, handle);
|
||||
console.log(`Streaming “${stream.name}” on port ${stream.port}`);
|
||||
}
|
||||
|
||||
async function stopStream(id) {
|
||||
const handle = activeStreams.get(id);
|
||||
if (!handle) return;
|
||||
if (handle.tcpServer) await new Promise(resolve => handle.tcpServer.close(resolve));
|
||||
if (handle.udpSocket) {
|
||||
for (const session of handle.udpSessions.values()) { clearTimeout(session.timer); session.outbound.close(); }
|
||||
handle.udpSocket.close();
|
||||
}
|
||||
activeStreams.delete(id);
|
||||
}
|
||||
|
||||
async function restartStream(stream) {
|
||||
await stopStream(stream.id);
|
||||
if (stream.enabled !== false) await startStream(stream);
|
||||
}
|
||||
|
||||
async function checkStream(stream) {
|
||||
if (stream.enabled === false) { const result = { status: "disabled", checkedAt: new Date().toISOString(), history: [] }; upstreamHealth.set(stream.id, result); return result; }
|
||||
if (stream.healthEnabled === false) { const result = { status: "unmonitored", checkedAt: null, history: [] }; upstreamHealth.set(stream.id, result); return result; }
|
||||
const started = performance.now();
|
||||
const [targetHost, targetPortRaw] = String(stream.target || "").split(":");
|
||||
const healthy = await tcpProbeHost(targetHost, Number(targetPortRaw), 4000);
|
||||
const responseMs = Math.round(performance.now() - started);
|
||||
const result = { status: healthy ? "healthy" : "unhealthy", responseMs, checkedAt: new Date().toISOString(), error: healthy ? null : `Could not open a TCP connection to ${stream.target}` };
|
||||
const previous = upstreamHealth.get(stream.id);
|
||||
result.history = [{ status: result.status, responseMs, checkedAt: result.checkedAt }, ...(previous?.history || [])].slice(0, 7);
|
||||
upstreamHealth.set(stream.id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function validatePort(port, exceptId) {
|
||||
if (!Number.isInteger(port) || port < minPort || port > maxPort) return `Port must be between ${minPort} and ${maxPort}.`;
|
||||
if (sites.some(site => site.port === port && site.id !== exceptId)) return "That port is already assigned.";
|
||||
@@ -754,7 +880,7 @@ async function installUpload(site, file) {
|
||||
}
|
||||
}
|
||||
|
||||
const portableCollections = { "sites.json": () => sites, "proxies.json": () => proxies, "redirects.json": () => redirects, "access-lists.json": () => accessLists, "users.json": () => users, "groups.json": () => groups, "settings.json": () => settings };
|
||||
const portableCollections = { "sites.json": () => sites, "proxies.json": () => proxies, "redirects.json": () => redirects, "streams.json": () => streams, "access-lists.json": () => accessLists, "users.json": () => users, "groups.json": () => groups, "settings.json": () => settings };
|
||||
|
||||
async function protectBackup(buffer, password) {
|
||||
if (!password) return buffer;
|
||||
@@ -832,7 +958,7 @@ async function restoreBackup(filename, password = "", createSafetyBackup = true)
|
||||
await fsp.copyFile(restoredDatabase, activeDatabasePath); storage = await openStorage(dataDir, backupsDir);
|
||||
} else {
|
||||
const legacyRoot = fs.existsSync(path.join(staging, "portable-json")) ? path.join(staging, "portable-json") : fs.existsSync(path.join(staging, "legacy-json")) ? path.join(staging, "legacy-json") : path.join(staging, "config");
|
||||
storage.saveCollection("sites", []); storage.saveCollection("proxies", []); storage.saveCollection("redirects", []);
|
||||
storage.saveCollection("sites", []); storage.saveCollection("proxies", []); storage.saveCollection("redirects", []); storage.saveCollection("streams", []);
|
||||
for (const [name, kind] of Object.entries({ "access-lists.json":"access_lists", "sites.json":"sites", "proxies.json":"proxies", "redirects.json":"redirects", "users.json":"users" })) { const candidate = path.join(legacyRoot, name); if (fs.existsSync(candidate)) storage.saveCollection(kind, JSON.parse(await fsp.readFile(candidate, "utf8"))); }
|
||||
const settingsCandidate = path.join(legacyRoot, "settings.json"); if (fs.existsSync(settingsCandidate)) storage.saveSettings(JSON.parse(await fsp.readFile(settingsCandidate, "utf8")));
|
||||
}
|
||||
@@ -843,9 +969,10 @@ async function restoreBackup(filename, password = "", createSafetyBackup = true)
|
||||
if (manifest.type === "complete" && fs.existsSync(path.join(staging, "custom-certificates"))) {
|
||||
await fsp.mkdir(customCertificatesDir, { recursive: true }); await fsp.cp(path.join(staging, "custom-certificates"), customCertificatesDir, { recursive: true });
|
||||
}
|
||||
await Promise.all([...activeServers.keys()].map(stopSite)); sites = []; proxies = []; users = []; redirects = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites();
|
||||
await Promise.all([...activeServers.keys()].map(stopSite)); await Promise.all([...activeStreams.keys()].map(stopStream)); sites = []; proxies = []; users = []; redirects = []; streams = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites();
|
||||
if (manifest.type === "complete") for (const site of sites) { const contentRoot = path.join(sitesDir, site.id); if (!fs.existsSync(path.join(contentRoot, "index.html"))) throw new Error(`Restored hosted site “${site.name || site.id}” is missing index.html.`); }
|
||||
for (const site of sites.filter(item => item.enabled)) await startSite(site);
|
||||
for (const stream of streams.filter(item => item.enabled !== false)) { try { await startStream(stream); } catch (error) { console.error(`Could not start streaming host “${stream.name}”:`, error.message); } }
|
||||
await syncCaddy(); recordActivity(`Backup ${filename} restored.`);
|
||||
} catch (error) {
|
||||
if (safetyBackup) {
|
||||
@@ -867,6 +994,9 @@ try {
|
||||
for (const site of sites.filter(item => item.enabled)) {
|
||||
try { await startSite(site); } catch (error) { console.error(`Could not start ${site.name}:`, error.message); }
|
||||
}
|
||||
for (const stream of streams.filter(item => item.enabled !== false)) {
|
||||
try { await startStream(stream); } catch (error) { console.error(`Could not start streaming host “${stream.name}”:`, error.message); }
|
||||
}
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try { await syncCaddy(); break; }
|
||||
catch (error) {
|
||||
@@ -973,7 +1103,7 @@ 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|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.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.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." }));
|
||||
@@ -1099,7 +1229,7 @@ function entryLabel(item) {
|
||||
}
|
||||
app.put("/api/:kind/:id/icon", async (req, res, next) => {
|
||||
try {
|
||||
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
|
||||
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
|
||||
if (!collection) return res.status(404).json({ error: "Entry type not found." });
|
||||
const item = collection.find(entry => entry.id === req.params.id);
|
||||
if (!item) return res.status(404).json({ error: "Entry not found." });
|
||||
@@ -1107,7 +1237,7 @@ app.put("/api/:kind/:id/icon", async (req, res, next) => {
|
||||
const url = String(req.body.url || "").trim();
|
||||
if (!/^https:\/\//i.test(url) || url.length > 2048) return res.status(400).json({ error: "Icon URL must be a valid HTTPS URL under 2048 characters." });
|
||||
item.iconSlug = null; item.icon = url;
|
||||
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists();
|
||||
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === streams) await saveStreams(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists();
|
||||
recordActivity(`Icon URL updated for “${entryLabel(item)}”.`);
|
||||
return res.json(item);
|
||||
}
|
||||
@@ -1115,14 +1245,14 @@ app.put("/api/:kind/:id/icon", async (req, res, next) => {
|
||||
const icon = slug ? await cacheIcon(slug) : null;
|
||||
item.iconSlug = slug || null;
|
||||
item.icon = icon;
|
||||
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else await saveAccessLists();
|
||||
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === streams) await saveStreams(); else if (collection === groups) await saveGroups(); else await saveAccessLists();
|
||||
recordActivity(`${slug ? "Icon updated" : "Icon reset"} for “${entryLabel(item)}”.`);
|
||||
res.json(item);
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next) => {
|
||||
try {
|
||||
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
|
||||
const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "streams" ? streams : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null;
|
||||
if (!collection) return res.status(404).json({ error: "Entry type not found." });
|
||||
const item = collection.find(entry => entry.id === req.params.id);
|
||||
if (!item) return res.status(404).json({ error: "Entry not found." });
|
||||
@@ -1132,7 +1262,7 @@ app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next
|
||||
const filename = `${req.params.kind}-${item.id}.${extension}`;
|
||||
await fsp.rename(req.file.path, path.join(iconsDir, filename));
|
||||
item.iconSlug = null; item.icon = `/site-icons/${filename}`;
|
||||
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists();
|
||||
if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === streams) await saveStreams(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists();
|
||||
recordActivity(`Custom icon uploaded for “${entryLabel(item)}”.`);
|
||||
res.json(item);
|
||||
} catch (error) { next(error); }
|
||||
@@ -1424,6 +1554,70 @@ app.delete("/api/redirects/:id", async (req, res, next) => {
|
||||
try { const index = redirects.findIndex(item => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Redirect Host not found." }); const [item] = redirects.splice(index, 1); await syncCaddy(); await pruneOrphanedCertificates(normalizeDomains(item.domain, item.domains)); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” deleted.`); res.status(204).end(); } catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.get("/api/streams", (req, res) => res.json(streams.map(publicStream)));
|
||||
app.post("/api/streams", async (req, res, next) => {
|
||||
try {
|
||||
const name = String(req.body.name || "").trim();
|
||||
if (!name) return res.status(400).json({ error: "Name is required." });
|
||||
const port = validateStreamPort(req.body.port);
|
||||
const portError = streamPortConflict(port); if (portError) return res.status(400).json({ error: portError });
|
||||
const target = validateStreamHostPort(req.body.target);
|
||||
const tcp = req.body.tcp !== false, udp = req.body.udp === true;
|
||||
if (!tcp && !udp) return res.status(400).json({ error: "Enable TCP, UDP, or both." });
|
||||
const stream = { id: `stream-${crypto.randomBytes(4).toString("hex")}`, name, port, target, tcp, udp, healthEnabled: req.body.healthEnabled !== false, enabled: true, createdAt: new Date().toISOString() };
|
||||
try { await startStream(stream); } catch (error) { return res.status(409).json({ error: `Could not bind port ${port}: ${error.message}` }); }
|
||||
streams.push(stream);
|
||||
if (stream.healthEnabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] });
|
||||
else { upstreamHealth.set(stream.id, { status: "pending", checkedAt: null, history: [] }); checkStream(stream).catch(error => console.warn("Streaming host health check failed:", error.message)); }
|
||||
await saveStreams();
|
||||
recordActivity(`Streaming host “${stream.name}” created.`);
|
||||
res.status(201).json(publicStream(stream));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.patch("/api/streams/:id", async (req, res, next) => {
|
||||
try {
|
||||
const stream = streams.find(item => item.id === req.params.id); if (!stream) return res.status(404).json({ error: "Streaming host not found." });
|
||||
const next_ = { ...stream };
|
||||
if (req.body.name !== undefined) { const name = String(req.body.name).trim(); if (!name) return res.status(400).json({ error: "Name is required." }); next_.name = name; }
|
||||
if (req.body.port !== undefined) { const port = validateStreamPort(req.body.port); const portError = streamPortConflict(port, stream.id); if (portError) return res.status(400).json({ error: portError }); next_.port = port; }
|
||||
if (req.body.target !== undefined) next_.target = validateStreamHostPort(req.body.target);
|
||||
if (req.body.tcp !== undefined) next_.tcp = Boolean(req.body.tcp);
|
||||
if (req.body.udp !== undefined) next_.udp = Boolean(req.body.udp);
|
||||
if (!next_.tcp && !next_.udp) return res.status(400).json({ error: "Enable TCP, UDP, or both." });
|
||||
if (req.body.healthEnabled !== undefined) next_.healthEnabled = req.body.healthEnabled === true || (typeof req.body.healthEnabled === "string" && req.body.healthEnabled.toLowerCase() === "true");
|
||||
if (req.body.enabled !== undefined) next_.enabled = Boolean(req.body.enabled);
|
||||
const portOrProtocolChanged = next_.port !== stream.port || next_.target !== stream.target || next_.tcp !== stream.tcp || next_.udp !== stream.udp || next_.enabled !== stream.enabled;
|
||||
Object.assign(stream, next_);
|
||||
if (portOrProtocolChanged) { try { await restartStream(stream); } catch (error) { return res.status(409).json({ error: `Could not bind port ${stream.port}: ${error.message}` }); } }
|
||||
if (stream.healthEnabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] });
|
||||
else { upstreamHealth.set(stream.id, { status: "pending", checkedAt: null, history: [] }); checkStream(stream).catch(error => console.warn("Streaming host health check failed:", error.message)); }
|
||||
await saveStreams();
|
||||
recordActivity(`Streaming host “${stream.name}” updated.`);
|
||||
res.json(publicStream(stream));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.post("/api/streams/:id/toggle", async (req, res, next) => {
|
||||
try {
|
||||
const stream = streams.find(item => item.id === req.params.id); if (!stream) return res.status(404).json({ error: "Streaming host not found." });
|
||||
stream.enabled = !stream.enabled;
|
||||
try { await restartStream(stream); } catch (error) { stream.enabled = !stream.enabled; return res.status(409).json({ error: `Could not bind port ${stream.port}: ${error.message}` }); }
|
||||
if (stream.enabled === false) upstreamHealth.set(stream.id, { status: "unmonitored", checkedAt: null, history: [] });
|
||||
await saveStreams();
|
||||
recordActivity(`Streaming host “${stream.name}” ${stream.enabled ? "enabled" : "disabled"}.`);
|
||||
res.json(publicStream(stream));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
app.delete("/api/streams/:id", async (req, res, next) => {
|
||||
try {
|
||||
const index = streams.findIndex(item => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Streaming host not found." });
|
||||
const [item] = streams.splice(index, 1);
|
||||
await stopStream(item.id); upstreamHealth.delete(item.id);
|
||||
await saveStreams();
|
||||
recordActivity(`Streaming host “${item.name}” deleted.`);
|
||||
res.status(204).end();
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
app.patch("/api/settings", async (req, res, next) => {
|
||||
try {
|
||||
if (req.body.defaultSite) {
|
||||
@@ -1448,7 +1642,7 @@ app.post("/api/logs/prune", async (req, res, next) => { try { if (req.user.role
|
||||
app.get("/api/logs/prune/preview", (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); res.json({ enabled: settings.logsRetention?.pruningEnabled === true, counts: storage.previewPruneEvents(settings.logsRetention || {}) }); } catch (error) { next(error); } });
|
||||
app.get("/api/logs/download", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const payload = { product: "Site Gateway", generatedAt: new Date().toISOString(), access: storage.listAccessEvents(500), activity: storage.listActivity(500), audit: storage.listAudit({}) }; res.setHeader("Content-Disposition", `attachment; filename="site-gateway-logs-${new Date().toISOString().slice(0, 10)}.json"`); res.json(payload); } catch (error) { next(error); } });
|
||||
app.post("/api/settings/reset-defaults", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.confirmation || "") !== "RESTORE DEFAULT") return res.status(400).json({ error:"Type RESTORE DEFAULT exactly to continue." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); settings.defaultSite = { mode:"themed404", redirectUrl:"", redirectCode:302, preservePath:true, title:"Route not found", message:"The gateway is responding, but this address has not been configured.", customHtml:"" }; settings.backups = { enabled:false, frequency:"daily", hour:2, retention:7, type:"configuration", includeLogs:false, encrypt:false, lastRunAt:null, lastStatus:null }; settings.certificateHealth = { warningDays:30, criticalDays:7, staleMinutes:10 }; await saveSettings(); recordActivity("Gateway preferences restored to defaults."); res.json({ ...settings, backupDirectory:backupsDir }); } catch (error) { next(error); } });
|
||||
app.post("/api/factory-reset", async (req, res, next) => { try { if (String(req.body.confirmation || "") !== "FACTORY RESET") return res.status(400).json({ error:"Type FACTORY RESET exactly to continue." }); if (String(req.body.username || "").toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); await Promise.all([...activeServers.keys()].map(stopSite)); storage.close(); for (const directory of [sitesDir, uploadDir, caddyDir, iconsDir, logsDir, backupsDir, defaultSiteDir, certificatesRoot, path.join(dataDir,"database")]) await clearDirectoryContents(directory); storage = await openStorage(dataDir, backupsDir); sites = []; proxies = []; users = []; redirects = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites(); await syncCaddy(); res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); res.status(202).json({ ok:true }); } catch (error) { next(error); } });
|
||||
app.post("/api/factory-reset", async (req, res, next) => { try { if (String(req.body.confirmation || "") !== "FACTORY RESET") return res.status(400).json({ error:"Type FACTORY RESET exactly to continue." }); if (String(req.body.username || "").toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); await Promise.all([...activeServers.keys()].map(stopSite)); await Promise.all([...activeStreams.keys()].map(stopStream)); storage.close(); for (const directory of [sitesDir, uploadDir, caddyDir, iconsDir, logsDir, backupsDir, defaultSiteDir, certificatesRoot, path.join(dataDir,"database")]) await clearDirectoryContents(directory); storage = await openStorage(dataDir, backupsDir); sites = []; proxies = []; users = []; redirects = []; streams = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites(); await syncCaddy(); res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); res.status(202).json({ ok:true }); } catch (error) { next(error); } });
|
||||
app.use("/api/backups", (req, res, next) => req.user.role === "administrator" ? next() : res.status(403).json({ error: "Administrator access is required." }));
|
||||
app.get("/api/backups", async (req, res, next) => { try { res.json(await listBackups()); } catch (error) { next(error); } });
|
||||
app.post("/api/backups", async (req, res, next) => {
|
||||
@@ -1473,7 +1667,7 @@ app.delete("/api/backups/:filename", async (req, res, next) => {
|
||||
try { const filename = path.basename(req.params.filename); if (!filename.endsWith(".sgbackup")) return res.status(400).json({ error: "Invalid backup." }); await fsp.rm(path.join(backupsDir, filename)); recordActivity(`Backup ${filename} deleted.`); res.status(204).end(); } catch (error) { next(error); }
|
||||
});
|
||||
function humanizeGatewayActivityError(message) { const text = String(message || "Unexpected gateway error"); if (/upstream address scheme is HTTP but transport is configured for HTTP\+TLS/i.test(text)) return "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; if (/upstream address scheme is HTTPS but transport is configured for plain HTTP/i.test(text)) return "Gateway configuration rejected: HTTPS upstream requires HTTPS transport settings. Change the upstream URL or transport setting."; if (/duplicate.*address|already.*site address/i.test(text)) return "Gateway configuration rejected: This hostname or address is already used by another host. Choose a unique hostname and port."; if (/dial tcp|no such host|lookup .* no such host|upstream.*(invalid|malformed)/i.test(text)) return "Gateway configuration rejected: The upstream address could not be reached or is invalid. Check the hostname, IP address, and port."; if (/invalid hostname|host name.*invalid|malformed.*host/i.test(text)) return "Gateway configuration rejected: The hostname is not valid. Use a valid domain name without a protocol or path."; if (/unrecognized directive|unknown directive|parsing caddyfile tokens/i.test(text)) return "Gateway configuration rejected: The gateway configuration contains an unsupported or malformed directive. Check the selected host settings."; if (/certificate|tls.*(config|handshake)|no certificate/i.test(text)) return "Gateway configuration rejected: The TLS certificate configuration is invalid or unavailable. Check the certificate, key, and HTTPS settings."; return text.replace(/^Gateway configuration was rejected:\s*/i, "Gateway configuration rejected: ").replace(/\s+Details:\s+[\s\S]*$/i, ""); }
|
||||
const GATEWAY_CONFIG_ROUTE = /^\/api\/(sites|proxies|redirects|access-lists)(\/|$)/i;
|
||||
const GATEWAY_CONFIG_ROUTE = /^\/api\/(sites|proxies|redirects|streams|access-lists)(\/|$)/i;
|
||||
app.use((error, req, res, next) => {
|
||||
console.error(error);
|
||||
const rawMessage = error.message || "Something went wrong.";
|
||||
@@ -1518,6 +1712,7 @@ setInterval(() => importAccessLogsToSqlite(), 30000).unref();
|
||||
|
||||
async function shutdown() {
|
||||
await Promise.all([...activeServers.keys()].map(stopSite));
|
||||
await Promise.all([...activeStreams.keys()].map(stopStream));
|
||||
try { storage?.close(); } catch { /* Database may already be closed during restore. */ }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
+5
-3
@@ -6,9 +6,9 @@ import { DatabaseSync } from "node:sqlite";
|
||||
import AdmZip from "adm-zip";
|
||||
|
||||
export const LOCAL_INSTANCE_ID = "local";
|
||||
export const ENTITY_KINDS = ["sites", "proxies", "redirects", "access_lists", "users", "groups"];
|
||||
const legacyFiles = { sites: "sites.json", proxies: "proxies.json", redirects: "redirects.json", access_lists: "access-lists.json", users: "users.json", groups: "groups.json" };
|
||||
const entityTables = { sites: "hosted_sites", proxies: "proxy_hosts", redirects: "redirect_hosts", access_lists: "access_lists", users: "users", groups: "groups" };
|
||||
export const ENTITY_KINDS = ["sites", "proxies", "redirects", "streams", "access_lists", "users", "groups"];
|
||||
const legacyFiles = { sites: "sites.json", proxies: "proxies.json", redirects: "redirects.json", streams: "streams.json", access_lists: "access-lists.json", users: "users.json", groups: "groups.json" };
|
||||
const entityTables = { sites: "hosted_sites", proxies: "proxy_hosts", redirects: "redirect_hosts", streams: "stream_hosts", access_lists: "access_lists", users: "users", groups: "groups" };
|
||||
|
||||
function now() { return new Date().toISOString(); }
|
||||
|
||||
@@ -50,12 +50,14 @@ export async function openStorage(dataDir, backupsDir) {
|
||||
CREATE TABLE IF NOT EXISTS hosted_sites (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS proxy_hosts (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS redirect_hosts (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS stream_hosts (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS access_lists (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS groups (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, payload TEXT NOT NULL CHECK(json_valid(payload)), created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS hosted_sites_instance ON hosted_sites(instance_id);
|
||||
CREATE INDEX IF NOT EXISTS proxy_hosts_instance ON proxy_hosts(instance_id);
|
||||
CREATE INDEX IF NOT EXISTS redirect_hosts_instance ON redirect_hosts(instance_id);
|
||||
CREATE INDEX IF NOT EXISTS stream_hosts_instance ON stream_hosts(instance_id);
|
||||
CREATE INDEX IF NOT EXISTS access_lists_instance ON access_lists(instance_id);
|
||||
CREATE INDEX IF NOT EXISTS users_instance ON users(instance_id);
|
||||
CREATE INDEX IF NOT EXISTS groups_instance ON groups(instance_id);
|
||||
|
||||
Reference in New Issue
Block a user