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:
co-authored by
arena-agent
parent
2077cbd0fb
commit
a65d977489
@@ -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
|
||||||
|
|||||||
@@ -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’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’s internal IP <code class="port-req-internal-ip">' + internalIpHtml + '</code>, using the same internal and external port.'
|
||||||
: "Use this computer’s internal IP as the destination/internal IP when creating each router forwarding rule.";
|
: 'Forward each port below in your router to this computer’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 computer’s 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) {
|
||||||
|
|||||||
@@ -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’s internal IP <strong>' + escHtml(internalIp) + '</strong>'
|
||||||
|
: ' to this computer’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’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 computer’s 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 computer’s 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 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 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 & 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 & locale configured</li>
|
<li>✅ Timezone & 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user