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) + '
' + 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:
' +
- '
' +
- '
Port(s)
Protocol
Purpose
' +
- '' + rows + '' +
- '
' +
- '
ℹ 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.
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 +
+ '
' +
+ '
Port(s)
Protocol
Purpose
Status
' +
+ '' + rows + '' +
+ '
' +
+ '
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 '
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.
' +
+ '
' +
+ '
Port(s)
Protocol
Purpose
' +
+ '' + rows + '' +
+ '
' +
+ '
ℹ 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