From df5ad3afe2d5f5d0346835b9caa0828768ef6601 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:29:02 +0000 Subject: [PATCH] Add dynamic port status detection and improved port forwarding instructions Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/cd52f6a2-250b-49e3-8558-aa2ae7512d1b Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 164 ++++++++++++++++++++ app/sovran_systemsos_web/static/app.js | 173 ++++++++++++++++++---- app/sovran_systemsos_web/static/style.css | 54 +++++++ iso/installer.py | 42 +++++- modules/core/sovran-manage-domains.nix | 84 +++++++++++ 5 files changed, 479 insertions(+), 38 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index da23ac3..bbc79b8 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -354,6 +354,142 @@ def _get_external_ip() -> str: return "unavailable" +# ── Port status helpers (local-only, no external calls) ────────── + +def _get_listening_ports() -> dict[str, set[int]]: + """Return sets of TCP and UDP ports that have services actively listening. + + Uses ``ss -tlnp`` for TCP and ``ss -ulnp`` for UDP. Returns a dict with + keys ``"tcp"`` and ``"udp"`` whose values are sets of integer port numbers. + """ + result: dict[str, set[int]] = {"tcp": set(), "udp": set()} + for proto, flag in (("tcp", "-tlnp"), ("udp", "-ulnp")): + try: + proc = subprocess.run( + ["ss", flag], + capture_output=True, text=True, timeout=10, + ) + for line in proc.stdout.splitlines(): + # Column 4 is the local address:port (e.g. "0.0.0.0:443" or "[::]:443") + parts = line.split() + if len(parts) < 5: + continue + addr = parts[4] + # strip IPv6 brackets and extract port after last ":" + port_str = addr.rsplit(":", 1)[-1] + try: + result[proto].add(int(port_str)) + except ValueError: + pass + except Exception: + pass + return result + + +def _get_firewall_allowed_ports() -> dict[str, set[int]]: + """Return sets of TCP and UDP ports that the firewall allows. + + Tries ``nft list ruleset`` first (NixOS default), then falls back to + ``iptables -L -n``. Returns a dict with keys ``"tcp"`` and ``"udp"``. + """ + result: dict[str, set[int]] = {"tcp": set(), "udp": set()} + + # ── nftables ───────────────────────────────────────────────── + try: + proc = subprocess.run( + ["nft", "list", "ruleset"], + capture_output=True, text=True, timeout=10, + ) + if proc.returncode == 0: + text = proc.stdout + # Match patterns like: tcp dport 443 accept or tcp dport { 80, 443 } + for proto in ("tcp", "udp"): + for m in re.finditer( + rf'{proto}\s+dport\s+\{{?([^}};\n]+)\}}?', text + ): + raw = m.group(1) + for token in re.split(r'[\s,]+', raw): + token = token.strip() + if re.match(r'^\d+$', token): + result[proto].add(int(token)) + elif re.match(r'^(\d+)-(\d+)$', token): + lo, hi = token.split("-") + result[proto].update(range(int(lo), int(hi) + 1)) + return result + except Exception: + pass + + # ── iptables fallback ───────────────────────────────────────── + try: + proc = subprocess.run( + ["iptables", "-L", "-n"], + capture_output=True, text=True, timeout=10, + ) + if proc.returncode == 0: + for line in proc.stdout.splitlines(): + # e.g. ACCEPT tcp -- ... dpt:443 or dpts:7882:7894 + m = re.search(r'(tcp|udp).*dpts?:(\d+)(?::(\d+))?', line) + if m: + proto_match = m.group(1) + lo = int(m.group(2)) + hi = int(m.group(3)) if m.group(3) else lo + result[proto_match].update(range(lo, hi + 1)) + except Exception: + pass + + return result + + +def _port_range_to_ints(port_str: str) -> list[int]: + """Convert a port string like ``"443"``, ``"7882-7894"`` to a list of ints.""" + port_str = port_str.strip() + if re.match(r'^\d+$', port_str): + return [int(port_str)] + m = re.match(r'^(\d+)-(\d+)$', port_str) + if m: + return list(range(int(m.group(1)), int(m.group(2)) + 1)) + return [] + + +def _check_port_status( + port_str: str, + protocol: str, + listening: dict[str, set[int]], + allowed: dict[str, set[int]], +) -> str: + """Return ``"listening"``, ``"firewall_open"``, ``"closed"``, or ``"unknown"``.""" + protos = [] + p = protocol.upper() + if "TCP" in p: + protos.append("tcp") + if "UDP" in p: + protos.append("udp") + if not protos: + protos = ["tcp"] + + ports = _port_range_to_ints(port_str) + if not ports: + return "unknown" + + ports_set = set(ports) + is_listening = any( + pt in ports_set + for proto_key in protos + for pt in listening.get(proto_key, set()) + ) + is_allowed = any( + pt in allowed.get(proto_key, set()) + for proto_key in protos + for pt in ports_set + ) + + if is_listening and is_allowed: + return "listening" + if is_allowed: + return "firewall_open" + return "closed" + + # ── QR code helper ──────────────────────────────────────────────── def _generate_qr_base64(data: str) -> str | None: @@ -796,6 +932,34 @@ async def api_network(): return {"internal_ip": internal, "external_ip": external} +class PortCheckRequest(BaseModel): + ports: list[dict] + + +@app.post("/api/ports/status") +async def api_ports_status(req: PortCheckRequest): + """Check port status locally using ss and firewall rules — no external calls.""" + loop = asyncio.get_event_loop() + internal_ip, listening, allowed = await asyncio.gather( + loop.run_in_executor(None, _get_internal_ip), + loop.run_in_executor(None, _get_listening_ports), + loop.run_in_executor(None, _get_firewall_allowed_ports), + ) + + port_results = [] + for p in req.ports: + port_str = str(p.get("port", "")) + protocol = str(p.get("protocol", "TCP")) + status = _check_port_status(port_str, protocol, listening, allowed) + port_results.append({ + "port": port_str, + "protocol": protocol, + "status": status, + }) + + return {"internal_ip": internal_ip, "ports": port_results} + + @app.get("/api/updates/check") async def api_updates_check(): loop = asyncio.get_event_loop() diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 7308f57..2be9d48 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -228,8 +228,7 @@ function buildTile(svc) { var ports = svc.port_requirements || []; var portsHtml = ""; if (ports.length > 0) { - var portLabels = ports.map(function(p) { return escHtml(p.port) + ' (' + escHtml(p.protocol) + ')'; }); - portsHtml = '
🔌Ports: ' + portLabels.join(', ') + '
'; + portsHtml = '
🔌Ports: ' + ports.length + ' required
'; } tile.innerHTML = infoBtn + '' + escHtml(svc.name) + '
' + escHtml(svc.name) + '
' + st + '
' + portsHtml; @@ -249,6 +248,40 @@ function buildTile(svc) { e.stopPropagation(); openPortRequirementsModal(svc.name, ports, null); }); + + // Async: fetch port status and update badge summary + if (ports.length > 0) { + fetch("/api/ports/status", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ports: ports }), + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + var listeningCount = 0; + (data.ports || []).forEach(function(p) { + if (p.status === "listening") listeningCount++; + }); + var total = ports.length; + var labelEl = portsEl.querySelector(".tile-ports-label"); + if (labelEl) { + labelEl.classList.remove("tile-ports-label--loading"); + if (listeningCount === total) { + labelEl.className = "tile-ports-label tile-ports-all-ready"; + labelEl.textContent = "Ports: " + total + "/" + total + " ready ✓"; + } else if (listeningCount > 0) { + labelEl.className = "tile-ports-label tile-ports-partial"; + labelEl.textContent = "Ports: " + listeningCount + "/" + total + " ready"; + } else { + labelEl.className = "tile-ports-label tile-ports-none-ready"; + labelEl.textContent = "Ports: " + total + " required"; + } + } + }) + .catch(function() { + // Leave badge as-is on error + }); + } } return tile; @@ -912,43 +945,119 @@ function closeDomainSetupModal() { function openPortRequirementsModal(featureName, ports, onContinue) { if (!$portReqModal || !$portReqBody) return; - var rows = ports.map(function(p) { - return '' + escHtml(p.port) + '' + - '' + escHtml(p.protocol) + '' + - '' + escHtml(p.description) + ''; - }).join(""); - var continueBtn = onContinue ? '' : ''; + // Show loading state while fetching port status $portReqBody.innerHTML = - '

You have enabled ' + escHtml(featureName) + '. ' + - 'For it to work with clients outside your local network you must open the following ports ' + - 'on your home router / WAN firewall:

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

ℹ Consult your router manual or search "how to open ports on [router model]" ' + - 'for instructions. Features like Element Video Calling will not work for remote users until these ports are open.

' + - '
' + - '' + - continueBtn + - '
'; - - document.getElementById("port-req-dismiss-btn").addEventListener("click", function() { - closePortRequirementsModal(); - }); - - if (onContinue) { - document.getElementById("port-req-continue-btn").addEventListener("click", function() { - closePortRequirementsModal(); - onContinue(); - }); - } + '

Checking port status for ' + escHtml(featureName) + '

' + + '

Detecting which ports are open on this machine…

'; $portReqModal.classList.add("open"); + + // Fetch live port status from local system commands (no external calls) + fetch("/api/ports/status", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ports: ports }), + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + var internalIp = (data.internal_ip && data.internal_ip !== "unavailable") + ? data.internal_ip : null; + var portStatuses = {}; + (data.ports || []).forEach(function(p) { + portStatuses[p.port + "/" + p.protocol] = p.status; + }); + + var rows = ports.map(function(p) { + var key = p.port + "/" + p.protocol; + var status = portStatuses[key] || "unknown"; + var statusHtml; + if (status === "listening") { + statusHtml = '🟢 Listening'; + } else if (status === "firewall_open") { + statusHtml = '🟡 Open (idle)'; + } else if (status === "closed") { + statusHtml = '🔴 Closed'; + } else { + statusHtml = '⚪ Unknown'; + } + return '' + + '' + escHtml(p.port) + '' + + '' + escHtml(p.protocol) + '' + + '' + escHtml(p.description) + '' + + '' + statusHtml + '' + + ''; + }).join(""); + + var ipLine = internalIp + ? '

Forward each port below to this machine\'s internal IP: ' + escHtml(internalIp) + '

' + : '

Forward each port below to this machine\'s internal LAN IP in your router\'s port forwarding settings.

'; + + $portReqBody.innerHTML = + '

Port Forwarding Required

' + + '

For ' + escHtml(featureName) + ' to work with clients outside your local network, ' + + 'you must configure port forwarding in your router\'s admin panel.

' + + ipLine + + '' + + '' + + '' + rows + '' + + '
Port(s)ProtocolPurposeStatus
' + + '

How to verify: Router-side forwarding cannot be checked from inside your network. ' + + 'To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) ' + + 'or check your router\'s port forwarding page.

' + + '

ℹ Search "how to set up port forwarding on [your router model]" for step-by-step instructions.

' + + '
' + + '' + + continueBtn + + '
'; + + document.getElementById("port-req-dismiss-btn").addEventListener("click", function() { + closePortRequirementsModal(); + }); + + if (onContinue) { + document.getElementById("port-req-continue-btn").addEventListener("click", function() { + closePortRequirementsModal(); + onContinue(); + }); + } + }) + .catch(function() { + // Fallback: show static table without status column if fetch fails + var rows = ports.map(function(p) { + return '' + escHtml(p.port) + '' + + '' + escHtml(p.protocol) + '' + + '' + escHtml(p.description) + ''; + }).join(""); + + $portReqBody.innerHTML = + '

Port Forwarding Required

' + + '

For ' + escHtml(featureName) + ' to work with clients outside your local network, ' + + 'you must configure port forwarding in your router\'s admin panel and forward each port below to this machine\'s internal LAN IP.

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

ℹ Search "how to set up port forwarding on [your router model]" for step-by-step instructions.

' + + '
' + + '' + + continueBtn + + '
'; + + document.getElementById("port-req-dismiss-btn").addEventListener("click", function() { + closePortRequirementsModal(); + }); + + if (onContinue) { + document.getElementById("port-req-continue-btn").addEventListener("click", function() { + closePortRequirementsModal(); + onContinue(); + }); + } + }); } function closePortRequirementsModal() { diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 05f4c23..259fdad 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1390,3 +1390,57 @@ button.btn-reboot:hover:not(:disabled) { line-height: 1.5; margin-bottom: 14px; } + +/* ── Port status indicators ─────────────────────────────────────── */ + +.port-req-status { + padding: 5px 10px; + white-space: nowrap; + font-size: 0.82rem; +} + +.port-status-listening { + color: #a6e3a1; + font-weight: 600; +} + +.port-status-open { + color: #f9e2af; + font-weight: 600; +} + +.port-status-closed { + color: #f38ba8; + font-weight: 600; +} + +.port-status-unknown { + color: var(--text-dim); +} + +/* Internal IP highlight in port modal */ +.port-req-internal-ip { + font-family: 'JetBrains Mono', monospace; + background: rgba(137, 180, 250, 0.15); + color: var(--accent-color); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.95em; +} + +/* Tile port badge status colours */ +.tile-ports-all-ready { + color: #a6e3a1; +} + +.tile-ports-partial { + color: #f9e2af; +} + +.tile-ports-none-ready { + color: var(--text-secondary); +} + +.tile-ports-label--loading { + color: var(--text-dim); +} diff --git a/iso/installer.py b/iso/installer.py index cf73713..89d2f2e 100644 --- a/iso/installer.py +++ b/iso/installer.py @@ -349,10 +349,22 @@ class InstallerWindow(Adw.ApplicationWindow): """Inform the user about required router/firewall ports before install.""" outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + # Detect internal IP at install time + internal_ip = "this machine's LAN IP" + try: + import subprocess as _sp + _r = _sp.run(["hostname", "-I"], capture_output=True, text=True, timeout=5) + if _r.returncode == 0: + _parts = _r.stdout.strip().split() + if _parts: + internal_ip = _parts[0] + except Exception: + pass + # Warning banner banner = Adw.Banner() banner.set_title( - "⚠ You must open these ports on your home router / WAN firewall" + "⚠ Port Forwarding Setup Required — configure your router before install" ) banner.set_revealed(True) banner.set_margin_top(16) @@ -363,10 +375,12 @@ class InstallerWindow(Adw.ApplicationWindow): intro = Gtk.Label() intro.set_markup( "" - "Many Sovran_SystemsOS features require specific ports to be forwarded " - "through your router for remote access to work correctly. " + "Many Sovran_SystemsOS features require port forwarding to be configured " + "in your router's admin panel. This means telling your router to forward " + "specific ports to this machine's internal LAN IP.\n\n" "Services like Element Video/Audio Calling and Matrix Federation " - "will not work for clients outside your LAN unless these ports are open." + "will not work for clients outside your LAN unless these ports are " + "forwarded to this machine." "" ) intro.set_wrap(True) @@ -376,6 +390,17 @@ class InstallerWindow(Adw.ApplicationWindow): intro.set_margin_end(40) outer.append(intro) + ip_label = Gtk.Label() + ip_label.set_markup( + f"" + f" Forward ports to this machine's internal IP: {internal_ip}" + f"" + ) + ip_label.set_margin_top(10) + ip_label.set_margin_start(40) + ip_label.set_margin_end(40) + outer.append(ip_label) + sw = Gtk.ScrolledWindow() sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) sw.set_vexpand(True) @@ -425,8 +450,13 @@ class InstallerWindow(Adw.ApplicationWindow): note = Gtk.Label() note.set_markup( "" - "ℹ Search \"how to open ports on [your router model]\" for step-by-step instructions. " - "Most home routers have a \"Port Forwarding\" section in their admin panel." + "ℹ In your router's admin panel (usually at 192.168.1.1), find the " + "\"Port Forwarding\" section and add a rule for each port above with " + "the destination set to this machine's internal IP. " + "These ports only need to be forwarded to this specific machine — " + "this does NOT expose your entire network.\n" + "To verify forwarding is working, test from a device on a different network " + "(e.g. a phone on mobile data) or check your router's port forwarding page." "" ) note.set_wrap(True) diff --git a/modules/core/sovran-manage-domains.nix b/modules/core/sovran-manage-domains.nix index 7eb4163..9d3b15b 100644 --- a/modules/core/sovran-manage-domains.nix +++ b/modules/core/sovran-manage-domains.nix @@ -251,6 +251,48 @@ $PENDING_NJALLA" echo " DDNS script: /var/lib/njalla/njalla.sh" echo " DDNS cron: Every 15 minutes (already configured)" echo "" + + # ── Port Forwarding Reminder ────────────────────── + INTERNAL_IP=$(hostname -I 2>/dev/null | awk '{print $1}') + printf "%b%s%b\n" "$YELLOW" "══════════════════════════════════════════════" "$NC" + printf "%b ⚠ Port Forwarding Reminder%b\n" "$YELLOW" "$NC" + printf "%b%s%b\n" "$YELLOW" "══════════════════════════════════════════════" "$NC" + echo "" + echo " For your services to be reachable from the internet, you must" + echo " set up PORT FORWARDING in your router's admin panel." + echo "" + if [ -n "$INTERNAL_IP" ]; then + printf " Forward these ports to this machine's internal IP: %b%s%b\n" "$CYAN" "$INTERNAL_IP" "$NC" + else + echo " Forward these ports to this machine's internal LAN IP." + fi + echo "" + echo " 80/TCP — HTTP (redirects to HTTPS)" + echo " 443/TCP — HTTPS (all domain-based services)" + echo " 8448/TCP — Matrix federation (server-to-server)" + echo "" + echo " If you enabled Element Calling, also forward:" + echo " 7881/TCP — LiveKit WebRTC signalling" + echo " 7882-7894/UDP — LiveKit media streams" + echo " 5349/TCP — TURN over TLS" + echo " 3478/UDP — TURN (STUN/relay)" + echo " 30000-40000/TCP+UDP — TURN relay" + echo "" + echo " How: Log into your router (usually 192.168.1.1), find the" + echo " \"Port Forwarding\" section, and add rules for each port above" + if [ -n "$INTERNAL_IP" ]; then + printf " with the destination set to %b%s%b.\n" "$CYAN" "$INTERNAL_IP" "$NC" + else + echo " with the destination set to this machine's IP." + fi + echo "" + echo " These ports only need to be forwarded to this specific machine —" + echo " this does NOT expose your entire network." + echo "" + printf "%b%s%b\n" "$YELLOW" "══════════════════════════════════════════════" "$NC" + echo "" + read -p "Press Enter to continue with the rebuild..." + printf "%b%s%b\n" "$YELLOW" " Rebuilding to activate services with new domains..." "$NC" echo "" nixos-rebuild switch --flake /etc/nixos#nixos @@ -335,6 +377,48 @@ $PENDING_NJALLA" echo " All configured domains:" ${domainSummary} echo "" + + # ── Port Forwarding Reminder ────────────────────── + INTERNAL_IP=$(hostname -I 2>/dev/null | awk '{print $1}') + printf "%b%s%b\n" "$YELLOW" "══════════════════════════════════════════════" "$NC" + printf "%b ⚠ Port Forwarding Reminder%b\n" "$YELLOW" "$NC" + printf "%b%s%b\n" "$YELLOW" "══════════════════════════════════════════════" "$NC" + echo "" + echo " For your services to be reachable from the internet, you must" + echo " set up PORT FORWARDING in your router's admin panel." + echo "" + if [ -n "$INTERNAL_IP" ]; then + printf " Forward these ports to this machine's internal IP: %b%s%b\n" "$CYAN" "$INTERNAL_IP" "$NC" + else + echo " Forward these ports to this machine's internal LAN IP." + fi + echo "" + echo " 80/TCP — HTTP (redirects to HTTPS)" + echo " 443/TCP — HTTPS (all domain-based services)" + echo " 8448/TCP — Matrix federation (server-to-server)" + echo "" + echo " If you enabled Element Calling, also forward:" + echo " 7881/TCP — LiveKit WebRTC signalling" + echo " 7882-7894/UDP — LiveKit media streams" + echo " 5349/TCP — TURN over TLS" + echo " 3478/UDP — TURN (STUN/relay)" + echo " 30000-40000/TCP+UDP — TURN relay" + echo "" + echo " How: Log into your router (usually 192.168.1.1), find the" + echo " \"Port Forwarding\" section, and add rules for each port above" + if [ -n "$INTERNAL_IP" ]; then + printf " with the destination set to %b%s%b.\n" "$CYAN" "$INTERNAL_IP" "$NC" + else + echo " with the destination set to this machine's IP." + fi + echo "" + echo " These ports only need to be forwarded to this specific machine —" + echo " this does NOT expose your entire network." + echo "" + printf "%b%s%b\n" "$YELLOW" "══════════════════════════════════════════════" "$NC" + echo "" + read -p "Press Enter to continue with the rebuild..." + printf "%b%s%b\n" "$YELLOW" " Rebuilding to activate services with new domains..." "$NC" echo "" nixos-rebuild switch --impure