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)
# 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