Merge pull request #349 from naturallaw777/arena/019faad3-sovran-systemsos

Port-forward UX: remove tile Step 4 and misleading local port 'ready' status
This commit is contained in:
Sovran Systems
2026-07-28 17:36:45 -05:00
committed by GitHub
6 changed files with 195 additions and 153 deletions
+41 -28
View File
@@ -319,6 +319,14 @@ _PORTS_ELEMENT_CALLING = [
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"}, {"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
] ]
# Units whose port requirements exist purely so the user can forward them in
# their router. Whether those ports actually work can only be judged from
# OUTSIDE the network, so we never show a local "ready/not ready" verdict for
# them and they never affect tile health — we just tell the user what to
# forward. (E.g. the LiveKit TURN relay range is bound on demand, so a local
# `ss` check reports "closed" even on a perfectly working system.)
ROUTER_FORWARD_ONLY_UNITS: set[str] = {"livekit.service"}
SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = { SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = {
# Infrastructure # Infrastructure
"caddy.service": [], "caddy.service": [],
@@ -2895,7 +2903,11 @@ async def api_services():
else: else:
domain_reachability = "unreachable" domain_reachability = "unreachable"
health_port_requirements = list(port_requirements) # Router-forwarded ports can only be verified from outside the network,
# so they must not drive local health.
health_port_requirements = (
[] if unit in ROUTER_FORWARD_ONLY_UNITS else list(port_requirements)
)
if needs_domain: if needs_domain:
health_port_requirements = [ health_port_requirements = [
{"port": "80", "protocol": "TCP"}, {"port": "80", "protocol": "TCP"},
@@ -3136,10 +3148,17 @@ async def api_service_detail(unit: str, icon: str | None = None):
domain_check_steps = domain_eval.get("domain_check_steps", []) domain_check_steps = domain_eval.get("domain_check_steps", [])
has_domain_issues = bool(domain_eval.get("has_issues")) has_domain_issues = bool(domain_eval.get("has_issues"))
# Port requirements and statuses # Port requirements and statuses.
#
# For router-forward-only units we deliberately skip the local
# listening/firewall probe: those ports live on the *router*, and a local
# probe can neither prove nor disprove that forwarding works. Reporting a
# local verdict there confused users more than it helped, so the UI just
# lists what to forward.
port_requirements = SERVICE_PORT_REQUIREMENTS.get(unit, []) port_requirements = SERVICE_PORT_REQUIREMENTS.get(unit, [])
router_forward_only = unit in ROUTER_FORWARD_ONLY_UNITS
port_statuses: list[dict] = [] port_statuses: list[dict] = []
if port_requirements: if port_requirements and not router_forward_only:
listening, allowed = await asyncio.gather( listening, allowed = await asyncio.gather(
loop.run_in_executor(None, _get_listening_ports), loop.run_in_executor(None, _get_listening_ports),
loop.run_in_executor(None, _get_firewall_allowed_ports), loop.run_in_executor(None, _get_firewall_allowed_ports),
@@ -3154,30 +3173,19 @@ async def api_service_detail(unit: str, icon: str | None = None):
"status": ps, "status": ps,
"description": p.get("description", ""), "description": p.get("description", ""),
}) })
extra_ports = port_statuses if unit == "livekit.service" else [] # Ports the user must forward in their router (no local status — see above).
router_ports = (
if needs_domain and unit == "livekit.service": [
if has_domain_issues: {
domain_check_steps.append({ "port": str(p.get("port", "")),
"step": 4, "protocol": str(p.get("protocol", "TCP")),
"label": "Router Setup Needed", "description": p.get("description", ""),
"status": "skipped", }
"detail": "Finish the domain steps first, then forward the Element Call ports in your router.", for p in port_requirements
}) ]
else: if router_forward_only
# These checks are local-only (listening/firewall state on this computer), else []
# not an outside-in verification of router/NAT forwarding. )
all_local_ready = all(p["status"] != "closed" for p in extra_ports)
domain_check_steps.append({
"step": 4,
"label": "Router Setup Needed" if all_local_ready else "Sovran_SystemsOS Port Setup Needed",
"status": "warning" if all_local_ready else "error",
"detail": (
"Sovran_SystemsOS is ready to use these ports on this computer. Now forward them in your router so Element Call can work from outside your home network."
if all_local_ready
else "Sovran_SystemsOS is not ready to use all required Element Call ports on this computer yet. Fix the ports marked “Not ready yet” below, then forward them in your router."
),
})
# Compute composite health # Compute composite health
sync_progress: float | None = None sync_progress: float | None = None
@@ -3264,7 +3272,7 @@ async def api_service_detail(unit: str, icon: str | None = None):
"domain_check_steps": domain_check_steps, "domain_check_steps": domain_check_steps,
"port_requirements": port_requirements, "port_requirements": port_requirements,
"port_statuses": port_statuses, "port_statuses": port_statuses,
"extra_ports": extra_ports, "router_ports": router_ports,
"external_ip": external_ip, "external_ip": external_ip,
"internal_ip": internal_ip, "internal_ip": internal_ip,
"feature": feature_entry, "feature": feature_entry,
@@ -3362,6 +3370,11 @@ async def api_ports_health():
if not enabled: if not enabled:
continue continue
# Router-forwarded ports are not locally verifiable — excluded from
# aggregate health so they can't raise a false alarm.
if unit in ROUTER_FORWARD_ONLY_UNITS:
continue
ports = SERVICE_PORT_REQUIREMENTS.get(unit, []) ports = SERVICE_PORT_REQUIREMENTS.get(unit, [])
if ports: if ports:
enabled_port_requirements.append((entry.get("name", unit), unit, ports)) enabled_port_requirements.append((entry.get("name", unit), unit, ports))
@@ -172,6 +172,58 @@ domain-field-actions {
font-weight: 600; font-weight: 600;
} }
/* ── Port-forwarding instructions ─────────────────────────────────── */
.port-req-steps {
margin: 4px 0 12px;
padding-left: 20px;
font-size: 0.84rem;
color: var(--text-secondary);
line-height: 1.65;
}
.port-req-steps li {
margin-bottom: 5px;
}
.port-proto-badge {
display: inline-block;
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: 5px;
white-space: nowrap;
}
.port-proto-badge--tcp {
color: #6dbf8b;
background: rgba(109, 191, 139, 0.12);
border: 1px solid rgba(109, 191, 139, 0.4);
}
.port-proto-badge--udp {
color: #6aa9e0;
background: rgba(106, 169, 224, 0.12);
border: 1px solid rgba(106, 169, 224, 0.4);
}
.port-proto-badge--both {
color: #e5a50a;
background: rgba(229, 165, 10, 0.12);
border: 1px solid rgba(229, 165, 10, 0.45);
}
.port-proto-note {
display: block;
margin-top: 3px;
font-size: 0.68rem;
color: var(--text-dim);
text-transform: none;
letter-spacing: 0;
}
/* ── Wallet Connections unique-hostname warning ───────────────────── */ /* ── Wallet Connections unique-hostname warning ───────────────────── */
.domain-nwc-warning { .domain-nwc-warning {
+9 -50
View File
@@ -279,24 +279,12 @@ function openPortRequirementsModal(featureName, ports, onContinue) {
: ''; : '';
function renderPortRequirements(internalIp) { function renderPortRequirements(internalIp) {
var rows = ports.map(function(p) {
return '<tr><td class="port-req-port">' + escHtml(p.port) + '</td>' +
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
}).join("");
var ipPart = internalIp
? ' to this computer&rsquo;s internal IP <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code>'
: " to this computer's internal IP";
$portReqBody.innerHTML = $portReqBody.innerHTML =
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + '</strong> to work for people outside your home network, ' + renderPortForwardGuideHtml(ports, {
'forward each port below' + ipPart + " in your router's port-forwarding settings. " + internalIp: internalIp,
'Set the internal and external port to the same number.</p>' + serviceName: featureName,
'<table class="port-req-table">' + }) +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' + '<p class="port-req-hint">💡 This list is always available again on the <strong>' + escHtml(featureName) + '</strong> tile.</p>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'<p class="port-req-hint">💡 You can review these ports with live status any time on the <strong>' + escHtml(featureName) + '</strong> tile after enabling.</p>' +
'<div class="domain-field-actions">' + '<div class="domain-field-actions">' +
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' + '<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
continueBtn + continueBtn +
@@ -429,39 +417,10 @@ function handleFeatureToggle(feat, newEnabled) {
return; return;
} }
// Check which ports are actually closed before showing the modal // Always show the full list of ports to forward. Port forwarding happens on
fetch("/api/ports/status", { // the router, which this computer cannot inspect — a local check would only
method: "POST", // hide ports the user still has to open.
headers: { "Content-Type": "application/json" }, openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
body: JSON.stringify({ ports: ports }),
})
.then(function(r) {
if (!r.ok) throw new Error("Port status request failed: " + r.status);
return r.json();
})
.then(function(data) {
var portStatuses = {};
(data.ports || []).forEach(function(p) {
portStatuses[p.port + "/" + p.protocol] = p.status;
});
var closedPorts = ports.filter(function(p) {
var key = p.port + "/" + p.protocol;
var status = portStatuses[key] || "unknown";
return status !== "listening" && status !== "firewall_open";
});
if (closedPorts.length === 0) {
proceedAfterPortCheck();
} else {
openPortRequirementsModal(feat.name, closedPorts, proceedAfterPortCheck);
}
})
.catch(function(err) {
console.warn("Failed to fetch port status for feature enable flow:", err);
// Safe fallback if status check fails
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
});
} }
if (feat.id === "bitcoin-core") { if (feat.id === "bitcoin-core") {
@@ -40,6 +40,66 @@ function linkify(str) {
return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="creds-link">$1</a>'); return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="creds-link">$1</a>');
} }
// ── Router port-forwarding guide ──────────────────────────────────
// Whether a port is truly reachable can only be judged from OUTSIDE the
// network, so we never show a local "ready" verdict here. We only tell the
// user exactly what to enter in their router.
// Render the protocol cell so TCP / UDP / both is unmistakable.
function portProtocolHtml(protocol) {
var p = String(protocol || "TCP").toUpperCase();
var isTcp = p.indexOf("TCP") !== -1;
var isUdp = p.indexOf("UDP") !== -1;
if (isTcp && isUdp) {
return '<span class="port-proto-badge port-proto-badge--both">TCP + UDP</span>' +
'<span class="port-proto-note">both required</span>';
}
if (isUdp) return '<span class="port-proto-badge port-proto-badge--udp">UDP</span>';
return '<span class="port-proto-badge port-proto-badge--tcp">TCP</span>';
}
// ports: [{ port, protocol, description }]
// opts: { internalIp, serviceName, tableClass, introClass, noteClass }
function renderPortForwardGuideHtml(ports, opts) {
opts = opts || {};
var tableClass = opts.tableClass || "port-req-table";
var introClass = opts.introClass || "port-req-intro";
var noteClass = opts.noteClass || "port-req-hint";
var ipHtml = opts.internalIp
? '<code class="port-req-internal-ip">' + escHtml(opts.internalIp) + '</code>'
: 'this computer&rsquo;s <strong>internal IP</strong> (shown as &ldquo;Internal IP&rdquo; at the top of the Hub dashboard)';
var rows = (ports || []).map(function(p) {
return '<tr>' +
'<td class="port-req-port">' + escHtml(p.port) + '</td>' +
'<td class="port-req-proto">' + portProtocolHtml(p.protocol) + '</td>' +
'<td class="port-req-desc">' + escHtml(p.description || "") + '</td>' +
'</tr>';
}).join("");
var forWhat = opts.serviceName
? 'For <strong>' + escHtml(opts.serviceName) + '</strong> to be reachable from outside your home network, open'
: 'Open';
return '<p class="' + introClass + '">' +
forWhat + ' the ports below in your router&rsquo;s <strong>port forwarding</strong> settings ' +
'and point them at ' + ipHtml + '.' +
'</p>' +
'<ul class="port-req-steps">' +
'<li>Set the <strong>internal (private) port</strong> and the <strong>external (public) port</strong> to the <strong>same number</strong>.</li>' +
'<li>Match the <strong>protocol</strong> exactly — a rule set to TCP will not pass UDP traffic. Where the table says <strong>TCP + UDP</strong>, create both rules (or pick &ldquo;Both&rdquo;/&ldquo;TCP/UDP&rdquo; if your router offers it).</li>' +
'<li>For a range such as <strong>30000-40000</strong>, use your router&rsquo;s port-range fields — start 30000, end 40000 — rather than one rule per port.</li>' +
'</ul>' +
'<table class="' + tableClass + '">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'<p class="' + noteClass + '">' +
'📱 <strong>How to confirm it worked:</strong> forwarding happens on your router, so it can only be verified from outside your network. ' +
'Turn Wi-Fi off on your phone and open the service over mobile data — if it loads, your ports are open.' +
'</p>';
}
function formatDuration(seconds) { function formatDuration(seconds) {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
@@ -514,85 +514,41 @@ async function openServiceDetailModal(unit, name, icon) {
domainActionHtml + domainActionHtml +
'</div>'; '</div>';
if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) { if (data.router_ports && data.router_ports.length > 0) {
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : ""; var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
var internalIp = trimmedInternalIp || "";
var internalIpHtml = internalIp ? escHtml(internalIp) : "Could not detect";
var forwardNote = internalIp
? 'Forward each port below in your router to this computer&rsquo;s internal IP <code class="port-req-internal-ip">' + internalIpHtml + '</code>, using the same internal and external port.'
: 'Forward each port below in your router to this computer&rsquo;s internal IP, using the same internal and external port.';
var domainConfigured = !!(data.domain && String(data.domain).trim());
var extraRows = "";
data.extra_ports.forEach(function(p) {
var statusIcon, statusClass2;
if (!effectiveEnabled) {
statusIcon = "⚠ Configure Element Call first";
statusClass2 = "port-status-open";
} else if (!domainConfigured) {
statusIcon = "⚠ Configure domain first";
statusClass2 = "port-status-open";
} else if (p.status === "listening") {
statusIcon = "✅ Ready";
statusClass2 = "port-status-listening";
} else if (p.status === "firewall_open") {
statusIcon = "✅ Ready";
statusClass2 = "port-status-open";
} else if (p.status === "closed") {
statusIcon = "❌ Not ready yet";
statusClass2 = "port-status-closed";
} else {
statusIcon = "— Could not check";
statusClass2 = "port-status-unknown";
}
extraRows += '<tr>' +
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="svc-detail-port-table-desc">' + escHtml(p.description || "") + '</td>' +
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
'</tr>';
});
html += '<div class="svc-detail-section">' + html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' + '<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
'<div class="svc-detail-port-note">' + forwardNote + '</div>' + renderPortForwardGuideHtml(data.router_ports, {
'<table class="svc-detail-port-table">' + internalIp: trimmedInternalIp || null,
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' + tableClass: "svc-detail-port-table",
'<tbody>' + extraRows + '</tbody>' + introClass: "svc-detail-port-note",
'</table>' + noteClass: "svc-detail-port-note",
'<div class="svc-detail-port-note">✅ = Sovran_SystemsOS is ready on this computer. Router-side forwarding itself can only be verified from outside your network (e.g. a phone on mobile data).</div>' + }) +
'</div>'; '</div>';
} }
} else if (data.port_statuses && data.port_statuses.length > 0) { } else if (data.port_statuses && data.port_statuses.length > 0) {
// Non-domain services (SSH) keep local single-port checks. // Non-domain services (e.g. SSH): show what to forward, plus a short
var portTableRows = ""; // note if the service isn't actually listening on this computer yet.
data.port_statuses.forEach(function(p) { var localInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
var statusIcon, statusClass2; var notListening = data.port_statuses.filter(function(p) {
if (p.status === "listening") { return p.status === "closed";
statusIcon = "✅ Ready";
statusClass2 = "port-status-listening";
} else if (p.status === "firewall_open") {
statusIcon = "✅ Ready";
statusClass2 = "port-status-open";
} else if (p.status === "closed") {
statusIcon = "❌ Not ready";
statusClass2 = "port-status-closed";
} else {
statusIcon = "— Could not check";
statusClass2 = "port-status-unknown";
}
portTableRows += '<tr>' +
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="svc-detail-port-table-desc">' + escHtml(p.description || "") + '</td>' +
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
'</tr>';
}); });
var localNote = notListening.length
? '<div class="svc-detail-port-note port-status-closed">' +
'⚠ Port ' + escHtml(notListening.map(function(p) { return p.port; }).join(", ")) +
' is not open on this computer yet — enable the service below first, then forward it in your router.' +
'</div>'
: "";
html += '<div class="svc-detail-section">' + html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Port Requirements</div>' + '<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
'<div class="svc-detail-port-note">This shows whether Sovran_SystemsOS is ready to use this port on this computer. If you need access from outside your home network, forward this port in your router.</div>' + '<div class="svc-detail-port-note">Only needed if you want to reach this service from <strong>outside</strong> your home network. On your local network it already works without any router changes.</div>' +
'<table class="svc-detail-port-table">' + renderPortForwardGuideHtml(data.port_statuses, {
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' + internalIp: localInternalIp || null,
'<tbody>' + portTableRows + '</tbody>' + tableClass: "svc-detail-port-table",
'</table>' + introClass: "svc-detail-port-note",
noteClass: "svc-detail-port-note",
}) +
localNote +
'</div>'; '</div>';
} }
@@ -383,10 +383,12 @@ async function loadStep3() {
? ' to this computer&rsquo;s internal IP <strong>' + escHtml(internalIp) + '</strong>' ? ' to this computer&rsquo;s internal IP <strong>' + escHtml(internalIp) + '</strong>'
: ' to this computer&rsquo;s internal IP'; : ' to this computer&rsquo;s internal IP';
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">' html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ '🔌 <strong>One router task:</strong> forward ports <strong>80</strong> and <strong>443</strong> (TCP)' + '🔌 <strong>One router task:</strong> in your router&rsquo;s <strong>port forwarding</strong> settings, forward '
+ routerIpPart + '. They are required for HTTPS and SSL certificates — without them your services cannot be reached from outside your home network. ' + 'port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>'
+ 'Forward port <strong>22</strong> (TCP) too if you want remote SSH access. ' + routerIpPart + '. Use the <strong>same number for the internal and external port</strong>. '
+ 'Element Call needs a few extra ports, but you&rsquo;ll be shown those when you enable it.' + 'These are required for HTTPS and SSL certificates — without them your services cannot be reached from outside your home network. '
+ 'Add port <strong>22 (TCP)</strong> as well if you want remote SSH access. '
+ 'Element Call needs a few extra ports (some UDP), and you&rsquo;ll be shown exactly which when you enable it.'
+ '</div>'; + '</div>';
relevantDomains.forEach(function(d) { relevantDomains.forEach(function(d) {