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(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.
' + - '| Port(s) | Protocol | Purpose |
|---|
💡 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.
' + '' + 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 '' + + forWhat + ' the ports below in your router’s port forwarding settings ' + + 'and point them at ' + ipHtml + '.' + + '
' + + '| Port(s) | Protocol | Used 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 + '' + 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 += '| Port | Protocol | Used For | Sovran_SystemsOS Status |
|---|
| Port | Protocol | Used For | Sovran_SystemsOS Status |
|---|