From a65d9774899ebcd4c64d9cdd51e0538dfe367fa4 Mon Sep 17 00:00:00 2001 From: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:38:07 +0000 Subject: [PATCH] Simplify port-forward UX (drop onboarding step 4) + run Njal.la DDNS on feature enable - Onboarding: remove the redundant/error-prone 'Router Setup' step (5 steps -> 4). A compact 80/443 (+22 SSH) note now lives inside Domain Configuration, and the Element Call ports are only shown at the moment they matter: when enabling the feature, and afterwards on the service tile. - Onboarding step 3: fix domain prefill bug (API returns {domains: {...}}), make /api/network fetch best-effort so it can never block the step. - Enable-time port modal: streamline copy (one intro + table + pointer to the tile's live status view). - Element Call tile detail: replace 5 repetitive prose blocks with 2 compact notes around the live-status port table. - Njal.la DDNS: run njalla.sh immediately when a DDNS-backed feature is enabled (previously only ran on domain save or the 15-min cron tick). - Harden njalla.sh handling: create the base script (shebang + IP lookup) if missing before appending curl lines; invoke via bash explicitly. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 72 +++++++- .../static/js/features.js | 18 +- .../static/js/service-detail.js | 16 +- app/sovran_systemsos_web/static/onboarding.js | 163 +++++------------- .../templates/onboarding.html | 30 +--- 5 files changed, 121 insertions(+), 178 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 0cd84bb..936fab3 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -3995,6 +3995,14 @@ async def api_features_toggle(req: FeatureToggleRequest): await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub, cur_tz, cur_locale) + # When enabling a feature that relies on dynamic DNS, refresh the Njal.la + # records right away instead of waiting for the 15-minute cron tick. + # The newly enabled service needs DNS pointing at this machine as soon as + # the rebuild finishes (cert issuance, reachability). + if req.enabled and feat_meta.get("needs_ddns"): + await loop.run_in_executor(None, _ensure_njalla_script) + await loop.run_in_executor(None, _run_njalla_ddns) + # Clear the old rebuild log so the frontend doesn't pick up stale results try: open(REBUILD_LOG, "w").close() @@ -4091,6 +4099,58 @@ def _validate_safe_name(name: str) -> bool: return bool(name) and _SAFE_NAME_RE.match(name) is not None +def _ensure_njalla_script() -> None: + """Create the base njalla.sh (shebang + public-IP lookup) if it is missing. + + The Hub appends DDNS curl lines to this script, and those lines use ${IP}. + If the file exists only because of an append (e.g. the web app saved a + domain before the njalla-init systemd unit ran), it would lack the IP + lookup β ${IP} would expand empty during cron runs and the file couldn't + be executed directly. Keep in sync with modules/core/njalla.nix. + """ + njalla_dir = os.path.dirname(NJALLA_SCRIPT) + if njalla_dir: + os.makedirs(njalla_dir, exist_ok=True) + existing = "" + try: + with open(NJALLA_SCRIPT, "r") as f: + existing = f.read() + except OSError: + pass + if "myip.opendns.com" in existing: + return # base header already present + header = ( + "#!/usr/bin/env bash\n" + "IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n\n" + "## Add DDNS entries below β one curl per line\n" + "## Managed via Sovran Hub web interface\n" + ) + try: + with open(NJALLA_SCRIPT, "w") as f: + f.write(header + existing) + os.chmod(NJALLA_SCRIPT, 0o755) + except OSError: + pass + + +def _run_njalla_ddns() -> None: + """Run the Njal.la DDNS script immediately (best-effort). + + Called when a domain/DDNS entry is saved and when a DDNS-backed feature + is enabled, so DNS is refreshed right away instead of waiting for the + 15-minute cron job (see configuration.nix). + """ + if not os.path.isfile(NJALLA_SCRIPT): + return + try: + subprocess.run( + ["bash", NJALLA_SCRIPT], timeout=30, check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + except Exception: + pass + + # Hostname characters: letters, digits, hyphens only within labels; dots separate labels. # Each label must start and end with a letter or digit; no consecutive dots. _HOSTNAME_RE = re.compile( @@ -4194,10 +4254,9 @@ async def api_domains_set(req: DomainSetRequest): # Replace trailing &auto with &a=${IP} if ddns_url.endswith("&auto"): ddns_url = ddns_url[:-5] + "&a=${IP}" - # Append curl line to njalla.sh - njalla_dir = os.path.dirname(NJALLA_SCRIPT) - if njalla_dir: - os.makedirs(njalla_dir, exist_ok=True) + # Append curl line to njalla.sh, creating the base script first if + # needed so the shebang/IP lookup are present for this run and cron. + _ensure_njalla_script() with open(NJALLA_SCRIPT, "a") as f: f.write(f'curl "{ddns_url}"\n') try: @@ -4205,10 +4264,7 @@ async def api_domains_set(req: DomainSetRequest): except OSError: pass # Run njalla.sh immediately to update DNS - try: - subprocess.run([NJALLA_SCRIPT], timeout=30, check=False) - except Exception: - pass + _run_njalla_ddns() # Regenerate the server-local /etc/hosts loopback entries so the newly # saved domain is immediately reachable on this computer without NAT diff --git a/app/sovran_systemsos_web/static/js/features.js b/app/sovran_systemsos_web/static/js/features.js index fcb7b8d..841e2ac 100644 --- a/app/sovran_systemsos_web/static/js/features.js +++ b/app/sovran_systemsos_web/static/js/features.js @@ -284,23 +284,19 @@ function openPortRequirementsModal(featureName, ports, onContinue) { '
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.
"; + var ipPart = internalIp + ? ' to this computer’s internal IP' + escHtml(internalIp) + ''
+ : " to this computer's internal IP";
$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 + + '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 |
|---|
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.
' + + 'π‘ You can review these ports with live status any time on the ' + escHtml(featureName) + ' tile after enabling.
' + '' + 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) {
@@ -556,15 +553,12 @@ async function openServiceDetailModal(unit, name, icon) {
});
html += '| Port | Protocol | Used For | Sovran_SystemsOS Status |
|---|
β Could not load service data: ' + escHtml(err.message) + '
'; return; } + // Best-effort internal IP for the router note β never blocks this step + var internalIp = ""; + try { + var networkData = await apiFetch("/api/network"); + if (networkData && networkData.internal_ip && networkData.internal_ip !== "unavailable") { + internalIp = String(networkData.internal_ip).trim(); + } + } catch (_) {} + // Build set of enabled service units var enabledUnits = new Set(); (_servicesData || []).forEach(function(svc) { @@ -344,6 +350,8 @@ async function loadStep3() { return enabledUnits.has(d.unit); }); + var domainValues = (_domainsData && _domainsData.domains) || {}; + var html = ""; if (relevantDomains.length === 0) { @@ -368,8 +376,21 @@ async function loadStep3() { + '' + 'Enter each service\'s full domain β a subdomain (e.g. call.yourdomain.com) or a separate domain (e.g. call.com) β and its Njal.la DDNS curl command.
Let\'s Encrypt uses this for certificate expiry notifications.
'; @@ -511,106 +532,10 @@ async function saveStep3() { return true; } -// ββ Step 4: Port Forwarding βββββββββββββββββββββββββββββββββββββββ - -async function loadStep4() { - var body = document.getElementById("step-4-body"); - if (!body) return; - body.innerHTML = 'Loading router setupβ¦
'; - - var networkData = null; - - try { - networkData = await apiFetch("/api/network"); - } catch (err) { - body.innerHTML = 'β Could not load network data: ' + escHtml(err.message) + '
'; - return; - } - - var trimmedInternalIp = (networkData && networkData.internal_ip) ? String(networkData.internal_ip).trim() : ""; - var internalIp = trimmedInternalIp || ""; - var hasInternalIp = !!internalIp; - var ip = escHtml(internalIp || "Could not detect"); - var routerIpHelp = hasInternalIp - ? "Use this IP address as the destination/internal IP when creating each router forwarding rule." - : "Use this computerβs internal IP as the destination/internal IP when creating each router forwarding rule."; - var destinationInstruction = hasInternalIp - ? 'Set the destination/internal IP to ' + ip + '' - : 'Use this computerβs internal IP as the destination/internal IP'; - - var html = '' - + 'β Each port only needs to be forwarded once β all services share the same ports.' - + '
'; - - html += '| Port | Protocol | Forward To | Used For |
|---|---|---|---|
| 80 | TCP | ' + ip + ' | HTTP / SSL setup |
| 443 | TCP | ' + ip + ' | HTTPS |
| 22 | TCP | ' + ip + ' | Remote SSH access |
| Port | Protocol | Forward To | Used For |
|---|---|---|---|
| 7881 | TCP | ' + ip + ' | LiveKit WebRTC signalling |
| 7882 | UDP | ' + ip + ' | LiveKit media (UDP mux) |
| 5349 | TCP | ' + ip + ' | TURN over TLS |
| 3478 | UDP | ' + ip + ' | TURN (STUN/relay) |
| 30000-40000 | TCP & UDP | ' + ip + ' | TURN relay (WebRTC) |
http://192.168.1.1 or http://192.168.0.1