From b2fb7035e06f4458940dde91c805cd1db9edb8e1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 3 Apr 2026 17:03:42 +0000
Subject: [PATCH] Add network port requirements UI, install notification, and
tile port info
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/54981eb1-b1c5-4e1a-b587-730f41c59e01
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
---
app/sovran_systemsos_web/server.py | 50 +++++++++
app/sovran_systemsos_web/static/app.js | 90 ++++++++++++++-
app/sovran_systemsos_web/static/style.css | 79 +++++++++++++
app/sovran_systemsos_web/templates/index.html | 11 ++
iso/installer.py | 105 +++++++++++++++++-
5 files changed, 332 insertions(+), 3 deletions(-)
diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py
index 6844370..da23ac3 100644
--- a/app/sovran_systemsos_web/server.py
+++ b/app/sovran_systemsos_web/server.py
@@ -83,6 +83,7 @@ FEATURE_REGISTRY = [
"needs_ddns": False,
"extra_fields": [],
"conflicts_with": [],
+ "port_requirements": [],
},
{
"id": "haven",
@@ -102,6 +103,8 @@ FEATURE_REGISTRY = [
},
],
"conflicts_with": [],
+ # Haven uses only 80/443, already covered by the main install alert
+ "port_requirements": [],
},
{
"id": "element-calling",
@@ -114,6 +117,15 @@ FEATURE_REGISTRY = [
"extra_fields": [],
"conflicts_with": [],
"requires": ["matrix_domain"],
+ "port_requirements": [
+ {"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
+ {"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"},
+ {"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
+ {"port": "7882-7894", "protocol": "UDP", "description": "LiveKit media streams"},
+ {"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
+ {"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
+ {"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
+ ],
},
{
"id": "mempool",
@@ -125,6 +137,7 @@ FEATURE_REGISTRY = [
"needs_ddns": False,
"extra_fields": [],
"conflicts_with": [],
+ "port_requirements": [],
},
{
"id": "bip110",
@@ -136,6 +149,7 @@ FEATURE_REGISTRY = [
"needs_ddns": False,
"extra_fields": [],
"conflicts_with": ["bitcoin-core"],
+ "port_requirements": [],
},
{
"id": "bitcoin-core",
@@ -147,6 +161,7 @@ FEATURE_REGISTRY = [
"needs_ddns": False,
"extra_fields": [],
"conflicts_with": ["bip110"],
+ "port_requirements": [],
},
]
@@ -160,6 +175,37 @@ FEATURE_SERVICE_MAP = {
"bitcoin-core": None,
}
+# Port requirements for service tiles (keyed by unit name or icon)
+# Services using only 80/443 for domain access share the same base list.
+_PORTS_WEB = [
+ {"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
+ {"port": "443", "protocol": "TCP", "description": "HTTPS"},
+]
+_PORTS_MATRIX_FEDERATION = _PORTS_WEB + [
+ {"port": "8448", "protocol": "TCP", "description": "Matrix server-to-server federation"},
+]
+_PORTS_ELEMENT_CALLING = _PORTS_WEB + [
+ {"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
+ {"port": "7882-7894", "protocol": "UDP", "description": "LiveKit media streams"},
+ {"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
+ {"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
+ {"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
+]
+
+SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = {
+ # Infrastructure
+ "caddy.service": _PORTS_WEB,
+ # Communication
+ "matrix-synapse.service": _PORTS_MATRIX_FEDERATION,
+ "livekit.service": _PORTS_ELEMENT_CALLING,
+ # Domain-based apps (80/443)
+ "btcpayserver.service": _PORTS_WEB,
+ "vaultwarden.service": _PORTS_WEB,
+ "phpfpm-nextcloud.service": _PORTS_WEB,
+ "phpfpm-wordpress.service": _PORTS_WEB,
+ "haven-relay.service": _PORTS_WEB,
+}
+
# For features that share a unit, disambiguate by icon field
FEATURE_ICON_MAP = {
"bip110": "bip110",
@@ -689,6 +735,8 @@ async def api_services():
creds = entry.get("credentials", [])
has_credentials = len(creds) > 0
+ port_requirements = SERVICE_PORT_REQUIREMENTS.get(unit, [])
+
return {
"name": entry.get("name", ""),
"unit": unit,
@@ -698,6 +746,7 @@ async def api_services():
"category": entry.get("category", "other"),
"status": status,
"has_credentials": has_credentials,
+ "port_requirements": port_requirements,
}
results = await asyncio.gather(*[get_status(s) for s in services])
@@ -910,6 +959,7 @@ async def api_features():
"needs_ddns": feat.get("needs_ddns", False),
"extra_fields": extra_fields,
"conflicts_with": feat.get("conflicts_with", []),
+ "port_requirements": feat.get("port_requirements", []),
}
if "requires" in feat:
entry["requires"] = feat["requires"]
diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js
index 09e6f89..7308f57 100644
--- a/app/sovran_systemsos_web/static/app.js
+++ b/app/sovran_systemsos_web/static/app.js
@@ -113,6 +113,11 @@ const $featureConfirmOk = document.getElementById("feature-confirm-ok-btn")
const $featureConfirmCancel = document.getElementById("feature-confirm-cancel-btn");
const $featureConfirmClose = document.getElementById("feature-confirm-close-btn");
+// Port Requirements modal
+const $portReqModal = document.getElementById("port-requirements-modal");
+const $portReqBody = document.getElementById("port-req-body");
+const $portReqClose = document.getElementById("port-req-close-btn");
+
// ── Helpers ───────────────────────────────────────────────────────
function tileId(svc) { return svc.unit + "::" + svc.name; }
@@ -218,7 +223,16 @@ function buildTile(svc) {
}
var infoBtn = hasCreds ? 'i ' : "";
- tile.innerHTML = infoBtn + '
?
' + escHtml(svc.name) + '
' + st + '
';
+
+ // Port requirements badge
+ 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(', ') + '
';
+ }
+
+ tile.innerHTML = infoBtn + '?
' + escHtml(svc.name) + '
' + st + '
' + portsHtml;
var infoBtnEl = tile.querySelector(".tile-info-btn");
if (infoBtnEl) {
@@ -227,6 +241,16 @@ function buildTile(svc) {
openCredsModal(svc.unit, svc.name);
});
}
+
+ var portsEl = tile.querySelector(".tile-ports");
+ if (portsEl) {
+ portsEl.style.cursor = "pointer";
+ portsEl.addEventListener("click", function(e) {
+ e.stopPropagation();
+ openPortRequirementsModal(svc.name, ports, null);
+ });
+ }
+
return tile;
}
@@ -883,6 +907,58 @@ function closeDomainSetupModal() {
if ($domainSetupModal) $domainSetupModal.classList.remove("open");
}
+// ── Port Requirements modal ───────────────────────────────────────
+
+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
+ ? 'I Understand — Continue '
+ : '';
+
+ $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.
' +
+ '' +
+ 'Dismiss ' +
+ continueBtn +
+ '
';
+
+ document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
+ closePortRequirementsModal();
+ });
+
+ if (onContinue) {
+ document.getElementById("port-req-continue-btn").addEventListener("click", function() {
+ closePortRequirementsModal();
+ onContinue();
+ });
+ }
+
+ $portReqModal.classList.add("open");
+}
+
+function closePortRequirementsModal() {
+ if ($portReqModal) $portReqModal.classList.remove("open");
+}
+
+if ($portReqClose) {
+ $portReqClose.addEventListener("click", closePortRequirementsModal);
+}
+
// ── Feature toggle logic ──────────────────────────────────────────
async function performFeatureToggle(featId, enabled, extra) {
@@ -935,7 +1011,7 @@ function handleFeatureToggle(feat, newEnabled) {
});
}
- function proceedAfterConflictCheck() {
+ function proceedAfterPortCheck() {
// Check SSL email first
if (!_featuresData || !_featuresData.ssl_email_configured) {
if (feat.needs_domain) {
@@ -967,6 +1043,16 @@ function handleFeatureToggle(feat, newEnabled) {
performFeatureToggle(feat.id, true, {});
}
+ function proceedAfterConflictCheck() {
+ // Show port requirements notification if the feature has extra port needs
+ var ports = feat.port_requirements || [];
+ if (ports.length > 0) {
+ openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
+ } else {
+ proceedAfterPortCheck();
+ }
+ }
+
if (conflictNames.length > 0) {
openFeatureConfirm(
"This will disable " + conflictNames.join(", ") + ". Continue?",
diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css
index a65ffa4..05f4c23 100644
--- a/app/sovran_systemsos_web/static/style.css
+++ b/app/sovran_systemsos_web/static/style.css
@@ -1311,3 +1311,82 @@ button.btn-reboot:hover:not(:disabled) {
margin: 0 12px;
}
}
+
+/* ── Tile: Port Requirements badge ──────────────────────────────── */
+
+.tile-ports {
+ margin-top: 6px;
+ font-size: 0.7rem;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: flex-start;
+ gap: 4px;
+ line-height: 1.4;
+ flex-wrap: wrap;
+}
+
+.tile-ports:hover {
+ color: var(--accent-color);
+}
+
+.tile-ports-icon {
+ flex-shrink: 0;
+}
+
+.tile-ports-label {
+ word-break: break-word;
+}
+
+/* ── Port Requirements Modal ────────────────────────────────────── */
+
+.port-req-intro {
+ font-size: 0.9rem;
+ color: var(--text-primary);
+ margin-bottom: 14px;
+ line-height: 1.5;
+}
+
+.port-req-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+ margin-bottom: 14px;
+}
+
+.port-req-table thead th {
+ text-align: left;
+ padding: 6px 10px;
+ border-bottom: 1px solid var(--border-color);
+ color: var(--text-secondary);
+ font-weight: 600;
+}
+
+.port-req-table tbody tr:nth-child(even) {
+ background-color: rgba(255,255,255,0.03);
+}
+
+.port-req-port {
+ padding: 5px 10px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 0.82rem;
+ color: var(--accent-color);
+ white-space: nowrap;
+}
+
+.port-req-proto {
+ padding: 5px 10px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.port-req-desc {
+ padding: 5px 10px;
+ color: var(--text-primary);
+}
+
+.port-req-hint {
+ font-size: 0.78rem;
+ color: var(--text-dim);
+ line-height: 1.5;
+ margin-bottom: 14px;
+}
diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html
index f0e9bd0..618528e 100644
--- a/app/sovran_systemsos_web/templates/index.html
+++ b/app/sovran_systemsos_web/templates/index.html
@@ -129,6 +129,17 @@
+
+
+
diff --git a/iso/installer.py b/iso/installer.py
index 1e10b84..cf73713 100644
--- a/iso/installer.py
+++ b/iso/installer.py
@@ -341,7 +341,110 @@ class InstallerWindow(Adw.ApplicationWindow):
if radio.get_active():
self.role = radio.get_name()
break
- self.push_disk_confirm()
+ self.push_port_requirements()
+
+ # ── Step 1b: Port Requirements Notice ─────────────────────────────────
+
+ def push_port_requirements(self):
+ """Inform the user about required router/firewall ports before install."""
+ outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+
+ # Warning banner
+ banner = Adw.Banner()
+ banner.set_title(
+ "⚠ You must open these ports on your home router / WAN firewall"
+ )
+ banner.set_revealed(True)
+ banner.set_margin_top(16)
+ banner.set_margin_start(40)
+ banner.set_margin_end(40)
+ outer.append(banner)
+
+ 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. "
+ "Services like Element Video/Audio Calling and Matrix Federation "
+ "will not work for clients outside your LAN unless these ports are open."
+ " "
+ )
+ intro.set_wrap(True)
+ intro.set_justify(Gtk.Justification.FILL)
+ intro.set_margin_top(14)
+ intro.set_margin_start(40)
+ intro.set_margin_end(40)
+ outer.append(intro)
+
+ sw = Gtk.ScrolledWindow()
+ sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
+ sw.set_vexpand(True)
+ sw.set_margin_start(40)
+ sw.set_margin_end(40)
+ sw.set_margin_top(12)
+ sw.set_margin_bottom(8)
+
+ ports_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
+
+ port_sections = [
+ (
+ "🌐 Web / HTTPS (all domain-based services)",
+ [("80", "TCP", "HTTP (redirects to HTTPS)"),
+ ("443", "TCP", "HTTPS")],
+ ),
+ (
+ "💬 Matrix Federation (Matrix-Synapse)",
+ [("8448", "TCP", "Server-to-server federation")],
+ ),
+ (
+ "🎥 Element Video & Audio Calling (LiveKit / Element-call)",
+ [("7881", "TCP", "LiveKit WebRTC signalling"),
+ ("7882-7894", "UDP", "LiveKit media streams"),
+ ("5349", "TCP", "TURN over TLS"),
+ ("3478", "UDP", "TURN (STUN / relay)"),
+ ("30000-40000", "TCP/UDP", "TURN relay (WebRTC media)")],
+ ),
+ (
+ "🖥 Remote SSH (optional — only if you want WAN SSH access)",
+ [("22", "TCP", "SSH")],
+ ),
+ ]
+
+ for section_title, rows in port_sections:
+ group = Adw.PreferencesGroup()
+ group.set_title(section_title)
+
+ for port, proto, desc in rows:
+ row = Adw.ActionRow()
+ row.set_title(f"Port {port} ({proto})")
+ row.set_subtitle(desc)
+ group.add(row)
+
+ ports_box.append(group)
+
+ 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."
+ " "
+ )
+ note.set_wrap(True)
+ note.set_justify(Gtk.Justification.FILL)
+ note.set_margin_top(8)
+ ports_box.append(note)
+
+ sw.set_child(ports_box)
+ outer.append(sw)
+
+ outer.append(self.nav_row(
+ back_label="← Back",
+ back_cb=lambda b: self.nav.pop(),
+ next_label="I Understand →",
+ next_cb=lambda b: self.push_disk_confirm(),
+ ))
+
+ self.push_page("Network Port Requirements", outer, show_back=True)
# ── Step 2: Disk Confirm ───────────────────────────────────────────────