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
@@ -284,23 +284,19 @@ function openPortRequirementsModal(featureName, ports, onContinue) {
|
||||
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
|
||||
}).join("");
|
||||
var ipLine = 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>'
|
||||
: "<p class=\"port-req-intro\">Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.</p>";
|
||||
var ipPart = internalIp
|
||||
? ' to this computer’s internal IP <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code>'
|
||||
: " to this computer's internal IP";
|
||||
|
||||
$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 with clients outside your local network, " +
|
||||
"you must configure <strong>port forwarding</strong> in your router's admin panel.</p>" +
|
||||
ipLine +
|
||||
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + '</strong> 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.</p>' +
|
||||
'<table class="port-req-table">' +
|
||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
"<p class=\"port-req-hint\"><strong>How to verify:</strong> 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.</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>' +
|
||||
'<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>' +
|
||||
'<div class="domain-field-actions">' +
|
||||
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
|
||||
continueBtn +
|
||||
|
||||
@@ -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 <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 forwardNote = internalIp
|
||||
? '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.'
|
||||
: '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 += '<div class="svc-detail-section">' +
|
||||
'<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"><strong>Router Forward-To IP:</strong> ' + internalIpHtml + '</div>' +
|
||||
'<div class="svc-detail-port-note">' + routerIpHelp + '</div>' +
|
||||
'<div class="svc-detail-port-note">' + forwardNote + '</div>' +
|
||||
'<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>' +
|
||||
'<tbody>' + extraRows + '</tbody>' +
|
||||
'</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">' + routerNextStep + '</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>';
|
||||
}
|
||||
} else if (data.port_statuses && data.port_statuses.length > 0) {
|
||||
|
||||
@@ -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 = '<p class="onboarding-error">⚠ Could not load service data: ' + escHtml(err.message) + '</p>';
|
||||
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() {
|
||||
+ '</ol>'
|
||||
+ '</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>';
|
||||
|
||||
// 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) {
|
||||
var currentVal = (_domainsData && _domainsData[d.name]) || "";
|
||||
var currentVal = domainValues[d.name] || "";
|
||||
html += '<div class="onboarding-domain-group">';
|
||||
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) + '" />';
|
||||
@@ -383,7 +404,7 @@ async function loadStep3() {
|
||||
}
|
||||
|
||||
// SSL email section
|
||||
var emailVal = (_domainsData && _domainsData["sslemail"]) || "";
|
||||
var emailVal = domainValues["sslemail"] || "";
|
||||
html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
|
||||
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>';
|
||||
@@ -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 = '<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 ──────────────────────────────────────────────
|
||||
// ── 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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user