From 419680a847f9c91f76ea3e9043b8985cfd5e00da Mon Sep 17 00:00:00 2001 From: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:34:39 +0000 Subject: [PATCH] Port-forward UX: drop tile Step 4 and misleading local 'ready' status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Element Call tile still appended a synthetic 'Step 4: Router Setup Needed' to the domain diagnostic checklist, and every port table carried a 'Sovran_SystemsOS Status' column with Ready / Not ready yet verdicts. Both were misleading: port forwarding happens on the router, which this computer cannot inspect. A local ss/firewall probe can neither prove nor disprove that forwarding works — and the LiveKit TURN relay range binds on demand, so it reported 'Not ready yet' even on a perfectly working system. - server.py: add ROUTER_FORWARD_ONLY_UNITS ({livekit.service}); skip the local probe for those units, drop the step-4 append, replace extra_ports with router_ports (no status field), and exclude router-only ports from both tile health and /api/ports/health so they can't raise false alarms. - helpers.js: new shared renderPortForwardGuideHtml() — one intro naming the internal IP, explicit instructions (same internal/external port, match the protocol, use port-range fields for 30000-40000), a colour-coded TCP / UDP / TCP+UDP badge per row, and a closing note that the only real test is loading the service from a phone on mobile data. - features.js: enable-time modal uses the shared guide and now always lists every port to forward (the old local pre-filter hid ports the user still had to open). - service-detail.js: tile port section uses the same guide; the SSH/non-domain branch keeps a small 'not open on this computer yet' hint, which is a real local fact, separate from router forwarding. - onboarding.js: step 3 router note reworded to match (same number for internal/external, notes that Element Call adds UDP ports). - domain-setup.css: styles for the protocol badges and instruction list. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 69 +++++++------ .../static/css/domain-setup.css | 52 ++++++++++ .../static/js/features.js | 59 ++--------- app/sovran_systemsos_web/static/js/helpers.js | 60 ++++++++++++ .../static/js/service-detail.js | 98 +++++-------------- app/sovran_systemsos_web/static/onboarding.js | 10 +- 6 files changed, 195 insertions(+), 153 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 936fab3..7d7028c 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -319,6 +319,14 @@ _PORTS_ELEMENT_CALLING = [ {"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]] = { # Infrastructure "caddy.service": [], @@ -2895,7 +2903,11 @@ async def api_services(): else: 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: health_port_requirements = [ {"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", []) 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, []) + router_forward_only = unit in ROUTER_FORWARD_ONLY_UNITS port_statuses: list[dict] = [] - if port_requirements: + if port_requirements and not router_forward_only: listening, allowed = await asyncio.gather( loop.run_in_executor(None, _get_listening_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, "description": p.get("description", ""), }) - extra_ports = port_statuses if unit == "livekit.service" else [] - - if needs_domain and unit == "livekit.service": - if has_domain_issues: - domain_check_steps.append({ - "step": 4, - "label": "Router Setup Needed", - "status": "skipped", - "detail": "Finish the domain steps first, then forward the Element Call ports in your router.", - }) - else: - # These checks are local-only (listening/firewall state on this computer), - # 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." - ), - }) + # Ports the user must forward in their router (no local status — see above). + router_ports = ( + [ + { + "port": str(p.get("port", "")), + "protocol": str(p.get("protocol", "TCP")), + "description": p.get("description", ""), + } + for p in port_requirements + ] + if router_forward_only + else [] + ) # Compute composite health 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, "port_requirements": port_requirements, "port_statuses": port_statuses, - "extra_ports": extra_ports, + "router_ports": router_ports, "external_ip": external_ip, "internal_ip": internal_ip, "feature": feature_entry, @@ -3362,6 +3370,11 @@ async def api_ports_health(): if not enabled: 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, []) if ports: enabled_port_requirements.append((entry.get("name", unit), unit, ports)) diff --git a/app/sovran_systemsos_web/static/css/domain-setup.css b/app/sovran_systemsos_web/static/css/domain-setup.css index 2db406e..6bcf638 100644 --- a/app/sovran_systemsos_web/static/css/domain-setup.css +++ b/app/sovran_systemsos_web/static/css/domain-setup.css @@ -172,6 +172,58 @@ domain-field-actions { 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 ───────────────────── */ .domain-nwc-warning { diff --git a/app/sovran_systemsos_web/static/js/features.js b/app/sovran_systemsos_web/static/js/features.js index 841e2ac..b212869 100644 --- a/app/sovran_systemsos_web/static/js/features.js +++ b/app/sovran_systemsos_web/static/js/features.js @@ -279,24 +279,12 @@ function openPortRequirementsModal(featureName, ports, onContinue) { : ''; function renderPortRequirements(internalIp) { - var rows = ports.map(function(p) { - return '' + escHtml(p.port) + '' + - '' + escHtml(p.protocol) + '' + - '' + escHtml(p.description) + ''; - }).join(""); - var ipPart = internalIp - ? ' to this computer’s internal IP ' + escHtml(internalIp) + '' - : " to this computer's internal IP"; - $portReqBody.innerHTML = - '

For ' + escHtml(featureName) + ' to work for people outside your home network, ' + - 'forward each port below' + ipPart + " in your router's port-forwarding settings. " + - 'Set the internal and external port to the same number.

' + - '' + - '' + - '' + rows + '' + - '
Port(s)ProtocolPurpose
' + - '

💡 You can review these ports with live status any time on the ' + escHtml(featureName) + ' tile after enabling.

' + + renderPortForwardGuideHtml(ports, { + internalIp: internalIp, + serviceName: featureName, + }) + + '

💡 This list is always available again on the ' + escHtml(featureName) + ' tile.

' + '
' + '' + continueBtn + @@ -429,39 +417,10 @@ function handleFeatureToggle(feat, newEnabled) { return; } - // Check which ports are actually closed before showing the modal - fetch("/api/ports/status", { - method: "POST", - headers: { "Content-Type": "application/json" }, - 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); - }); + // Always show the full list of ports to forward. Port forwarding happens on + // the router, which this computer cannot inspect — a local check would only + // hide ports the user still has to open. + openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck); } if (feat.id === "bitcoin-core") { diff --git a/app/sovran_systemsos_web/static/js/helpers.js b/app/sovran_systemsos_web/static/js/helpers.js index bfeb702..494990b 100644 --- a/app/sovran_systemsos_web/static/js/helpers.js +++ b/app/sovran_systemsos_web/static/js/helpers.js @@ -40,6 +40,66 @@ function linkify(str) { return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '$1'); } +// ── 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 'TCP + UDP' + + 'both required'; + } + if (isUdp) return 'UDP'; + return 'TCP'; +} + +// 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 + ? '' + escHtml(opts.internalIp) + '' + : 'this computer’s internal IP (shown as “Internal IP” at the top of the Hub dashboard)'; + + var rows = (ports || []).map(function(p) { + return '' + + '' + escHtml(p.port) + '' + + '' + portProtocolHtml(p.protocol) + '' + + '' + escHtml(p.description || "") + '' + + ''; + }).join(""); + + var forWhat = opts.serviceName + ? 'For ' + escHtml(opts.serviceName) + ' to be reachable from outside your home network, open' + : 'Open'; + + return '

' + + forWhat + ' the ports below in your router’s port forwarding settings ' + + 'and point them at ' + ipHtml + '.' + + '

' + + '' + + '' + + '' + + '' + rows + '' + + '
Port(s)ProtocolUsed for
' + + '

' + + '📱 How to confirm it worked: 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.' + + '

'; +} + function formatDuration(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index cb6557e..ad7ea37 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -514,85 +514,41 @@ async function openServiceDetailModal(unit, name, icon) { domainActionHtml + '
'; - 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 internalIp = trimmedInternalIp || ""; - var internalIpHtml = internalIp ? escHtml(internalIp) : "Could not detect"; - var forwardNote = internalIp - ? 'Forward each port below in your router to this computer’s internal IP ' + internalIpHtml + ', using the same internal and external port.' - : 'Forward each port below in your router to this computer’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 += '' + - '' + escHtml(p.port) + '' + - '' + escHtml(p.protocol) + '' + - '' + escHtml(p.description || "") + '' + - '' + statusIcon + '' + - ''; - }); html += '
' + '
Ports to Forward in Your Router
' + - '
' + forwardNote + '
' + - '' + - '' + - '' + extraRows + '' + - '
PortProtocolUsed ForSovran_SystemsOS Status
' + - '
✅ = 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).
' + + renderPortForwardGuideHtml(data.router_ports, { + internalIp: trimmedInternalIp || null, + tableClass: "svc-detail-port-table", + introClass: "svc-detail-port-note", + noteClass: "svc-detail-port-note", + }) + '
'; } } else if (data.port_statuses && data.port_statuses.length > 0) { - // Non-domain services (SSH) keep local single-port checks. - var portTableRows = ""; - data.port_statuses.forEach(function(p) { - var statusIcon, statusClass2; - 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"; - statusClass2 = "port-status-closed"; - } else { - statusIcon = "— Could not check"; - statusClass2 = "port-status-unknown"; - } - portTableRows += '' + - '' + escHtml(p.port) + '' + - '' + escHtml(p.protocol) + '' + - '' + escHtml(p.description || "") + '' + - '' + statusIcon + '' + - ''; + // Non-domain services (e.g. SSH): show what to forward, plus a short + // note if the service isn't actually listening on this computer yet. + var localInternalIp = data.internal_ip ? String(data.internal_ip).trim() : ""; + var notListening = data.port_statuses.filter(function(p) { + return p.status === "closed"; }); + var localNote = notListening.length + ? '
' + + '⚠ 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.' + + '
' + : ""; html += '
' + - '
Port Requirements
' + - '
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.
' + - '' + - '' + - '' + portTableRows + '' + - '
PortProtocolUsed ForSovran_SystemsOS Status
' + + '
Ports to Forward in Your Router
' + + '
Only needed if you want to reach this service from outside your home network. On your local network it already works without any router changes.
' + + renderPortForwardGuideHtml(data.port_statuses, { + internalIp: localInternalIp || null, + tableClass: "svc-detail-port-table", + introClass: "svc-detail-port-note", + noteClass: "svc-detail-port-note", + }) + + localNote + '
'; } diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js index 525f4bb..998d827 100644 --- a/app/sovran_systemsos_web/static/onboarding.js +++ b/app/sovran_systemsos_web/static/onboarding.js @@ -383,10 +383,12 @@ async function loadStep3() { ? ' to this computer’s internal IP ' + escHtml(internalIp) + '' : ' to this computer’s internal IP'; html += '
' - + '🔌 One router task: forward ports 80 and 443 (TCP)' - + routerIpPart + '. They are required for HTTPS and SSL certificates — without them your services cannot be reached from outside your home network. ' - + 'Forward port 22 (TCP) too if you want remote SSH access. ' - + 'Element Call needs a few extra ports, but you’ll be shown those when you enable it.' + + '🔌 One router task: in your router’s port forwarding settings, forward ' + + 'port 80 (TCP) and port 443 (TCP)' + + routerIpPart + '. Use the same number for the internal and external port. ' + + 'These are required for HTTPS and SSL certificates — without them your services cannot be reached from outside your home network. ' + + 'Add port 22 (TCP) as well if you want remote SSH access. ' + + 'Element Call needs a few extra ports (some UDP), and you’ll be shown exactly which when you enable it.' + '
'; relevantDomains.forEach(function(d) {