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) { '' + escHtml(p.protocol) + '' + '' + escHtml(p.description) + ''; }).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.

"; + 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.

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

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.

' + '
' + '' + continueBtn + diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index 7ff9b91..cb6557e 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -518,12 +518,9 @@ async function openServiceDetailModal(unit, name, icon) { var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : ""; var internalIp = trimmedInternalIp || ""; var internalIpHtml = internalIp ? escHtml(internalIp) : "Could not detect"; - var routerIpHelp = internalIp - ? "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 routerNextStep = internalIp - ? 'Next step: Log in to your router and create forwarding rules for the ports above. Set the destination/internal IP to ' + internalIpHtml + '.' - : 'Next step: Log in to your router and create forwarding rules for the ports above. Use this computer’s internal IP as the destination/internal IP.'; + var forwardNote = internalIp + ? 'Forward each port below in your router to this computer’s internal IP ' + 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 += '
' + '
Ports to Forward in Your Router
' + - '
Forward these ports in your router to this Sovran_SystemsOS computer.
' + - '
Router Forward-To IP: ' + internalIpHtml + '
' + - '
' + routerIpHelp + '
' + + '
' + forwardNote + '
' + '' + '' + '' + extraRows + '' + '
PortProtocolUsed ForSovran_SystemsOS Status
' + - '
The Hub can check whether Sovran_SystemsOS is ready on this computer, but full public port verification requires an outside internet check.
' + - '
' + routerNextStep + '
' + + '
βœ… = Sovran_SystemsOS is ready on this computer. Router-side forwarding itself can only be verified from outside your network (e.g. a phone on mobile data).
' + '
'; } } else if (data.port_statuses && data.port_statuses.length > 0) { diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js index f6b5868..525f4bb 100644 --- a/app/sovran_systemsos_web/static/onboarding.js +++ b/app/sovran_systemsos_web/static/onboarding.js @@ -1,16 +1,16 @@ /* Sovran_SystemsOS Hub β€” First-Boot Onboarding Wizard - Drives the 5-step post-install setup flow. */ + Drives the 4-step post-install setup flow. */ "use strict"; // ── Constants ───────────────────────────────────────────────────── -const TOTAL_STEPS = 5; +const TOTAL_STEPS = 4; -// Steps to skip per role (steps 3 and 4 involve domain/port setup) +// Steps to skip per role (step 3 involves domain setup) // Step 2 (timezone/locale) is NEVER skipped β€” all roles need it. const ROLE_SKIP_STEPS = { - "desktop": [3, 4], - "node": [3, 4], + "desktop": [3], + "node": [3], }; // ── Role state (loaded at init) ─────────────────────────────────── @@ -66,7 +66,7 @@ function setStatus(elId, msg, type) { el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : ""); } -function updateStep5Checklist() { +function updateCompleteChecklist() { var checklist = document.getElementById("onboarding-checklist"); if (!checklist) return; var existing = document.getElementById("onboarding-migration-check"); @@ -135,8 +135,7 @@ function showStep(step) { // Lazy-load step content if (step === 2) loadStep2(); if (step === 3) loadStep3(); - if (step === 4) loadStep4(); - // Step 5 (Complete) is static β€” no lazy-load needed + // Step 4 (Complete) is static β€” no lazy-load needed } // Return the next step number, skipping over role-excluded steps @@ -319,20 +318,27 @@ async function loadStep3() { if (!body) return; try { - // Fetch services, domains, and network info in parallel + // Fetch services and domains in parallel (network info is best-effort below) var results = await Promise.all([ apiFetch("/api/services"), apiFetch("/api/domains/status"), - apiFetch("/api/network"), ]); _servicesData = results[0]; _domainsData = results[1]; - var networkData = results[2]; } catch (err) { body.innerHTML = '

⚠ 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() { + '' + '
'; html += '

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.

'; + + // Compact router note (full port guidance is shown when a feature is + // enabled, and lives on each service tile afterwards) + var routerIpPart = internalIp + ? ' to this computer’s internal IP ' + escHtml(internalIp) + '' + : ' to this computer’s internal IP'; + html += '
' + + 'πŸ”Œ One router task: forward ports 80 and 443 (TCP)' + + routerIpPart + '. They are required for HTTPS and SSL certificates β€” without them your services cannot be reached from outside your home network. ' + + 'Forward port 22 (TCP) too if you want remote SSH access. ' + + 'Element Call needs a few extra ports, but you’ll be shown those when you enable it.' + + '
'; + relevantDomains.forEach(function(d) { - var currentVal = (_domainsData && _domainsData[d.name]) || ""; + var currentVal = domainValues[d.name] || ""; html += '
'; html += ''; html += ''; @@ -383,7 +404,7 @@ async function loadStep3() { } // SSL email section - var emailVal = (_domainsData && _domainsData["sslemail"]) || ""; + var emailVal = domainValues["sslemail"] || ""; html += '
'; html += ''; html += '

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 += '
'; - html += ' Forward router traffic to this Sovran_SystemsOS computer:'; - html += ' ' + ip + ''; - html += '
'; - html += '
' + routerIpHelp + '
'; - - // Required ports table - html += '
'; - html += '
Required Router Rules
'; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += '
PortProtocolForward ToUsed For
80TCP' + ip + 'HTTP / SSL setup
443TCP' + ip + 'HTTPS
22TCP' + ip + 'Remote SSH access
'; - html += '
'; - - // Optional ports table - html += '
'; - html += '
Element Call Router Rules
'; - html += '
Only add these if you enable Element Call. These ports help video and audio calls connect reliably.
'; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += '
PortProtocolForward ToUsed For
7881TCP' + ip + 'LiveKit WebRTC signalling
7882UDP' + ip + 'LiveKit media (UDP mux)
5349TCP' + ip + 'TURN over TLS
3478UDP' + ip + 'TURN (STUN/relay)
30000-40000TCP & UDP' + ip + 'TURN relay (WebRTC)
'; - html += '
β„Ή The 30000-40000 range is a single forwarding rule β€” just set its protocol to both TCP and UDP (often shown as "Both" or "TCP/UDP" on your router).
'; - html += '
'; - - // Totals - html += '
'; - html += 'Total port openings: 3 (without Element Call)
'; - html += 'Total port openings: 8 (with Element Call β€” 3 required + 5 optional)'; - html += '
'; - - html += '
' - + '⚠ Ports 80 and 443 must be forwarded first. ' - + 'Caddy uses these to obtain SSL certificates from Let\'s Encrypt. ' - + 'If they are closed, HTTPS will not work and your services will be unreachable from outside your network.' - + '
'; - - html += '
' - + 'How to set up port forwarding' - + '
    ' - + '
  1. Open your router\'s admin panel β€” usually http://192.168.1.1 or http://192.168.0.1
  2. ' - + '
  3. Look for "Port Forwarding", "NAT", or "Virtual Server" in the settings
  4. ' - + '
  5. Create a new rule for each port listed above
  6. ' - + '
  7. ' + destinationInstruction + '
  8. ' - + '
  9. Set both internal and external port to the same number
  10. ' - + '
  11. Save and apply changes
  12. ' - + '
' - + '
'; - - html += '
' - + 'Important: The Hub can show which ports Sovran_SystemsOS needs, but it cannot fully confirm router forwarding from inside your home network. Full public port verification requires an outside internet check.' - + '
'; - - body.innerHTML = html; -} - -// ── Step 5: Complete ────────────────────────────────────────────── +// ── Step 4: Complete ────────────────────────────────────────────── async function completeOnboarding() { - var btn = document.getElementById("step-5-finish"); + var btn = document.getElementById("step-4-finish"); if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; } try { @@ -633,7 +558,7 @@ function wireNavButtons() { try { await apiFetch("/api/migration/password-acknowledge", { method: "POST" }); _migrationOccurred = true; - updateStep5Checklist(); + updateCompleteChecklist(); showStep1FromMigration(); } catch (err) { setStatus("migration-password-status", "⚠ " + err.message, "error"); @@ -669,13 +594,9 @@ function wireNavButtons() { showStep(nextStep(3)); }); - // Step 4 β†’ 5 (port forwarding β€” no save needed) - var s4next = document.getElementById("step-4-next"); - if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); }); - - // Step 5: finish - var s5finish = document.getElementById("step-5-finish"); - if (s5finish) s5finish.addEventListener("click", completeOnboarding); + // Step 4: finish + var s4finish = document.getElementById("step-4-finish"); + if (s4finish) s4finish.addEventListener("click", completeOnboarding); // Back buttons document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) { @@ -707,13 +628,13 @@ document.addEventListener("DOMContentLoaded", async function() { try { var migration = await apiFetch("/api/migration/password-status"); if (migration && migration.pending) { - updateStep5Checklist(); + updateCompleteChecklist(); showMigrationStep(migration.password || ""); return; } } catch (_) {} - updateStep5Checklist(); + updateCompleteChecklist(); showStep(1); loadStep1(); }); diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html index c810cab..2048436 100644 --- a/app/sovran_systemsos_web/templates/onboarding.html +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -34,8 +34,6 @@ 3 4 - - 5
@@ -144,29 +142,8 @@
- + - - -