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>
This commit is contained in:
naturallaw777
2026-07-28 21:38:07 +00:00
co-authored by arena-agent
parent 2077cbd0fb
commit a65d977489
5 changed files with 121 additions and 178 deletions
+64 -8
View File
@@ -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) 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 # Clear the old rebuild log so the frontend doesn't pick up stale results
try: try:
open(REBUILD_LOG, "w").close() 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 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. # 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. # Each label must start and end with a letter or digit; no consecutive dots.
_HOSTNAME_RE = re.compile( _HOSTNAME_RE = re.compile(
@@ -4194,10 +4254,9 @@ async def api_domains_set(req: DomainSetRequest):
# Replace trailing &auto with &a=${IP} # Replace trailing &auto with &a=${IP}
if ddns_url.endswith("&auto"): if ddns_url.endswith("&auto"):
ddns_url = ddns_url[:-5] + "&a=${IP}" ddns_url = ddns_url[:-5] + "&a=${IP}"
# Append curl line to njalla.sh # Append curl line to njalla.sh, creating the base script first if
njalla_dir = os.path.dirname(NJALLA_SCRIPT) # needed so the shebang/IP lookup are present for this run and cron.
if njalla_dir: _ensure_njalla_script()
os.makedirs(njalla_dir, exist_ok=True)
with open(NJALLA_SCRIPT, "a") as f: with open(NJALLA_SCRIPT, "a") as f:
f.write(f'curl "{ddns_url}"\n') f.write(f'curl "{ddns_url}"\n')
try: try:
@@ -4205,10 +4264,7 @@ async def api_domains_set(req: DomainSetRequest):
except OSError: except OSError:
pass pass
# Run njalla.sh immediately to update DNS # Run njalla.sh immediately to update DNS
try: _run_njalla_ddns()
subprocess.run([NJALLA_SCRIPT], timeout=30, check=False)
except Exception:
pass
# Regenerate the server-local /etc/hosts loopback entries so the newly # Regenerate the server-local /etc/hosts loopback entries so the newly
# saved domain is immediately reachable on this computer without NAT # saved domain is immediately reachable on this computer without NAT
+7 -11
View File
@@ -284,23 +284,19 @@ function openPortRequirementsModal(featureName, ports, onContinue) {
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' + '<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>'; '<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
}).join(""); }).join("");
var ipLine = internalIp var ipPart = internalIp
? '<p class="port-req-intro">Forward each port below <strong>to this machine\'s internal IP: <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code></strong></p>' ? ' to this computer&rsquo;s internal IP <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code>'
: "<p class=\"port-req-intro\">Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.</p>"; : " to this computer's internal IP";
$portReqBody.innerHTML = $portReqBody.innerHTML =
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' + '<p class="port-req-intro">For <strong>' + escHtml(featureName) + '</strong> to work for people outside your home network, ' +
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + "</strong> to work with clients outside your local network, " + 'forward each port below' + ipPart + " in your router's port-forwarding settings. " +
"you must configure <strong>port forwarding</strong> in your router's admin panel.</p>" + 'Set the internal and external port to the same number.</p>' +
ipLine +
'<table class="port-req-table">' + '<table class="port-req-table">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' + '<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' + '<tbody>' + rows + '</tbody>' +
'</table>' + '</table>' +
"<p class=\"port-req-hint\"><strong>How to verify:</strong> Router-side forwarding cannot be checked from inside your network. " + '<p class="port-req-hint">💡 You can review these ports with live status any time on the <strong>' + escHtml(featureName) + '</strong> tile after enabling.</p>' +
"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.</p>" +
'<p class="port-req-hint"> Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
'<div class="domain-field-actions">' + '<div class="domain-field-actions">' +
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' + '<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
continueBtn + continueBtn +
@@ -518,12 +518,9 @@ async function openServiceDetailModal(unit, name, icon) {
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : ""; var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
var internalIp = trimmedInternalIp || ""; var internalIp = trimmedInternalIp || "";
var internalIpHtml = internalIp ? escHtml(internalIp) : "Could not detect"; var internalIpHtml = internalIp ? escHtml(internalIp) : "Could not detect";
var routerIpHelp = internalIp var forwardNote = internalIp
? "Use this IP address as the destination/internal IP when creating each router forwarding rule." ? 'Forward each port below in your router to this computer&rsquo;s internal IP <code class="port-req-internal-ip">' + internalIpHtml + '</code>, using the same internal and external port.'
: "Use this computers internal IP as the destination/internal IP when creating each router forwarding rule."; : 'Forward each port below in your router to this computer&rsquo;s internal IP, using the same internal and external port.';
var routerNextStep = internalIp
? 'Next step: Log in to your router and create forwarding rules for the ports above. Set the destination/internal IP to <strong>' + internalIpHtml + '</strong>.'
: 'Next step: Log in to your router and create forwarding rules for the ports above. Use this computers internal IP as the destination/internal IP.';
var domainConfigured = !!(data.domain && String(data.domain).trim()); var domainConfigured = !!(data.domain && String(data.domain).trim());
var extraRows = ""; var extraRows = "";
data.extra_ports.forEach(function(p) { data.extra_ports.forEach(function(p) {
@@ -556,15 +553,12 @@ async function openServiceDetailModal(unit, name, icon) {
}); });
html += '<div class="svc-detail-section">' + html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' + '<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
'<div class="svc-detail-port-note">Forward these ports in your router to this Sovran_SystemsOS computer.</div>' + '<div class="svc-detail-port-note">' + forwardNote + '</div>' +
'<div class="svc-detail-port-note"><strong>Router Forward-To IP:</strong> ' + internalIpHtml + '</div>' +
'<div class="svc-detail-port-note">' + routerIpHelp + '</div>' +
'<table class="svc-detail-port-table">' + '<table class="svc-detail-port-table">' +
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' + '<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' +
'<tbody>' + extraRows + '</tbody>' + '<tbody>' + extraRows + '</tbody>' +
'</table>' + '</table>' +
'<div class="svc-detail-port-note">The Hub can check whether Sovran_SystemsOS is ready on this computer, but full public port verification requires an outside internet check.</div>' + '<div class="svc-detail-port-note">✅ = 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).</div>' +
'<div class="svc-detail-port-note">' + routerNextStep + '</div>' +
'</div>'; '</div>';
} }
} else if (data.port_statuses && data.port_statuses.length > 0) { } else if (data.port_statuses && data.port_statuses.length > 0) {
+42 -121
View File
@@ -1,16 +1,16 @@
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard /* 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"; "use strict";
// ── Constants ───────────────────────────────────────────────────── // ── 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. // Step 2 (timezone/locale) is NEVER skipped — all roles need it.
const ROLE_SKIP_STEPS = { const ROLE_SKIP_STEPS = {
"desktop": [3, 4], "desktop": [3],
"node": [3, 4], "node": [3],
}; };
// ── Role state (loaded at init) ─────────────────────────────────── // ── Role state (loaded at init) ───────────────────────────────────
@@ -66,7 +66,7 @@ function setStatus(elId, msg, type) {
el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : ""); el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : "");
} }
function updateStep5Checklist() { function updateCompleteChecklist() {
var checklist = document.getElementById("onboarding-checklist"); var checklist = document.getElementById("onboarding-checklist");
if (!checklist) return; if (!checklist) return;
var existing = document.getElementById("onboarding-migration-check"); var existing = document.getElementById("onboarding-migration-check");
@@ -135,8 +135,7 @@ function showStep(step) {
// Lazy-load step content // Lazy-load step content
if (step === 2) loadStep2(); if (step === 2) loadStep2();
if (step === 3) loadStep3(); if (step === 3) loadStep3();
if (step === 4) loadStep4(); // Step 4 (Complete) is static — no lazy-load needed
// Step 5 (Complete) is static — no lazy-load needed
} }
// Return the next step number, skipping over role-excluded steps // Return the next step number, skipping over role-excluded steps
@@ -319,20 +318,27 @@ async function loadStep3() {
if (!body) return; if (!body) return;
try { 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([ var results = await Promise.all([
apiFetch("/api/services"), apiFetch("/api/services"),
apiFetch("/api/domains/status"), apiFetch("/api/domains/status"),
apiFetch("/api/network"),
]); ]);
_servicesData = results[0]; _servicesData = results[0];
_domainsData = results[1]; _domainsData = results[1];
var networkData = results[2];
} catch (err) { } catch (err) {
body.innerHTML = '<p class="onboarding-error">⚠ Could not load service data: ' + escHtml(err.message) + '</p>'; body.innerHTML = '<p class="onboarding-error">⚠ Could not load service data: ' + escHtml(err.message) + '</p>';
return; 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 // Build set of enabled service units
var enabledUnits = new Set(); var enabledUnits = new Set();
(_servicesData || []).forEach(function(svc) { (_servicesData || []).forEach(function(svc) {
@@ -344,6 +350,8 @@ async function loadStep3() {
return enabledUnits.has(d.unit); return enabledUnits.has(d.unit);
}); });
var domainValues = (_domainsData && _domainsData.domains) || {};
var html = ""; var html = "";
if (relevantDomains.length === 0) { if (relevantDomains.length === 0) {
@@ -368,8 +376,21 @@ async function loadStep3() {
+ '</ol>' + '</ol>'
+ '</div>'; + '</div>';
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>'; html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
// 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&rsquo;s internal IP <strong>' + escHtml(internalIp) + '</strong>'
: ' to this computer&rsquo;s internal IP';
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ '🔌 <strong>One router task:</strong> forward ports <strong>80</strong> and <strong>443</strong> (TCP)'
+ routerIpPart + '. They are required for HTTPS and SSL certificates — without them your services cannot be reached from outside your home network. '
+ 'Forward port <strong>22</strong> (TCP) too if you want remote SSH access. '
+ 'Element Call needs a few extra ports, but you&rsquo;ll be shown those when you enable it.'
+ '</div>';
relevantDomains.forEach(function(d) { relevantDomains.forEach(function(d) {
var currentVal = (_domainsData && _domainsData[d.name]) || ""; var currentVal = domainValues[d.name] || "";
html += '<div class="onboarding-domain-group">'; html += '<div class="onboarding-domain-group">';
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>'; html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />'; html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />';
@@ -383,7 +404,7 @@ async function loadStep3() {
} }
// SSL email section // SSL email section
var emailVal = (_domainsData && _domainsData["sslemail"]) || ""; var emailVal = domainValues["sslemail"] || "";
html += '<div class="onboarding-domain-group onboarding-domain-group--email">'; html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>'; html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>';
html += '<p class="onboarding-hint onboarding-hint--inline">Let\'s Encrypt uses this for certificate expiry notifications.</p>'; html += '<p class="onboarding-hint onboarding-hint--inline">Let\'s Encrypt uses this for certificate expiry notifications.</p>';
@@ -511,106 +532,10 @@ async function saveStep3() {
return true; return true;
} }
// ── Step 4: Port Forwarding ─────────────────────────────────────── // ── Step 4: Complete ──────────────────────────────────────────────
async function loadStep4() {
var body = document.getElementById("step-4-body");
if (!body) return;
body.innerHTML = '<p class="onboarding-loading">Loading router setup…</p>';
var networkData = null;
try {
networkData = await apiFetch("/api/network");
} catch (err) {
body.innerHTML = '<p class="onboarding-error">⚠ Could not load network data: ' + escHtml(err.message) + '</p>';
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 computers internal IP as the destination/internal IP when creating each router forwarding rule.";
var destinationInstruction = hasInternalIp
? 'Set the destination/internal IP to <strong>' + ip + '</strong>'
: 'Use this computers internal IP as the destination/internal IP';
var html = '<p class="onboarding-port-note" style="margin-bottom:14px;">'
+ '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
+ '</p>';
html += '<div class="onboarding-port-ip">';
html += ' <span class="onboarding-port-ip-label">Forward router traffic to this Sovran_SystemsOS computer:</span>';
html += ' <span class="port-req-internal-ip">' + ip + '</span>';
html += '</div>';
html += '<div class="onboarding-port-note" style="margin:8px 0 16px;">' + routerIpHelp + '</div>';
// Required ports table
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required Router Rules</div>';
html += '<table class="onboarding-port-table">';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;To</th><th>Used For</th></tr></thead>';
html += '<tbody>';
html += '<tr><td class="port-req-port">80</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTP / SSL setup</td></tr>';
html += '<tr><td class="port-req-port">443</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTPS</td></tr>';
html += '<tr><td class="port-req-port">22</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Remote SSH access</td></tr>';
html += '</tbody></table>';
html += '</div>';
// Optional ports table
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Element Call Router Rules</div>';
html += '<div style="font-size:0.88em;margin-bottom:8px;color:var(--color-text-muted,#888);">Only add these if you enable Element Call. These ports help video and audio calls connect reliably.</div>';
html += '<table class="onboarding-port-table">';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;To</th><th>Used For</th></tr></thead>';
html += '<tbody>';
html += '<tr><td class="port-req-port">7881</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit WebRTC signalling</td></tr>';
html += '<tr><td class="port-req-port">7882</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media (UDP mux)</td></tr>';
html += '<tr><td class="port-req-port">5349</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN over TLS</td></tr>';
html += '<tr><td class="port-req-port">3478</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN (STUN/relay)</td></tr>';
html += '<tr><td class="port-req-port">30000-40000</td><td class="port-req-proto">TCP &amp; UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN relay (WebRTC)</td></tr>';
html += '</tbody></table>';
html += '<div style="font-size:0.85em;margin-top:6px;color:var(--color-text-muted,#888);"> The <strong>30000-40000</strong> range is a single forwarding rule — just set its protocol to <strong>both TCP and UDP</strong> (often shown as "Both" or "TCP/UDP" on your router).</div>';
html += '</div>';
// Totals
html += '<div class="onboarding-port-totals">';
html += '<strong>Total port openings: 3</strong> (without Element Call)<br>';
html += '<strong>Total port openings: 8</strong> (with Element Call — 3 required + 5 optional)';
html += '</div>';
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ '⚠ <strong>Ports 80 and 443 must be forwarded first.</strong> '
+ '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.'
+ '</div>';
html += '<details class="onboarding-port-details" style="margin-bottom:16px;">'
+ '<summary class="onboarding-port-details-summary">How to set up port forwarding</summary>'
+ '<ol style="margin:12px 0 0 16px; padding:0; line-height:1.8;">'
+ '<li>Open your router\'s admin panel — usually <code>http://192.168.1.1</code> or <code>http://192.168.0.1</code></li>'
+ '<li>Look for <strong>"Port Forwarding"</strong>, <strong>"NAT"</strong>, or <strong>"Virtual Server"</strong> in the settings</li>'
+ '<li>Create a new rule for each port listed above</li>'
+ '<li>' + destinationInstruction + '</li>'
+ '<li>Set both internal and external port to the same number</li>'
+ '<li>Save and apply changes</li>'
+ '</ol>'
+ '</details>';
html += '<div class="onboarding-port-note" style="margin-top:12px;">'
+ '<strong>Important:</strong> 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.'
+ '</div>';
body.innerHTML = html;
}
// ── Step 5: Complete ──────────────────────────────────────────────
async function completeOnboarding() { 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…"; } if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
try { try {
@@ -633,7 +558,7 @@ function wireNavButtons() {
try { try {
await apiFetch("/api/migration/password-acknowledge", { method: "POST" }); await apiFetch("/api/migration/password-acknowledge", { method: "POST" });
_migrationOccurred = true; _migrationOccurred = true;
updateStep5Checklist(); updateCompleteChecklist();
showStep1FromMigration(); showStep1FromMigration();
} catch (err) { } catch (err) {
setStatus("migration-password-status", "⚠ " + err.message, "error"); setStatus("migration-password-status", "⚠ " + err.message, "error");
@@ -669,13 +594,9 @@ function wireNavButtons() {
showStep(nextStep(3)); showStep(nextStep(3));
}); });
// Step 4 → 5 (port forwarding — no save needed) // Step 4: finish
var s4next = document.getElementById("step-4-next"); var s4finish = document.getElementById("step-4-finish");
if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); }); if (s4finish) s4finish.addEventListener("click", completeOnboarding);
// Step 5: finish
var s5finish = document.getElementById("step-5-finish");
if (s5finish) s5finish.addEventListener("click", completeOnboarding);
// Back buttons // Back buttons
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) { document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
@@ -707,13 +628,13 @@ document.addEventListener("DOMContentLoaded", async function() {
try { try {
var migration = await apiFetch("/api/migration/password-status"); var migration = await apiFetch("/api/migration/password-status");
if (migration && migration.pending) { if (migration && migration.pending) {
updateStep5Checklist(); updateCompleteChecklist();
showMigrationStep(migration.password || ""); showMigrationStep(migration.password || "");
return; return;
} }
} catch (_) {} } catch (_) {}
updateStep5Checklist(); updateCompleteChecklist();
showStep(1); showStep(1);
loadStep1(); loadStep1();
}); });
@@ -34,8 +34,6 @@
<span class="onboarding-step-dot" data-step="3">3</span> <span class="onboarding-step-dot" data-step="3">3</span>
<span class="onboarding-step-connector"></span> <span class="onboarding-step-connector"></span>
<span class="onboarding-step-dot" data-step="4">4</span> <span class="onboarding-step-dot" data-step="4">4</span>
<span class="onboarding-step-connector"></span>
<span class="onboarding-step-dot" data-step="5">5</span>
</div> </div>
<!-- Step panels --> <!-- Step panels -->
@@ -144,29 +142,8 @@
</div> </div>
</div> </div>
<!-- ── Step 4: Port Forwarding ── --> <!-- ── Step 4: Complete ── -->
<div class="onboarding-panel" id="step-4" style="display:none"> <div class="onboarding-panel" id="step-4" style="display:none">
<div class="onboarding-step-header">
<span class="onboarding-step-icon">🔌</span>
<h2 class="onboarding-step-title">Router Setup</h2>
<p class="onboarding-step-desc">
Forward these ports in your router to this Sovran_SystemsOS computer. These rules let people reach your services from outside your home network.
<strong>Ports 80 and 443 are required for HTTPS and SSL certificates.</strong>
</p>
</div>
<div class="onboarding-card" id="step-4-body">
<p class="onboarding-loading">Loading router setup…</p>
</div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
<button class="btn btn-primary onboarding-btn-next" id="step-4-next">
Continue →
</button>
</div>
</div>
<!-- ── Step 5: Complete ── -->
<div class="onboarding-panel" id="step-5" style="display:none">
<div class="onboarding-hero"> <div class="onboarding-hero">
<div class="onboarding-logo"></div> <div class="onboarding-logo"></div>
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1> <h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
@@ -180,12 +157,11 @@
<ul class="onboarding-checklist" id="onboarding-checklist"> <ul class="onboarding-checklist" id="onboarding-checklist">
<li>✅ Timezone &amp; locale configured</li> <li>✅ Timezone &amp; locale configured</li>
<li>✅ Domain configuration saved</li> <li>✅ Domain configuration saved</li>
<li>✅ Port forwarding reviewed</li>
</ul> </ul>
</div> </div>
<div class="onboarding-footer"> <div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button> <button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
<button class="btn btn-primary" id="step-5-finish"> <button class="btn btn-primary" id="step-4-finish">
Go to Dashboard → Go to Dashboard →
</button> </button>
</div> </div>