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.
' +
'
' +
'
Port(s)
Protocol
Purpose
' +
'' + rows + '' +
'
' +
- "
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 + '
' +
'
' +
'
Port
Protocol
Used For
Sovran_SystemsOS Status
' +
'' + extraRows + '' +
'
' +
- '
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 = '
β 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 += '
Port
Protocol
Forward To
Used For
';
- html += '';
- html += '
80
TCP
' + ip + '
HTTP / SSL setup
';
- html += '
443
TCP
' + ip + '
HTTPS
';
- html += '
22
TCP
' + ip + '
Remote SSH access
';
- html += '
';
- 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 += '
Port
Protocol
Forward To
Used For
';
- html += '';
- html += '
7881
TCP
' + ip + '
LiveKit WebRTC signalling
';
- html += '
7882
UDP
' + ip + '
LiveKit media (UDP mux)
';
- html += '
5349
TCP
' + ip + '
TURN over TLS
';
- html += '
3478
UDP
' + ip + '
TURN (STUN/relay)
';
- html += '
30000-40000
TCP & UDP
' + ip + '
TURN relay (WebRTC)
';
- html += '
';
- 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'
- + ''
- + '
Open your router\'s admin panel β usually http://192.168.1.1 or http://192.168.0.1
'
- + '
Look for "Port Forwarding", "NAT", or "Virtual Server" in the settings
'
- + '
Create a new rule for each port listed above
'
- + '
' + destinationInstruction + '
'
- + '
Set both internal and external port to the same number
'
- + '
Save and apply changes
'
- + ''
- + '';
-
- 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.'
- + '
- Forward these ports in your router to this Sovran_SystemsOS computer. These rules let people reach your services from outside your home network.
- Ports 80 and 443 are required for HTTPS and SSL certificates.
-