71 Commits
Author SHA1 Message Date
Sovran SystemsandGitHub 53f59aa388 Merge pull request #324 from naturallaw777/copilot/fix-rdp-boot-time-setup
fix(rdp): prepend /run/wrappers/bin to PATH and add pkexec preflight check
2026-07-14 16:02:03 +00:00
copilot-swe-agent[bot]andGitHub 69e996ff98 fix(rdp): prepend /run/wrappers/bin to PATH and add pkexec preflight check
GRD 50.x invokes pkexec internally for every grdctl --system call, even
when the caller is root.  An isolated systemd script PATH does not include
/run/wrappers/bin automatically, causing exit 70 at boot.

Changes:
- Prepend /run/wrappers/bin to PATH at script start so every subsequent
  grdctl --system resolves the NixOS setuid pkexec wrapper.
- Add an explicit preflight check (test -x /run/wrappers/bin/pkexec) with
  a clear error message before the first grdctl_system call.
- pkgs.polkit remains absent from the service path.
- Update test_setup_runs_grdctl_directly_as_root to reflect that pkexec
  now appears in the preflight check (not as a direct invocation).
- Add test_run_wrappers_bin_prepended_to_path and test_pkexec_preflight_check.
2026-07-14 15:54:40 +00:00
copilot-swe-agent[bot]andGitHub 6e4d0d22a1 Initial plan 2026-07-14 15:51:34 +00:00
Sovran SystemsandGitHub 881587c42a Merge pull request #323 from naturallaw777/copilot/fix-gnome-remote-desktop-boot-setup
fix(rdp): run grdctl --system directly as root, drop runuser/polkit/util-linux
2026-07-14 15:20:22 +00:00
copilot-swe-agent[bot]andGitHub 1bb7d1c680 style: fix spelling authorisation → authorization in test comment 2026-07-14 00:29:26 +00:00
copilot-swe-agent[bot]andGitHub fd5f651f2c fix(rdp): run grdctl --system directly as root, remove runuser/polkit/util-linux
Root cause: gnome-remote-desktop-setup.service ran as root but dropped
privileges via `runuser -u gnome-remote-desktop`. Non-root grdctl --system
attempts authorisation through pkexec. The Nix-store pkexec binary is not
setuid, so every system-mode credential call silently failed while the script
still printed "configured successfully". GNOME Remote Desktop then started
without applied credentials, causing Remmina to loop at the login dialog.

Fix:
- Remove `runuser -u gnome-remote-desktop --` from grdctl_system helper;
  the root-run oneshot service can call grdctl --system directly.
- Remove pkgs.polkit and pkgs.util-linux from the setup service path as
  neither polkit nor runuser is needed any more.
- Update tests: rename test_setup_runs_grdctl_as_gnome_remote_desktop_user
  to test_setup_runs_grdctl_directly_as_root and assert that runuser,
  pkexec, and sudo are absent; add
  test_privilege_escalation_packages_absent_from_setup_path.

All 21 app/tests pass.
2026-07-14 00:28:17 +00:00
copilot-swe-agent[bot]andGitHub b4317c9589 Initial plan 2026-07-14 00:26:18 +00:00
Sovran SystemsandGitHub 07f3e4cef9 Merge pull request #322 from naturallaw777/copilot/fix-rdp-credentials-activation
Fix GNOME Remote Desktop boot-time credential activation for Hub-managed RDP
2026-07-13 23:56:48 +00:00
copilot-swe-agent[bot]andGitHub 7592bda57a fix: apply hub-managed GRD credentials at boot 2026-07-13 23:54:08 +00:00
copilot-swe-agent[bot]andGitHub 47f496efcf fix: tighten GRD boot credential setup 2026-07-13 23:48:12 +00:00
copilot-swe-agent[bot]andGitHub 4a2c3a8eb4 Initial plan 2026-07-13 23:25:36 +00:00
Sovran SystemsandGitHub e9e7451a8e Merge pull request #321 from naturallaw777/copilot/update-element-calling-nix
element-calling: universal LiveKit interface detection, full JWT routing, homeserver config
2026-07-13 18:26:16 +00:00
copilot-swe-agent[bot]andGitHub 06c0cfbb78 feat(element-calling): universal LiveKit interface detection, JWT/Caddy fixes 2026-07-13 18:24:43 +00:00
copilot-swe-agent[bot]andGitHub 37b0369361 Initial plan 2026-07-13 18:20:58 +00:00
naturallaw777 81a974d715 nixpkgs update and btc client update 2026-07-13 13:02:42 -05:00
naturallaw777 ff70668f38 Merge remote-tracking branch 'github/main' into staging-dev 2026-07-13 12:46:51 -05:00
Sovran SystemsandGitHub fbe431555e Merge pull request #320 from naturallaw777/copilot/update-hub-workflow-to-stage-os-updates
Stage Hub full OS updates for next reboot (boot-first) instead of live switch
2026-07-13 17:46:35 +00:00
copilot-swe-agent[bot]andGitHub bd4c4f8716 Adjust update status docstring ordering 2026-07-13 17:41:36 +00:00
copilot-swe-agent[bot]andGitHub 201cdb5bf9 Polish staged-update test and log header formatting 2026-07-13 17:40:29 +00:00
copilot-swe-agent[bot]andGitHub 7ba4adb376 Stage Hub full updates for next reboot 2026-07-13 17:39:14 +00:00
copilot-swe-agent[bot]andGitHub a4fc880c38 Initial plan 2026-07-13 17:36:29 +00:00
naturallaw777 edf6294315 Update nixpkgs 2026-07-04 11:25:52 -05:00
Sovran SystemsandGitHub b4dd074894 Merge pull request #319 from naturallaw777/copilot/implement-desktop-only-safety-fixes
fix(desktop): harden Desktop Only role — nix-bitcoin compat, force-off server services, conditional Caddy
2026-07-03 23:39:22 +00:00
copilot-swe-agent[bot]andGitHub e81f6643fc fix: desktop-only safety fixes (nix-bitcoin compat, mkForce, conditional caddy)
1. Add nix-bitcoin.generateSecrets = lib.mkDefault true global compat
   default in role-logic.nix so Desktop Only systems can evaluate while
   nix-bitcoin is still globally imported by the flake.

2. Harden Desktop Only role: change all server/node service and feature
   disables from lib.mkDefault false to lib.mkForce false so they cannot
   be overridden by custom.nix or option defaults.
   - sovran_systemsOS.services: synapse, bitcoin, vaultwarden, wordpress, nextcloud
   - sovran_systemsOS.features: haven, mempool, element-calling, bitcoin-core
   - sovran_systemsOS.web.btcpayserver

3. Make Caddy conditional in caddy.nix:
   enable = needsHttpsPorts || extraVhosts != ""
   so Caddy does not run on Desktop Only installs with no web services.
2026-07-03 23:35:30 +00:00
copilot-swe-agent[bot]andGitHub afe51c4abd Initial plan 2026-07-03 23:33:55 +00:00
Sovran SystemsandGitHub a101e73c0e Merge pull request #318 from naturallaw777/copilot/fix-template-response-error
Fix TemplateResponse calls for Starlette 1.1.0+ compatibility
2026-06-30 17:38:27 +00:00
copilot-swe-agent[bot]andGitHub 1a5c6aca08 Fix TemplateResponse calls to use Starlette 1.1.0+ keyword-argument style 2026-06-30 17:37:21 +00:00
copilot-swe-agent[bot]andGitHub 00f76f33fd Initial plan 2026-06-30 17:35:17 +00:00
naturallaw777 0a92b8f57a Update nixpkgs 2026-06-30 12:14:53 -05:00
naturallaw777 8345c97664 updated nixpkgs and btc clients 2026-06-25 16:26:43 -05:00
Sovran SystemsandGitHub 1f61eb8c7e Merge pull request #317 from naturallaw777/copilot/update-hub-router-port-forwarding-ui
Clarify Hub router forwarding copy and surface internal IP in Element Call flows
2026-06-24 17:26:11 -05:00
Sovran SystemsandGitHub 625a307a8d Merge pull request #316 from naturallaw777/copilot/fix-legacy-ssh-key-handling
Repair legacy factory SSH keys when Hub passphrase changes
2026-06-24 17:25:57 -05:00
copilot-swe-agent[bot]andGitHub c2f3f048b9 fix: simplify internal IP copy handling 2026-06-24 22:19:14 +00:00
copilot-swe-agent[bot]andGitHub bd3dbcb057 fix: clarify router forwarding IP guidance 2026-06-24 22:17:13 +00:00
copilot-swe-agent[bot]andGitHub 7f975bc4f1 chore: use flock for ssh bootstrap repair 2026-06-24 22:16:44 +00:00
copilot-swe-agent[bot]andGitHub 31abf40722 chore: serialize ssh bootstrap key repairs 2026-06-24 22:16:05 +00:00
copilot-swe-agent[bot]andGitHub 439021f798 chore: harden ssh bootstrap script 2026-06-24 22:15:24 +00:00
copilot-swe-agent[bot]andGitHub 181465c376 chore: log legacy ssh key regeneration 2026-06-24 22:14:42 +00:00
copilot-swe-agent[bot]andGitHub 6ec28b1ad7 Initial plan 2026-06-24 22:14:35 +00:00
copilot-swe-agent[bot]andGitHub db1a88ab2e fix: repair legacy factory ssh key passphrases 2026-06-24 22:14:06 +00:00
copilot-swe-agent[bot]andGitHub aa148fe435 Initial plan 2026-06-24 22:12:39 +00:00
Sovran SystemsandGitHub f4590ff653 Merge pull request #315 from naturallaw777/copilot/update-sovran-systemsos-port-forwarding-ui
Align port-forwarding UX to local-readiness semantics across service detail, Step 4 checklist, and onboarding
2026-06-24 16:55:07 -05:00
copilot-swe-agent[bot]andGitHub 22402cb4fd Align router setup wording and local port statuses 2026-06-24 19:06:12 +00:00
copilot-swe-agent[bot]andGitHub 1868a58ee0 Initial plan 2026-06-24 19:03:36 +00:00
naturallaw777 50cbd2fa28 added onlyoffice 2026-06-24 13:19:41 -05:00
Sovran SystemsandGitHub 81e34a4adb Merge pull request #314 from naturallaw777/copilot/fix-livekit-service-permissions
fix: deliver LiveKit config via LoadCredential to resolve DynamicUser permission denied
2026-06-23 21:04:51 -05:00
copilot-swe-agent[bot]andGitHub 302eb43233 fix: deliver livekit config via LoadCredential to fix DynamicUser permission denied 2026-06-24 01:21:05 +00:00
copilot-swe-agent[bot]andGitHub 949391ed44 Initial plan 2026-06-24 01:19:37 +00:00
Sovran SystemsandGitHub 5041e5202f Merge pull request #313 from naturallaw777/fix/livekit-embedded-turn
fix(element-calling): enable LiveKit embedded TURN with runtime matri…
2026-06-23 20:04:38 -05:00
Sovran Systems 8baefe1bfd fix(element-calling): enable LiveKit embedded TURN with runtime matrix domain + cert
LiveKit was exiting cleanly with "TURN domain required" because turn.enabled
was set in the build-time config but turn.domain was never provided to the
process (the old livekit-runtime-config.service wrote a YAML that nothing
read). A clean exit (status 0) meant Restart=on-failure never restarted it,
so the Hub reported the service as Inactive.

This replaces the dead runtime-config oneshot with livekit-turn-setup.service,
which at runtime:
  - reads the matrix domain from /var/lib/domains/matrix (no hardcoding)
  - copies Caddy's already-issued matrix cert/key into /var/lib/livekit
  - generates a complete LiveKit config (incl. turn.domain + TLS cert/key)
    at /run/livekit/livekit.yaml

The livekit.service ExecStart is overridden to load that runtime config
(mirroring the existing Caddy ExecStart override pattern in
modules/core/caddy.nix), since turn.domain is only known at runtime. The cert
is delivered via LoadCredential so it is readable under DynamicUser=true
without weakening the sandbox.

Also aligns the RTC media port range (rtc.port_range_start/end = 30000-40000)
so it matches the forwarded ports, and drops the now-redundant manual
30000-40000 firewall ranges (covered by services.livekit settings/openFirewall).
2026-06-23 20:03:26 -05:00
Sovran SystemsandGitHub d8108dae0f Merge pull request #312 from naturallaw777/copilot/rewrite-njalla-domain-setup
Rewrite Njal.la domain-setup instructions: remove IP box, clarify Name-field host-only rule, support subdomain-or-separate-domain
2026-06-22 19:46:10 -05:00
copilot-swe-agent[bot]andGitHub 3eb347da06 Rewrite Njal.la domain-setup instructions (no IP box, Name-field clarification, subdomain-or-domain) 2026-06-23 00:44:39 +00:00
copilot-swe-agent[bot]andGitHub 8d15b71c06 Initial plan 2026-06-23 00:40:49 +00:00
naturallaw777 a28ad04b55 removed uneeded livekit-cli 2026-06-18 16:51:15 -05:00
naturallaw777 6720b602ba nixpkgs update 2026-06-18 16:05:02 -05:00
Sovran Systems 4e2264d5db Fix Element Calling 30000-40000 port note: single rule with both TCP and UDP
The previous change incorrectly split the 30000-40000 TURN relay range into two
rows and told users to create two separate forwarding rules. On most routers
this range is a single port-forwarding rule with a protocol selector set to
"Both" (or TCP/UDP). Revert to a single row (protocol "TCP & UDP"), update the
note to say it's one rule with both protocols enabled, and restore the totals to
3 required + 5 optional = 8.
2026-06-13 11:59:18 -05:00
Sovran Systems 82a6221880 Clarify Element Calling 30000-40000 range needs separate TCP and UDP forwards
The onboarding Step 4 port table listed the 30000-40000 TURN relay range as a
single "TCP/UDP" row, which is ambiguous on most routers where TCP and UDP
forwards are separate entries. Split it into two explicit rows (TCP and UDP),
add a clarifying note, and update the totals so users create both forwarding
rules.
2026-06-13 11:50:43 -05:00
Sovran SystemsandGitHub 6ec1faf3e6 Merge pull request #311 from naturallaw777/copilot/migrate-federation-to-well-known
Migrate Matrix federation from port 8448 to .well-known/matrix/server delegation on 443
2026-06-13 10:04:42 -05:00
copilot-swe-agent[bot]andGitHub d3beee602d feat: migrate Matrix federation from port 8448 to .well-known/matrix/server on 443
- modules/core/caddy.nix: replace $MATRIX:8448 site with .well-known/matrix/server handler inside $MATRIX vhost
- modules/element-calling.nix: add .well-known/matrix/server respond directive, remove $MATRIX:8448 block
- modules/synapse.nix: remove 8448 TCP/UDP firewall openings
- app/sovran_systemsos_web/server.py: remove _PORTS_MATRIX_FEDERATION constant, set matrix-synapse.service to [], simplify api_service_detail to only check LiveKit extra ports
- app/sovran_systemsos_web/static/onboarding.js: remove 8448 row from Step 4, update totals to 3/8 (3 required + 5 optional)
2026-06-13 14:52:26 +00:00
copilot-swe-agent[bot]andGitHub 29960e9937 Initial plan 2026-06-13 14:49:39 +00:00
Sovran SystemsandGitHub 417456485a Merge pull request #310 from naturallaw777/copilot/fix-iso-installer-imports
iso: remove orphaned branding.nix import from common.nix
2026-06-10 13:42:55 -05:00
copilot-swe-agent[bot]andGitHub 0945092dde Remove orphaned ./branding.nix import from iso/common.nix 2026-06-10 18:41:49 +00:00
copilot-swe-agent[bot]andGitHub 6f12117521 Initial plan 2026-06-10 18:40:41 +00:00
naturallaw777 8bf8814fa7 nixpkgs update 2026-06-09 11:03:40 -05:00
Sovran SystemsandGitHub d90b5b091b Add files via upload 2026-06-05 15:15:19 -05:00
Sovran SystemsandGitHub 4275ac1d2f Add files via upload 2026-06-05 13:27:04 -05:00
Sovran SystemsandGitHub 07b36d62d2 Merge pull request #309 from naturallaw777/copilot/add-desktop-screenshot
Add desktop screenshot to README
2026-06-05 13:20:41 -05:00
copilot-swe-agent[bot]andGitHub 5fb8279d61 Improve screenshot alt text for accessibility 2026-06-05 18:14:13 +00:00
copilot-swe-agent[bot]andGitHub 6eb63d3f85 Add desktop screenshot placeholder and README embed 2026-06-05 18:13:46 +00:00
copilot-swe-agent[bot]andGitHub 2702854513 Plan README changes with placeholder screenshot 2026-06-05 18:13:16 +00:00
copilot-swe-agent[bot]andGitHub 106537cc63 Initial plan 2026-06-05 17:56:09 +00:00
21 changed files with 939 additions and 252 deletions
+8
View File
@@ -10,6 +10,14 @@
</div> </div>
<div align="center">
<img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS desktop showing application dock and PRIVACY. SOVEREIGNTY. BITCOIN. tagline" width="800" />
*The Sovran_SystemsOS desktop — "Privacy. Sovereignty. Bitcoin."*
</div>
--- ---
## Table of Contents ## Table of Contents
+42 -40
View File
@@ -281,9 +281,6 @@ FEATURE_SERVICE_MAP = {
} }
# Port requirements for service tiles (keyed by unit name or icon) # Port requirements for service tiles (keyed by unit name or icon)
_PORTS_MATRIX_FEDERATION = [
{"port": "8448", "protocol": "TCP", "description": "Matrix server-to-server federation"},
]
_PORTS_ELEMENT_CALLING = [ _PORTS_ELEMENT_CALLING = [
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"}, {"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"}, {"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
@@ -296,7 +293,7 @@ SERVICE_PORT_REQUIREMENTS: dict[str, list[dict]] = {
# Infrastructure # Infrastructure
"caddy.service": [], "caddy.service": [],
# Communication # Communication
"matrix-synapse.service": _PORTS_MATRIX_FEDERATION, "matrix-synapse.service": [],
"livekit.service": _PORTS_ELEMENT_CALLING, "livekit.service": _PORTS_ELEMENT_CALLING,
# Domain-based apps (80/443 handled by end-to-end domain reachability checks) # Domain-based apps (80/443 handled by end-to-end domain reachability checks)
"btcpayserver.service": [], "btcpayserver.service": [],
@@ -1222,7 +1219,7 @@ def _generate_qr_base64(data: str) -> str | None:
# ── Update helpers (file-based, no systemctl) ──────────────────── # ── Update helpers (file-based, no systemctl) ────────────────────
def _read_update_status() -> str: def _read_update_status() -> str:
"""Read the status file. Returns RUNNING, SUCCESS, FAILED, or IDLE.""" """Read the status file. Returns RUNNING, SUCCESS, REBOOT_REQUIRED, FAILED, or IDLE."""
try: try:
with open(UPDATE_STATUS, "r") as f: with open(UPDATE_STATUS, "r") as f:
return f.read().strip() return f.read().strip()
@@ -1953,10 +1950,13 @@ def _verify_support_removed() -> bool:
@app.get("/login", response_class=HTMLResponse) @app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request): async def login_page(request: Request):
return templates.TemplateResponse("login.html", { return templates.TemplateResponse(
"request": request, request=request,
name="login.html",
context={
"asset_version": ASSET_VERSION, "asset_version": ASSET_VERSION,
}) },
)
@app.get("/auto-login") @app.get("/auto-login")
@@ -2021,20 +2021,26 @@ async def api_logout(request: Request):
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def index(request: Request): async def index(request: Request):
return templates.TemplateResponse("index.html", { return templates.TemplateResponse(
"request": request, request=request,
name="index.html",
context={
"asset_version": ASSET_VERSION, "asset_version": ASSET_VERSION,
}) },
)
@app.get("/onboarding", response_class=HTMLResponse) @app.get("/onboarding", response_class=HTMLResponse)
async def onboarding(request: Request): async def onboarding(request: Request):
_ensure_onboarding_reopened_for_migration() _ensure_onboarding_reopened_for_migration()
return templates.TemplateResponse("onboarding.html", { return templates.TemplateResponse(
"request": request, request=request,
name="onboarding.html",
context={
"asset_version": ASSET_VERSION, "asset_version": ASSET_VERSION,
"onboarding_js_hash": _ONBOARDING_JS_HASH, "onboarding_js_hash": _ONBOARDING_JS_HASH,
}) },
)
@app.get("/api/onboarding/status") @app.get("/api/onboarding/status")
@@ -2983,36 +2989,28 @@ async def api_service_detail(unit: str, icon: str | None = None):
"status": ps, "status": ps,
"description": p.get("description", ""), "description": p.get("description", ""),
}) })
extra_ports = port_statuses if unit in ("matrix-synapse.service", "livekit.service") else [] extra_ports = port_statuses if unit == "livekit.service" else []
if needs_domain and unit in ("matrix-synapse.service", "livekit.service"): if needs_domain and unit == "livekit.service":
if has_domain_issues: if has_domain_issues:
domain_check_steps.append({ domain_check_steps.append({
"step": 4, "step": 4,
"label": "Federation Port" if unit == "matrix-synapse.service" else "Additional Ports Required", "label": "Router Setup Needed",
"status": "skipped", "status": "skipped",
"detail": "Skipped until Steps 1-3 are complete", "detail": "Finish the domain steps first, then forward the Element Call ports in your router.",
})
elif unit == "matrix-synapse.service":
if extra_ports:
matrix_open = extra_ports[0]["status"] != "closed"
domain_check_steps.append({
"step": 4,
"label": "Federation Port",
"status": "ok" if matrix_open else "error",
"detail": (
f"Matrix federation port 8448 (TCP) is {'open' if matrix_open else 'closed'}.\n"
f"Matrix federation requires port 8448 (TCP) forwarded to {internal_ip}"
),
}) })
else: else:
extra_open = all(p["status"] != "closed" for p in extra_ports) # These checks are local-only (listening/firewall state on this computer),
# not an outside-in verification of router/NAT forwarding.
all_local_ready = all(p["status"] != "closed" for p in extra_ports)
domain_check_steps.append({ domain_check_steps.append({
"step": 4, "step": 4,
"label": "Additional Ports Required", "label": "Router Setup Needed" if all_local_ready else "Sovran_SystemsOS Port Setup Needed",
"status": "ok" if extra_open else "error", "status": "warning" if all_local_ready else "error",
"detail": ( "detail": (
"Element-Call/LiveKit requires additional forwarded ports for WebRTC and TURN traffic." "Sovran_SystemsOS is ready to use these ports on this computer. Now forward them in your router so Element Call can work from outside your home network."
if all_local_ready
else "Sovran_SystemsOS is not ready to use all required Element Call ports on this computer yet. Fix the ports marked “Not ready yet” below, then forward them in your router."
), ),
}) })
@@ -4757,17 +4755,21 @@ def _recover_stale_status(status_file: str, log_file: str, unit_name: str) -> bo
except Exception: except Exception:
pass pass
new_status = "SUCCESS" if unit_result == "success" else "FAILED" if unit_result == "success":
new_status = "REBOOT_REQUIRED" if unit_name == UPDATE_UNIT else "SUCCESS"
else:
new_status = "FAILED"
try: try:
with open(status_file, "w") as f: with open(status_file, "w") as f:
f.write(new_status) f.write(new_status)
except OSError: except OSError:
pass pass
msg = ( if new_status == "REBOOT_REQUIRED":
"\n[Update completed successfully while the server was restarting.]\n" msg = "\n[Update staged successfully while the server was restarting. Reboot required.]\n"
if new_status == "SUCCESS" elif new_status == "SUCCESS":
else "\n[Update encountered an error. See log above for details.]\n" msg = "\n[Update completed successfully while the server was restarting.]\n"
) else:
msg = "\n[Update encountered an error. See log above for details.]\n"
try: try:
with open(log_file, "a") as f: with open(log_file, "a") as f:
f.write(msg) f.write(msg)
+39 -14
View File
@@ -73,22 +73,47 @@ function openDomainSetupModal(feat, onSaved) {
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>'; npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
} }
var externalIp = _cachedExternalIp || "your external IP"; var introHtml;
if (_currentRole === "node") {
introHtml =
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la.</p>' +
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">njal.la</a>.</li>' +
'<li>Set up a domain for it — either a free subdomain or a separate domain. Pick one option:</li>' +
'</ol>';
} else {
introHtml =
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la. ' +
'In your Njal.la account, set up a domain for it — either a free subdomain or a separate domain. Pick one option:</p>';
}
$domainSetupBody.innerHTML = $domainSetupBody.innerHTML =
'<div class="domain-setup-intro">' + '<div class="domain-setup-intro">' +
'<p><strong>Before continuing:</strong></p>' + introHtml +
'<ol>' + '<details style="margin-top:10px;">' +
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' + '<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' +
'<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>' + '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
'<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>' + '<li>In Njal.la, open a domain you own and click &quot;Add record&quot;.</li>' +
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' + '<li>Set record type to <strong>Dynamic</strong>.</li>' +
'<li>Njal.la will give you a curl command like:<br>' + '<li>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' +
'<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=sub.domain.com&amp;k=abc123&amp;auto&quot;</code></li>' + '(Example only, your choice — for &quot;call.yourdomain.com&quot; you&apos;d type just: &nbsp;<code>call</code>)<br>' +
'<li>Enter the subdomain and paste that curl command below</li>' + '&#9888; Do NOT type the full domain here — Njal.la adds it automatically.</li>' +
'<li>A Dynamic record has NO IP field — the IP auto-fills after the rebuild/reboot.</li>' +
'<li>Copy the curl command Njal.la gives you, e.g.:<br>' +
'<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=call.yourdomain.com&amp;k=abc123&amp;auto&quot;</code></li>' +
'</ol>' + '</ol>' +
'</details>' +
'<details style="margin-top:6px;">' +
'<summary style="cursor:pointer;font-weight:600;">Option B — Separate / new domain</summary>' +
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
'<li>In Njal.la, buy the domain you want.</li>' +
'<li>Add a Dynamic record as in Option A. If this domain is dedicated to the service, leave the Name field blank or use <code>@</code>.</li>' +
'<li>Copy the curl command Njal.la gives you.</li>' +
'</ol>' +
'</details>' +
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. call.yourdomain.com) or a separate domain (e.g. call.com) — and paste its curl command.</p>' +
'</div>' + '</div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain (e.g. myservice.example.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></div>' + '<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. call.yourdomain.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=myservice.example.com&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' + '<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=myservice.example.com&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
npubField + npubField +
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save &amp; Enable</button></div>'; '<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save &amp; Enable</button></div>';
@@ -103,7 +128,7 @@ function openDomainSetupModal(feat, onSaved) {
ddnsUrl = ddnsUrl.trim(); ddnsUrl = ddnsUrl.trim();
npub = npub.trim(); npub = npub.trim();
if (!subdomain) { alert("Please enter a subdomain."); return; } if (!subdomain) { alert("Please enter a domain."); return; }
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; } if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
var saveBtn = document.getElementById("domain-setup-save-btn"); var saveBtn = document.getElementById("domain-setup-save-btn");
@@ -159,14 +184,14 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
'<p><strong>Troubleshooting steps:</strong></p>' + '<p><strong>Troubleshooting steps:</strong></p>' +
'<ol>' + '<ol>' +
'<li>Log into your Njal.la dashboard at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' + '<li>Log into your Njal.la dashboard at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
'<li>Find the DNS record for <strong>' + escHtml(currentDomain || "your domain") + '</strong></li>' + '<li>Find the DNS record for <strong>' + escHtml(currentDomain || "your domain") + '</strong>. In Njal.la\'s Name field, note that only the host part is stored (the word before the domain) — not the full domain.</li>' +
'<li>Verify it has a <strong>Dynamic</strong> record pointing to your current external IP:<br>' + '<li>Verify it has a <strong>Dynamic</strong> record pointing to your current external IP:<br>' +
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' + '<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
'<li>If the IP is wrong or the record is missing, update it</li>' + '<li>If the IP is wrong or the record is missing, update it</li>' +
'<li>If you changed the DDNS curl command, paste the updated one below</li>' + '<li>If you changed the DDNS curl command, paste the updated one below</li>' +
'</ol>' + '</ol>' +
'</div>' + '</div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain (e.g. myservice.example.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" value="' + escHtml(currentDomain) + '" /></div>' + '<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. call.yourdomain.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" value="' + escHtml(currentDomain) + '" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=myservice.example.com&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' + '<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=myservice.example.com&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
npubField + npubField +
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save &amp; Update</button></div>'; '<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save &amp; Update</button></div>';
@@ -154,20 +154,36 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>'; '</div>';
if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) { if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) {
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 computers 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 computers internal IP as the destination/internal IP.';
var domainConfigured = !!(data.domain && String(data.domain).trim());
var extraRows = ""; var extraRows = "";
data.extra_ports.forEach(function(p) { data.extra_ports.forEach(function(p) {
var statusIcon, statusClass2; var statusIcon, statusClass2;
if (p.status === "listening") { if (!effectiveEnabled) {
statusIcon = "✅ Open"; statusIcon = "⚠ Configure Element Call first";
statusClass2 = "port-status-open";
} else if (!domainConfigured) {
statusIcon = "⚠ Configure domain first";
statusClass2 = "port-status-open";
} else if (p.status === "listening") {
statusIcon = "✅ Ready";
statusClass2 = "port-status-listening"; statusClass2 = "port-status-listening";
} else if (p.status === "firewall_open") { } else if (p.status === "firewall_open") {
statusIcon = "🟡 Firewall open"; statusIcon = "✅ Ready";
statusClass2 = "port-status-open"; statusClass2 = "port-status-open";
} else if (p.status === "closed") { } else if (p.status === "closed") {
statusIcon = "❌ Closed"; statusIcon = "❌ Not ready yet";
statusClass2 = "port-status-closed"; statusClass2 = "port-status-closed";
} else { } else {
statusIcon = "— Unknown"; statusIcon = "— Could not check";
statusClass2 = "port-status-unknown"; statusClass2 = "port-status-unknown";
} }
extraRows += '<tr>' + extraRows += '<tr>' +
@@ -178,11 +194,16 @@ async function openServiceDetailModal(unit, name, icon) {
'</tr>'; '</tr>';
}); });
html += '<div class="svc-detail-section">' + html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Step 4: Additional Ports</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"><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>Description</th><th>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">' + routerNextStep + '</div>' +
'</div>'; '</div>';
} }
} else if (data.port_statuses && data.port_statuses.length > 0) { } else if (data.port_statuses && data.port_statuses.length > 0) {
@@ -191,16 +212,16 @@ async function openServiceDetailModal(unit, name, icon) {
data.port_statuses.forEach(function(p) { data.port_statuses.forEach(function(p) {
var statusIcon, statusClass2; var statusIcon, statusClass2;
if (p.status === "listening") { if (p.status === "listening") {
statusIcon = "✅ Open"; statusIcon = "✅ Ready";
statusClass2 = "port-status-listening"; statusClass2 = "port-status-listening";
} else if (p.status === "firewall_open") { } else if (p.status === "firewall_open") {
statusIcon = "🟡 Firewall open"; statusIcon = "✅ Ready";
statusClass2 = "port-status-open"; statusClass2 = "port-status-open";
} else if (p.status === "closed") { } else if (p.status === "closed") {
statusIcon = "🔴 Closed"; statusIcon = "❌ Not ready";
statusClass2 = "port-status-closed"; statusClass2 = "port-status-closed";
} else { } else {
statusIcon = "— Unknown"; statusIcon = "— Could not check";
statusClass2 = "port-status-unknown"; statusClass2 = "port-status-unknown";
} }
portTableRows += '<tr>' + portTableRows += '<tr>' +
@@ -211,9 +232,10 @@ async function openServiceDetailModal(unit, name, icon) {
'</tr>'; '</tr>';
}); });
html += '<div class="svc-detail-section">' + html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Port Status</div>' + '<div class="svc-detail-section-title">Port Requirements</div>' +
'<div class="svc-detail-port-note">This shows whether Sovran_SystemsOS is ready to use this port on this computer. If you need access from outside your home network, forward this port in your router.</div>' +
'<table class="svc-detail-port-table">' + '<table class="svc-detail-port-table">' +
'<thead><tr><th>Port</th><th>Protocol</th><th>Description</th><th>Status</th></tr></thead>' + '<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' +
'<tbody>' + portTableRows + '</tbody>' + '<tbody>' + portTableRows + '</tbody>' +
'</table>' + '</table>' +
'</div>'; '</div>';
+45 -29
View File
@@ -333,8 +333,6 @@ async function loadStep3() {
return; return;
} }
var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)";
// 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) {
@@ -352,25 +350,31 @@ async function loadStep3() {
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>'; html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
} else { } else {
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">' html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ '<strong>Before you continue:</strong>' + '<p style="margin:0 0 8px;"><strong>Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS.</strong></p>'
+ '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">' + '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">'
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a></li>' + '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a>.</li>'
+ '<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>' + '<li>Buy at least one domain. Each service below needs its own domain — you can either give each service its own subdomain of a single domain you buy (subdomains are free, and one domain can have many), OR use a separate domain for each. Your choice.</li>'
+ '<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>' + '<li>For each service, add a <strong>Dynamic</strong> record in Njal.la:'
+ '<span style="display:inline-block;margin-top:4px;padding:4px 12px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1.1em;font-weight:700;letter-spacing:0.03em;">' + escHtml(externalIp) + '</span></li>' + '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
+ '<li>Njal.la will give you a curl command like:<br>' + '<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>'
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&amp;k=abc123&amp;auto"</code></li>' + '(Example only, your choice — for &quot;call.yourdomain.com&quot; you&apos;d type just: <code>call</code>.)<br>'
+ '<li>Enter the subdomain and paste that curl command below for each service</li>' + 'If you bought a whole separate domain just for this service, leave Name blank or use <code>@</code>.<br>'
+ '&#9888; Do NOT type the full domain in the Name field — Njal.la adds it automatically.</li>'
+ '<li>A Dynamic record has NO IP field. You don&apos;t enter an IP anywhere — it auto-fills once Sovran_SystemsOS updates it (on save, and again after reboot).</li>'
+ '</ul>'
+ '</li>'
+ '<li>Njal.la gives you a curl command like:<br>'
+ '<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=call.yourdomain.com&amp;k=abc123&amp;auto&quot;</code></li>'
+ '</ol>' + '</ol>'
+ '</div>'; + '</div>';
html += '<p class="onboarding-hint">Enter each fully-qualified subdomain (e.g. <code>matrix.yourdomain.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>';
relevantDomains.forEach(function(d) { relevantDomains.forEach(function(d) {
var currentVal = (_domainsData && _domainsData[d.name]) || ""; var currentVal = (_domainsData && _domainsData[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) + '" />';
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la DDNS Curl Command</label>'; html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la DDNS Curl Command</label>';
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl &quot;https://njal.la/update/?h=' + escHtml(d.name) + '.yourdomain.com&amp;k=abc123&amp;auto&quot;" />'; html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl &quot;https://njal.la/update/?h=...&amp;k=...&amp;auto&quot;" />';
html += '<p class="onboarding-hint" style="margin-top:4px;"> Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p>'; html += '<p class="onboarding-hint" style="margin-top:4px;"> Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p>';
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-domain="' + escHtml(d.name) + '" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>'; html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-domain="' + escHtml(d.name) + '" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
html += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>'; html += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>';
@@ -512,7 +516,7 @@ async function saveStep3() {
async function loadStep4() { async function loadStep4() {
var body = document.getElementById("step-4-body"); var body = document.getElementById("step-4-body");
if (!body) return; if (!body) return;
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>'; body.innerHTML = '<p class="onboarding-loading">Loading router setup…</p>';
var networkData = null; var networkData = null;
@@ -523,51 +527,59 @@ async function loadStep4() {
return; return;
} }
var internalIp = (networkData && networkData.internal_ip) || "unknown"; var trimmedInternalIp = (networkData && networkData.internal_ip) ? String(networkData.internal_ip).trim() : "";
var internalIp = trimmedInternalIp || "";
var ip = escHtml(internalIp); 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;">' 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>' + '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
+ '</p>'; + '</p>';
html += '<div class="onboarding-port-ip">'; html += '<div class="onboarding-port-ip">';
html += ' <span class="onboarding-port-ip-label">Forward ports to this machine\'s internal IP:</span>'; 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 += ' <span class="port-req-internal-ip">' + ip + '</span>';
html += '</div>'; html += '</div>';
html += '<div class="onboarding-port-note" style="margin:8px 0 16px;">' + routerIpHelp + '</div>';
// Required ports table // Required ports table
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">'; 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 Ports — open these on your router:</div>'; 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 += '<table class="onboarding-port-table">';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;to</th><th>Purpose</th></tr></thead>'; html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;To</th><th>Used For</th></tr></thead>';
html += '<tbody>'; 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</td></tr>'; 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">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">SSH Remote Access</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 += '<tr><td class="port-req-port">8448</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Matrix Federation</td></tr>';
html += '</tbody></table>'; html += '</tbody></table>';
html += '</div>'; html += '</div>';
// Optional ports table // Optional ports table
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">'; html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Optional — Only needed if you enable Element Calling:</div>'; 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);">These 5 additional port openings are required on top of the 4 required ports above.</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 += '<table class="onboarding-port-table">';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;to</th><th>Purpose</th></tr></thead>'; html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;To</th><th>Used For</th></tr></thead>';
html += '<tbody>'; 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">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">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">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">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">3000040000</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 += '<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 += '</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>'; html += '</div>';
// Totals // Totals
html += '<div class="onboarding-port-totals">'; html += '<div class="onboarding-port-totals">';
html += '<strong>Total port openings: 4</strong> (without Element Calling)<br>'; html += '<strong>Total port openings: 3</strong> (without Element Call)<br>';
html += '<strong>Total port openings: 9</strong> (with Element Calling4 required + 5 optional)'; html += '<strong>Total port openings: 8</strong> (with Element Call — 3 required + 5 optional)';
html += '</div>'; html += '</div>';
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">' html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
@@ -582,12 +594,16 @@ async function loadStep4() {
+ '<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>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>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>Create a new rule for each port listed above</li>'
+ '<li>Set the destination/internal IP to <strong>' + ip + '</strong></li>' + '<li>' + destinationInstruction + '</li>'
+ '<li>Set both internal and external port to the same number</li>' + '<li>Set both internal and external port to the same number</li>'
+ '<li>Save and apply changes</li>' + '<li>Save and apply changes</li>'
+ '</ol>' + '</ol>'
+ '</details>'; + '</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; body.innerHTML = html;
} }
@@ -128,9 +128,8 @@
<h2 class="onboarding-step-title">Domain Configuration</h2> <h2 class="onboarding-step-title">Domain Configuration</h2>
<p class="onboarding-step-desc"> <p class="onboarding-step-desc">
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS. Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS.
First, create an account at <strong>Njal.la</strong> and purchase a new domain, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost. Create an account at Njal.la, then for each service below, add a <strong>Dynamic</strong> record — no IP needed, it auto-populates once the DDNS curl command runs.
Then, in the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine's external IP address (shown below). Paste the curl command from your Njal.la dashboard for each service.
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
</p> </p>
</div> </div>
<div class="onboarding-card" id="step-3-body"> <div class="onboarding-card" id="step-3-body">
@@ -149,14 +148,14 @@
<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"> <div class="onboarding-step-header">
<span class="onboarding-step-icon">🔌</span> <span class="onboarding-step-icon">🔌</span>
<h2 class="onboarding-step-title">Port Forwarding Check</h2> <h2 class="onboarding-step-title">Router Setup</h2>
<p class="onboarding-step-desc"> <p class="onboarding-step-desc">
Forward these ports on your router to this machine. Each port only needs to be opened once — they are shared across all your services. 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 must be open for SSL certificates to work.</strong> <strong>Ports 80 and 443 are required for HTTPS and SSL certificates.</strong>
</p> </p>
</div> </div>
<div class="onboarding-card" id="step-4-body"> <div class="onboarding-card" id="step-4-body">
<p class="onboarding-loading">Checking ports</p> <p class="onboarding-loading">Loading router setup</p>
</div> </div>
<div class="onboarding-footer"> <div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button> <button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
+44
View File
@@ -0,0 +1,44 @@
import unittest
from pathlib import Path
HUB_NIX = Path(__file__).resolve().parents[2] / "modules" / "core" / "sovran-hub.nix"
def _section(source: str, start: str, end: str) -> str:
start_idx = source.find(start)
if start_idx == -1:
raise AssertionError(f"Expected section start not found: {start!r}")
end_idx = source.find(end, start_idx)
if end_idx == -1:
raise AssertionError(f"Expected section end not found: {end!r}")
return source[start_idx:end_idx]
class HubUpdateBootStagingTests(unittest.TestCase):
def setUp(self):
self.source = HUB_NIX.read_text()
self.update_section = _section(
self.source,
'update-script = pkgs.writeShellScript "sovran-hub-update.sh" \'\'',
"# ── Rebuild wrapper script",
)
self.rebuild_section = _section(
self.source,
'rebuild-script = pkgs.writeShellScript "sovran-hub-rebuild.sh" \'\'',
"# ── Brave launcher wrapper",
)
def test_full_update_uses_boot_not_switch(self):
self.assertIn("nixos-rebuild boot --flake /etc/nixos", self.update_section)
self.assertNotIn("nixos-rebuild switch --flake /etc/nixos", self.update_section)
def test_full_update_marks_reboot_required(self):
self.assertIn('echo "REBOOT_REQUIRED" > "$STATUS"', self.update_section)
def test_rebuild_path_keeps_switch_semantics(self):
self.assertIn("nixos-rebuild switch --flake /etc/nixos", self.rebuild_section)
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
import unittest
from pathlib import Path
RDP_NIX = Path(__file__).resolve().parents[2] / "modules" / "rdp.nix"
USERNAME_READ = "USERNAME=\"$(tr -d '\\n' < \"$USERNAME_FILE\")\""
USERNAME_LENGTH_GUARD = "if [ \"''${#USERNAME}\" -gt 32 ]; then"
SHORT_PASSWORD_GUARD = 'if [ "\'\'${#PASSWORD}" -lt 8 ]; then'
def _section(source: str, start: str, end: str) -> str:
start_idx = source.find(start)
if start_idx == -1:
raise AssertionError(f"Expected section start not found: {start!r}")
end_idx = source.find(end, start_idx)
if end_idx == -1:
raise AssertionError(f"Expected section end not found: {end!r}")
return source[start_idx:end_idx]
class RdpModuleBootSetupTests(unittest.TestCase):
def setUp(self):
self.source = RDP_NIX.read_text()
self.gnome_service = _section(
self.source,
"systemd.services.gnome-remote-desktop = {",
"systemd.tmpfiles.rules = [",
)
self.setup_service = _section(
self.source,
"systemd.services.gnome-remote-desktop-setup = {",
"};\n}",
)
def test_does_not_redeclare_gnome_remote_desktop_user(self):
self.assertNotIn("users.users.gnome-remote-desktop", self.source)
self.assertNotIn("createHome = true;", self.source)
def test_main_service_requires_setup_before_starting(self):
self.assertIn('wantedBy = [ "graphical.target" ];', self.gnome_service)
self.assertIn('after = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service)
self.assertIn('requires = [ "gnome-remote-desktop-setup.service" ];', self.gnome_service)
def test_setup_waits_for_configuration_service_and_bounded_timeout(self):
self.assertIn('wantedBy = [ "graphical.target" ];', self.setup_service)
self.assertIn('before = [ "gnome-remote-desktop.service" ];', self.setup_service)
self.assertIn('"dbus.service"', self.setup_service)
self.assertIn('"gnome-remote-desktop-configuration.service"', self.setup_service)
self.assertNotIn("RemainAfterExit", self.setup_service)
self.assertIn('TimeoutStartSec = "2min";', self.setup_service)
self.assertIn('timeout --kill-after=5s 10s', self.setup_service)
self.assertIn('echo "grdctl command timed out: $*" >&2', self.setup_service)
self.assertIn('echo "grdctl command failed (exit $rc): $*" >&2', self.setup_service)
def test_setup_runs_grdctl_directly_as_root(self):
# The oneshot service runs as root; grdctl --system is called directly.
# GRD 50.x invokes pkexec internally, but the call itself is plain
# grdctl --system, not a manual pkexec invocation.
self.assertIn('grdctl --system "$@"', self.setup_service)
self.assertNotIn("runuser", self.setup_service)
self.assertNotIn("sudo", self.setup_service)
# No direct Nix-store pkexec invocation (pkgs.polkit}/bin/pkexec).
self.assertNotIn("pkgs.polkit}/bin/pkexec", self.setup_service)
def test_privilege_escalation_packages_absent_from_setup_path(self):
self.assertNotIn("pkgs.polkit", self.setup_service)
self.assertNotIn("pkgs.util-linux", self.setup_service)
def test_run_wrappers_bin_prepended_to_path(self):
# /run/wrappers/bin must be prepended to PATH before any grdctl_system
# invocation so that grdctl --system resolves the NixOS setuid pkexec.
path_export = 'export PATH="/run/wrappers/bin:$PATH"'
grdctl_marker = "grdctl_system"
script = self.setup_service
path_idx = script.find(path_export)
grdctl_idx = script.find(grdctl_marker)
self.assertGreater(path_idx, -1, f"{path_export!r} not found in setup script")
self.assertGreater(
grdctl_idx, path_idx,
"PATH export must appear before the first grdctl_system usage",
)
def test_pkexec_preflight_check(self):
# A preflight must confirm /run/wrappers/bin/pkexec is executable
# with a clear error message before any GRD configuration changes.
self.assertIn("test -x /run/wrappers/bin/pkexec", self.setup_service)
self.assertIn(
"/run/wrappers/bin/pkexec is absent or not executable",
self.setup_service,
)
def test_hub_files_are_the_source_of_truth_for_username_and_password(self):
self.assertIn('DEFAULT_USERNAME="sovran"', self.setup_service)
self.assertIn('if [ ! -f "$USERNAME_FILE" ]; then', self.setup_service)
self.assertIn(USERNAME_READ, self.setup_service)
self.assertIn(USERNAME_LENGTH_GUARD, self.setup_service)
self.assertIn('case "$USERNAME" in', self.setup_service)
self.assertIn('[A-Za-z_][A-Za-z0-9_-]*)', self.setup_service)
self.assertIn("RDP username is too long (''${#USERNAME} characters, maximum 32)", self.setup_service)
self.assertIn("RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens", self.setup_service)
self.assertIn('if [ ! -f "$PASSWORD_FILE" ]; then', self.setup_service)
self.assertIn("tr -d '\\n'", self.setup_service)
self.assertIn('"$PASSWORD_FILE"', self.setup_service)
self.assertIn(SHORT_PASSWORD_GUARD, self.setup_service)
self.assertIn("RDP password is too short (''${#PASSWORD} characters, minimum 8)", self.setup_service)
self.assertIn('grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"', self.setup_service)
self.assertNotIn('grdctl --system rdp set-credentials sovran "$PASSWORD"', self.setup_service)
def test_secure_permissions_are_enforced_for_state_and_secret_files(self):
self.assertIn('"d /var/lib/gnome-remote-desktop/tls 0700', self.source)
self.assertIn("chmod 700", self.setup_service)
self.assertIn('chmod 600 "$USERNAME_FILE"', self.setup_service)
self.assertIn('chmod 600 "$PASSWORD_FILE"', self.setup_service)
self.assertIn('chmod 600 "$CRED_FILE"', self.setup_service)
self.assertIn('chmod 600 "$TLS_DIR/rdp-tls.key"', self.setup_service)
self.assertIn('chmod 644 "$TLS_DIR/rdp-tls.crt"', self.setup_service)
self.assertIn('LOCAL_IP="$(hostname -I | awk \'{print $1}\')"', self.setup_service)
self.assertIn('LOCAL_IP="127.0.0.1"', self.setup_service)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,171 @@
import unittest
from pathlib import Path
from unittest.mock import mock_open, patch
import sys
import types
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
def _install_web_stubs():
if "fastapi" in sys.modules:
return
class _HTTPException(Exception):
def __init__(self, status_code=None, detail=None):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
class _FastAPI:
def __init__(self, *args, **kwargs):
pass
def mount(self, *args, **kwargs):
return None
def add_middleware(self, *args, **kwargs):
return None
def __getattr__(self, _name):
def _decorator_factory(*args, **kwargs):
def _decorator(func):
return func
return _decorator
return _decorator_factory
class _BaseModel:
pass
class _StaticFiles:
def __init__(self, *args, **kwargs):
pass
class _Jinja2Templates:
def __init__(self, *args, **kwargs):
pass
class _BaseHTTPMiddleware:
pass
fastapi_module = types.ModuleType("fastapi")
fastapi_module.FastAPI = _FastAPI
fastapi_module.HTTPException = _HTTPException
sys.modules["fastapi"] = fastapi_module
responses_module = types.ModuleType("fastapi.responses")
responses_module.HTMLResponse = object
responses_module.JSONResponse = object
responses_module.RedirectResponse = object
sys.modules["fastapi.responses"] = responses_module
staticfiles_module = types.ModuleType("fastapi.staticfiles")
staticfiles_module.StaticFiles = _StaticFiles
sys.modules["fastapi.staticfiles"] = staticfiles_module
templating_module = types.ModuleType("fastapi.templating")
templating_module.Jinja2Templates = _Jinja2Templates
sys.modules["fastapi.templating"] = templating_module
requests_module = types.ModuleType("fastapi.requests")
requests_module.Request = object
sys.modules["fastapi.requests"] = requests_module
pydantic_module = types.ModuleType("pydantic")
pydantic_module.BaseModel = _BaseModel
sys.modules["pydantic"] = pydantic_module
starlette_base_module = types.ModuleType("starlette.middleware.base")
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
sys.modules["starlette.middleware.base"] = starlette_base_module
starlette_middleware_module = types.ModuleType("starlette.middleware")
starlette_middleware_module.base = starlette_base_module
sys.modules["starlette.middleware"] = starlette_middleware_module
starlette_module = types.ModuleType("starlette")
starlette_module.middleware = starlette_middleware_module
sys.modules["starlette"] = starlette_module
_install_web_stubs()
from sovran_systemsos_web import server
class ServiceDetailRouterWordingTests(unittest.IsolatedAsyncioTestCase):
async def test_livekit_service_detail_includes_internal_ip(self):
service_cfg = {
"services": [
{"unit": "livekit.service", "icon": "element-call", "enabled": True, "type": "system"}
]
}
domain_eval = {
"domain_status": {"status": "ok"},
"domain_reachable": {"reachable": True},
"domain_check_steps": [],
"has_issues": False,
}
with (
patch.object(server, "load_config", return_value=service_cfg),
patch.object(server, "_read_hub_overrides", return_value=({}, None, None)),
patch.object(server.sysctl, "is_active", return_value="active"),
patch.dict(server.SERVICE_DOMAIN_MAP, {"livekit.service": "element-call"}, clear=False),
patch.dict(
server.SERVICE_PORT_REQUIREMENTS,
{"livekit.service": [{"port": "7881", "protocol": "TCP", "description": "LiveKit"}]},
clear=False,
),
patch("builtins.open", mock_open(read_data="call.example.com\n")),
patch.object(server, "_evaluate_domain_checklist", return_value=domain_eval),
patch.object(server, "_get_internal_ip", return_value="192.168.1.44"),
patch.object(server, "_save_internal_ip"),
patch.object(server, "_get_listening_ports", return_value={"tcp": {7881}, "udp": set()}),
patch.object(server, "_get_firewall_allowed_ports", return_value={"tcp": set(), "udp": set()}),
):
result = await server.api_service_detail("livekit.service")
self.assertEqual(result["internal_ip"], "192.168.1.44")
self.assertEqual(result["extra_ports"][0]["status"], "listening")
self.assertEqual(result["domain_check_steps"][-1]["label"], "Router Setup Needed")
async def test_livekit_router_step_uses_not_ready_yet_wording(self):
service_cfg = {
"services": [
{"unit": "livekit.service", "icon": "element-call", "enabled": True, "type": "system"}
]
}
domain_eval = {
"domain_status": {"status": "ok"},
"domain_reachable": {"reachable": True},
"domain_check_steps": [],
"has_issues": False,
}
with (
patch.object(server, "load_config", return_value=service_cfg),
patch.object(server, "_read_hub_overrides", return_value=({}, None, None)),
patch.object(server.sysctl, "is_active", return_value="active"),
patch.dict(server.SERVICE_DOMAIN_MAP, {"livekit.service": "element-call"}, clear=False),
patch.dict(
server.SERVICE_PORT_REQUIREMENTS,
{"livekit.service": [{"port": "7881", "protocol": "TCP", "description": "LiveKit"}]},
clear=False,
),
patch("builtins.open", mock_open(read_data="call.example.com\n")),
patch.object(server, "_evaluate_domain_checklist", return_value=domain_eval),
patch.object(server, "_get_internal_ip", return_value="192.168.1.44"),
patch.object(server, "_save_internal_ip"),
patch.object(server, "_get_listening_ports", return_value={"tcp": set(), "udp": set()}),
patch.object(server, "_get_firewall_allowed_ports", return_value={"tcp": set(), "udp": set()}),
):
result = await server.api_service_detail("livekit.service")
self.assertEqual(result["extra_ports"][0]["status"], "closed")
self.assertIn("Not ready yet", result["domain_check_steps"][-1]["detail"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,99 @@
"""Regression test for Starlette 1.1.0+ TemplateResponse keyword-argument style.
Prior to this fix, the three HTML routes called:
templates.TemplateResponse("name.html", {"request": request, ...})
which passes the context dict as the second positional argument. With the
updated Starlette/FastAPI versions shipped in NixOS unstable (Starlette 1.1.0,
FastAPI 0.136.3) that positional argument is the template name, causing Jinja2
to receive a dict as a cache key and raise:
TypeError: unhashable type: 'dict'
The fix updates every call to use keyword arguments:
templates.TemplateResponse(request=request, name="name.html", context={...})
"""
import ast
import unittest
from pathlib import Path
SERVER_PY = Path(__file__).resolve().parents[1] / "sovran_systemsos_web" / "server.py"
def _template_response_calls(source: str):
"""Return a list of ast.Call nodes that are TemplateResponse calls."""
tree = ast.parse(source)
calls = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if isinstance(func, ast.Attribute) and func.attr == "TemplateResponse":
calls.append(node)
return calls
class TemplateResponseSignatureTests(unittest.TestCase):
def setUp(self):
self.source = SERVER_PY.read_text()
self.calls = _template_response_calls(self.source)
def test_at_least_one_template_response_call_found(self):
self.assertGreater(len(self.calls), 0, "No TemplateResponse calls found in server.py")
def test_no_old_style_positional_dict_context(self):
"""No TemplateResponse call should pass a dict literal as its second positional arg.
The old style was:
templates.TemplateResponse("name.html", {"request": request, ...})
where args[0] is a string and args[1] is a Dict node. That pattern
triggers the Starlette 1.1.0 bug.
"""
for call in self.calls:
positional = call.args
if len(positional) >= 2 and isinstance(positional[1], ast.Dict):
self.fail(
f"Found old-style TemplateResponse call at line {call.lineno}: "
"second positional argument is a dict literal. "
"Use keyword arguments (request=, name=, context=) instead."
)
def test_request_not_duplicated_in_context(self):
"""The 'request' key must not appear inside the context= dict when
request= is already passed as a dedicated keyword argument."""
for call in self.calls:
kw_dict = {kw.arg: kw.value for kw in call.keywords if isinstance(kw, ast.keyword)}
if "request" not in kw_dict:
continue # no request= kwarg, nothing to check
context_node = kw_dict.get("context")
if not isinstance(context_node, ast.Dict):
continue
for key_node in context_node.keys:
if isinstance(key_node, ast.Constant) and key_node.value == "request":
self.fail(
f"TemplateResponse at line {call.lineno} passes 'request' both as "
"request= keyword argument and inside the context dict."
)
def test_all_calls_use_keyword_arguments(self):
"""Every TemplateResponse call should use keyword arguments for request, name,
and context rather than relying on positional ordering."""
for call in self.calls:
kw_args = {kw.arg for kw in call.keywords if isinstance(kw, ast.keyword)}
self.assertIn(
"request",
kw_args,
f"TemplateResponse at line {call.lineno} is missing keyword argument 'request='.",
)
self.assertIn(
"name",
kw_args,
f"TemplateResponse at line {call.lineno} is missing keyword argument 'name='.",
)
if __name__ == "__main__":
unittest.main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

+2 -2
View File
@@ -149,8 +149,8 @@
gparted pv unzip parted screen zenity gparted pv unzip parted screen zenity
libargon2 gnome-terminal libreoffice-fresh libargon2 gnome-terminal libreoffice-fresh
dig firefox wp-cli axel dig firefox wp-cli axel
lk-jwt-service livekit-libwebrtc livekit-cli livekit lk-jwt-service livekit-libwebrtc livekit
matrix-synapse age matrix-synapse age onlyoffice-desktopeditors
]; ];
# ── Shell ────────────────────────────────────────────────── # ── Shell ──────────────────────────────────────────────────
Generated
+21 -21
View File
@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1780397635, "lastModified": 1783519926,
"narHash": "sha256-6WH7LKD6i91VLWoz4mEpoULtqVinCEZxG7ZjJPMSi3k=", "narHash": "sha256-2zwAN4lNitHFrHVnRZG3YcvpdtWOoF0cOBstxMeB1KI=",
"owner": "emmanuelrosa", "owner": "emmanuelrosa",
"repo": "btc-clients-nix", "repo": "btc-clients-nix",
"rev": "feacd7684dc6bfcd49c57764944a2049bbd71924", "rev": "731a1e11c2fefb14f0aa4b1f03cfa85c19c28d71",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -52,11 +52,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1778716662, "lastModified": 1782949081,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -108,11 +108,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1780218263, "lastModified": 1782911660,
"narHash": "sha256-T/f0pPDrH3Qc1VXyQXbK7yfHWRn90l3xwplc/nsxin4=", "narHash": "sha256-PbR+tJ5E/Ux+01UtdFKqblccVA4/FgWbkym4ev3VHHQ=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "7fc393d1b46fa000d48ff14e8b6a3c9985f03af0", "rev": "cf720c15e108d432d29041cc5a185630809acefb",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -139,11 +139,11 @@
}, },
"nixpkgs-stable": { "nixpkgs-stable": {
"locked": { "locked": {
"lastModified": 1780453794, "lastModified": 1783856661,
"narHash": "sha256-bXMRa9VTsHSPXL4Cw8R6JJLQeY3Y/IP4+YJCYVmQ7FY=", "narHash": "sha256-ZGP04e+Q6WyQJGA9ZvI5CL6+heGQldbAG9U1T9NGvmU=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "6b316287bae2ee04c9b93c8c858d930fd07d7338", "rev": "569d578509928497eddc3fdbf94a799027050be4",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -187,11 +187,11 @@
}, },
"nixpkgs_3": { "nixpkgs_3": {
"locked": { "locked": {
"lastModified": 1780243769, "lastModified": 1783776592,
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=", "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c", "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -203,11 +203,11 @@
}, },
"nixpkgs_4": { "nixpkgs_4": {
"locked": { "locked": {
"lastModified": 1780336545, "lastModified": 1783791668,
"narHash": "sha256-vhVhuXzFrIOfcssC/9hDHx7MHzDKjF3keHuREOQqQiQ=", "narHash": "sha256-zbcZ1dmBTPfJ7Mlqh/yLEPGpgJnwuv4Xr1xucy2WqMA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "4df1b885d76a54e1aa1a318f8d16fd6005b6401f", "rev": "716c7a2664ca8325617b8a7fbb609273f2c4cae7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -224,11 +224,11 @@
"systems": "systems_2" "systems": "systems_2"
}, },
"locked": { "locked": {
"lastModified": 1780646548, "lastModified": 1783941741,
"narHash": "sha256-Ckyl/l1XBmEwnaHcHD8PvBZk1uph0NqwbJ//CAvB7iE=", "narHash": "sha256-F+3M1IZrJa920cx2/k2AMKqedEodxLF7COJVkLJwUBo=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixvim", "repo": "nixvim",
"rev": "816a15282e58678dde831477964987d0262d4293", "rev": "e6715f01d9f56f07a27a01386b85ae22b06f0705",
"type": "github" "type": "github"
}, },
"original": { "original": {
-1
View File
@@ -16,7 +16,6 @@ in
{ {
imports = [ imports = [
"${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix" "${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix"
./branding.nix
]; ];
image.baseName = lib.mkForce "Sovran_SystemsOS"; image.baseName = lib.mkForce "Sovran_SystemsOS";
+8 -5
View File
@@ -16,7 +16,10 @@ let
in in
{ {
services.caddy = { services.caddy = {
enable = true; # Only enable Caddy when at least one domain-based service needs it or
# the operator has defined custom vhosts. This prevents Caddy from
# running on Desktop Only installs that have no web services configured.
enable = needsHttpsPorts || extraVhosts != "";
user = "caddy"; user = "caddy";
group = "root"; group = "root";
}; };
@@ -94,10 +97,10 @@ EOF
$MATRIX { $MATRIX {
reverse_proxy /_matrix/* http://localhost:8008 reverse_proxy /_matrix/* http://localhost:8008
reverse_proxy /_synapse/client/* http://localhost:8008 reverse_proxy /_synapse/client/* http://localhost:8008
} handle /.well-known/matrix/server {
header Content-Type application/json
$MATRIX:8448 { respond \`{"m.server":"$MATRIX:443"}\` 200
reverse_proxy http://localhost:8008 }
} }
EOF EOF
fi fi
+22 -6
View File
@@ -3,6 +3,13 @@
{ {
config = lib.mkMerge [ config = lib.mkMerge [
# nix-bitcoin is globally imported by the flake (nixosModules.Sovran_SystemsOS).
# This default satisfies nix-bitcoin's generateSecrets assertion so that Desktop
# Only systems can evaluate without enabling any Bitcoin services.
{
nix-bitcoin.generateSecrets = lib.mkDefault true;
}
# ── Server+Desktop Role (default) ───────────────────────── # ── Server+Desktop Role (default) ─────────────────────────
(lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop { (lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop {
sovran_systemsOS.web.btcpayserver = lib.mkDefault true; sovran_systemsOS.web.btcpayserver = lib.mkDefault true;
@@ -12,15 +19,24 @@
(lib.mkIf config.sovran_systemsOS.roles.desktop { (lib.mkIf config.sovran_systemsOS.roles.desktop {
services.desktopManager.gnome.enable = true; services.desktopManager.gnome.enable = true;
# Force all server/node services and features off so they cannot be
# accidentally enabled via custom.nix or option defaults on Desktop Only.
sovran_systemsOS.services = { sovran_systemsOS.services = {
synapse = lib.mkDefault false; synapse = lib.mkForce false;
bitcoin = lib.mkDefault false; bitcoin = lib.mkForce false;
vaultwarden = lib.mkDefault false; vaultwarden = lib.mkForce false;
wordpress = lib.mkDefault false; wordpress = lib.mkForce false;
nextcloud = lib.mkDefault false; nextcloud = lib.mkForce false;
}; };
sovran_systemsOS.web.btcpayserver = lib.mkDefault false; sovran_systemsOS.features = {
haven = lib.mkForce false;
mempool = lib.mkForce false;
element-calling = lib.mkForce false;
bitcoin-core = lib.mkForce false;
};
sovran_systemsOS.web.btcpayserver = lib.mkForce false;
}) })
# ── Bitcoin Node Only Role ──────────────────────────────── # ── Bitcoin Node Only Role ────────────────────────────────
+9 -25
View File
@@ -146,33 +146,16 @@ let
echo "" echo ""
if [ "$RC" -eq 0 ]; then if [ "$RC" -eq 0 ]; then
echo " Step 2/3: nixos-rebuild " echo " Step 2/3: nixos-rebuild boot (stage next reboot) "
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \ BOOT_OUT=$(nixos-rebuild boot --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \ --option connect-timeout 10 \
--option stalled-download-timeout 90 \ --option stalled-download-timeout 90 \
--option download-attempts 7 \ --option download-attempts 7 \
--option fallback true 2>&1) --option fallback true 2>&1)
SWITCH_RC=$? BOOT_RC=$?
echo "$SWITCH_OUT" echo "$BOOT_OUT"
if [ "$SWITCH_RC" -eq 0 ]; then if [ "$BOOT_RC" -ne 0 ]; then
echo "[OK] switch succeeded" echo "[ERROR] nixos-rebuild boot failed"
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then
echo ""
echo " Build succeeded a reboot is required to apply this update"
echo " (Critical system components changed; running nixos-rebuild boot instead)"
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1; then
echo "REBOOT_REQUIRED" > "$STATUS"
exit 0
else
echo "[ERROR] nixos-rebuild boot also failed"
RC=1
fi
else
echo "[ERROR] nixos-rebuild switch failed"
RC=1 RC=1
fi fi
echo "" echo ""
@@ -188,9 +171,10 @@ let
if [ "$RC" -eq 0 ]; then if [ "$RC" -eq 0 ]; then
echo "" echo ""
echo " Update completed successfully" echo " Update staged successfully"
echo " Reboot required to activate the new system"
echo "" echo ""
echo "SUCCESS" > "$STATUS" echo "REBOOT_REQUIRED" > "$STATUS"
else else
echo "" echo ""
echo " Update failed see errors above" echo " Update failed see errors above"
+36 -3
View File
@@ -31,7 +31,7 @@ lib.mkIf userExists {
}; };
systemd.services.factory-ssh-keygen = { systemd.services.factory-ssh-keygen = {
description = "Generate factory SSH key for ${userName} if missing"; description = "Generate or repair factory SSH key for ${userName}";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "ssh-passphrase-setup.service" ]; after = [ "ssh-passphrase-setup.service" ];
requires = [ "ssh-passphrase-setup.service" ]; requires = [ "ssh-passphrase-setup.service" ];
@@ -39,14 +39,47 @@ lib.mkIf userExists {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
}; };
path = [ pkgs.openssh pkgs.coreutils ]; path = [ pkgs.openssh pkgs.coreutils pkgs.util-linux ];
script = '' script = ''
if [ ! -f "${keyPath}" ]; then set -eu
PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase) PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase)
lock_file="${keyPath}.lock"
exec 9>"$lock_file"
if ! flock -n 9; then
echo "Factory SSH key setup is already running." >&2
exit 1
fi
generate_factory_key() {
ssh-keygen -q -N "$PASSPHRASE" -t ed25519 -f "${keyPath}" ssh-keygen -q -N "$PASSPHRASE" -t ed25519 -f "${keyPath}"
chown ${userName}:users "${keyPath}" "${keyPath}.pub" chown ${userName}:users "${keyPath}" "${keyPath}.pub"
chmod 600 "${keyPath}" chmod 600 "${keyPath}"
chmod 644 "${keyPath}.pub" chmod 644 "${keyPath}.pub"
}
if [ ! -f "${keyPath}" ]; then
generate_factory_key
elif ! ssh-keygen -y -P "$PASSPHRASE" -f "${keyPath}" >/dev/null 2>&1; then
backup_suffix="$(date -u +%Y%m%d_%H%M%S)-$$"
backup_path="${keyPath}.bak-$backup_suffix"
backup_index=0
while [ -e "$backup_path" ] || [ -e "$backup_path.pub" ]; do
backup_index=$((backup_index + 1))
backup_path="${keyPath}.bak-$backup_suffix-$backup_index"
done
echo "Existing factory SSH key does not match current passphrase; backing it up to $backup_path and generating a replacement."
mv "${keyPath}" "$backup_path"
if [ -f "${keyPath}.pub" ]; then
mv "${keyPath}.pub" "$backup_path.pub"
fi
generate_factory_key
fi fi
''; '';
}; };
+103 -21
View File
@@ -34,8 +34,8 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
}; };
####### ENSURE SERVICES START AFTER KEY EXISTS ####### ####### ENSURE SERVICES START AFTER KEY EXISTS #######
systemd.services.livekit.after = [ "livekit-key-setup.service" ]; systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
systemd.services.livekit.wants = [ "livekit-key-setup.service" ]; systemd.services.livekit.wants = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ]; systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ]; systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
@@ -68,35 +68,54 @@ $MATRIX {
header /.well-known/matrix/* Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" header /.well-known/matrix/* Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
header /.well-known/matrix/* Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization" header /.well-known/matrix/* Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization"
respond /.well-known/matrix/client \`{ "m.homeserver": {"base_url": "https://$MATRIX" }, "org.matrix.msc4143.rtc_foci": [{ "type":"livekit", "livekit_service_url":"https://$ELEMENT_CALLING/livekit/jwt" }] }\` respond /.well-known/matrix/client \`{ "m.homeserver": {"base_url": "https://$MATRIX" }, "org.matrix.msc4143.rtc_foci": [{ "type":"livekit", "livekit_service_url":"https://$ELEMENT_CALLING/livekit/jwt" }] }\`
} respond /.well-known/matrix/server \`{"m.server":"$MATRIX:443"}\`
$MATRIX:8448 {
reverse_proxy http://localhost:8008
} }
$ELEMENT_CALLING { $ELEMENT_CALLING {
handle /livekit/jwt/sfu/get { # Route all current lk-jwt-service authorization endpoints to port 8073,
# stripping the /livekit/jwt prefix that Caddy adds on the public URL.
@lk_jwt path /livekit/jwt/sfu/get* /livekit/jwt/get_token* /livekit/jwt/healthz* /livekit/jwt/sfu_webhook* /livekit/jwt/delegate_delayed_leave*
handle @lk_jwt {
uri strip_prefix /livekit/jwt uri strip_prefix /livekit/jwt
reverse_proxy [::1]:8073 { reverse_proxy [::1]:8073 {
header_up Host {host} header_up Host {host}
header_up X-Forwarded-Server {host} header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_host} header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host} header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
} }
} }
handle { handle {
reverse_proxy localhost:7880 reverse_proxy localhost:7880 {
header_up Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
header_up X-Real-IP {remote_host}
transport http {
read_timeout 300s
write_timeout 300s
}
}
} }
} }
EOF EOF
''; '';
}; };
####### LIVEKIT RUNTIME CONFIG ####### ####### LIVEKIT TURN SETUP (runtime cert + config) #######
systemd.services.livekit-runtime-config = { # Replaces the old dead livekit-runtime-config.service. At runtime this:
description = "Generate LiveKit runtime config from domain files"; # * reads the matrix domain from /var/lib/domains/matrix (never hardcoded)
# * copies Caddy's already-issued matrix cert/key into /var/lib/livekit
# so LoadCredential can stage them for the (DynamicUser) livekit unit
# * detects the primary network interface from the IPv4 default route so
# LiveKit only advertises real ICE candidates — not VPN/container/private
# addresses from interfaces like Tailscale or Docker bridges
# * writes a complete LiveKit config (with turn.domain and interface
# substituted) that the overridden ExecStart loads.
systemd.services.livekit-turn-setup = {
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
after = [ "caddy.service" "livekit-key-setup.service" ];
before = [ "livekit.service" ]; before = [ "livekit.service" ];
after = [ "livekit-key-setup.service" ];
requiredBy = [ "livekit.service" ]; requiredBy = [ "livekit.service" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig = { serviceConfig = {
@@ -106,20 +125,63 @@ EOF
unitConfig = { unitConfig = {
ConditionPathExists = "/var/lib/domains/element-calling"; ConditionPathExists = "/var/lib/domains/element-calling";
}; };
path = [ pkgs.coreutils ]; path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ];
script = '' script = ''
MATRIX=$(cat /var/lib/domains/matrix) MATRIX=$(cat /var/lib/domains/matrix)
mkdir -p /run/livekit mkdir -p /run/livekit
cat > /run/livekit/runtime-config.yaml <<EOF # Copy Caddy's already-issued matrix cert/key into LiveKit's state dir.
# The ACME CA hostname directory can vary, so glob for the domain dir.
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
cp "$CRT" /var/lib/livekit/turn.crt
cp "$KEY" /var/lib/livekit/turn.key
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
# Detect the primary network interface from the IPv4 default route.
# Restricting LiveKit to this single interface prevents it from
# advertising VPN/container/private ICE candidates (e.g. Tailscale,
# Docker bridges) that remote peers cannot reach, which causes all
# ICE negotiation attempts to fail with responsesReceived: 0.
IFACE=$(ip -4 route show default | awk '/^default/ { for(i=1;i<=NF;i++) if($i=="dev" && (i+1)<=NF) { print $(i+1); exit } }')
if [ -z "$IFACE" ]; then
echo "ERROR: Could not detect a default-route network interface from 'ip -4 route show default'." >&2
echo "ERROR: Cannot generate a valid LiveKit config without a real interface to bind ICE candidates to." >&2
echo "ERROR: Ensure a default IPv4 route is configured, e.g.: ip route add default via <gateway> dev <interface>" >&2
echo "ERROR: Inspect the current routing table with: ip -4 route show" >&2
exit 1
fi
echo "Detected primary network interface: $IFACE"
# Generate the full LiveKit config the daemon will load. turn.domain and
# rtc.interfaces.includes are only known at runtime, so they are
# substituted here. The cert/key paths point at the LoadCredential-staged
# copies under /run/credentials.
cat > /run/livekit/livekit.yaml <<EOF
port: 7880
rtc:
use_external_ip: true
skip_external_ip_validation: true
tcp_port: 7881
udp_port: 7882
port_range_start: 30000
port_range_end: 40000
interfaces:
includes:
- $IFACE
room:
auto_create: false
turn: turn:
enabled: true
domain: $MATRIX domain: $MATRIX
cert_file: /var/lib/livekit/$MATRIX.crt tls_port: 5349
key_file: /var/lib/livekit/$MATRIX.key udp_port: 3478
cert_file: /run/credentials/livekit.service/turn-cert
key_file: /run/credentials/livekit.service/turn-key
EOF EOF
chmod 640 /run/livekit/runtime-config.yaml chmod 644 /run/livekit/livekit.yaml
''; '';
}; };
@@ -130,7 +192,11 @@ EOF
keyFile = livekitKeyFile; keyFile = livekitKeyFile;
settings = { settings = {
rtc.use_external_ip = true; rtc.use_external_ip = true;
rtc.skip_external_ip_validation = true;
rtc.tcp_port = 7881;
rtc.udp_port = 7882; rtc.udp_port = 7882;
rtc.port_range_start = 30000;
rtc.port_range_end = 40000;
room.auto_create = false; room.auto_create = false;
turn = { turn = {
enabled = true; enabled = true;
@@ -140,13 +206,27 @@ EOF
}; };
}; };
# Override ExecStart to load the runtime-generated config (which carries the
# runtime-only turn.domain), mirroring the Caddy ExecStart override pattern in
# modules/core/caddy.nix. Deliver the TURN cert/key via LoadCredential so they
# are readable under the upstream unit's DynamicUser=true sandbox without
# weakening it. Everything else about the standard unit is left intact.
systemd.services.livekit.serviceConfig.ExecStart = lib.mkForce [
""
"${pkgs.livekit}/bin/livekit-server --config /run/credentials/livekit.service/livekit-config --key-file /run/credentials/livekit.service/livekit-secrets"
];
systemd.services.livekit.serviceConfig.LoadCredential = [
"livekit-config:/run/livekit/livekit.yaml"
"livekit-secrets:${livekitKeyFile}"
"turn-cert:/var/lib/livekit/turn.crt"
"turn-key:/var/lib/livekit/turn.key"
];
networking.firewall.allowedTCPPorts = [ 5349 7881 ]; networking.firewall.allowedTCPPorts = [ 5349 7881 ];
networking.firewall.allowedUDPPorts = [ 3478 7882 ]; networking.firewall.allowedUDPPorts = [ 3478 7882 ];
networking.firewall.allowedUDPPortRanges = [ networking.firewall.allowedUDPPortRanges = [
{ from = 30000; to = 40000; } { from = 30000; to = 40000; } # LiveKit internal TURN relay range
];
networking.firewall.allowedTCPPortRanges = [
{ from = 30000; to = 40000; }
]; ];
####### JWT SERVICE RUNTIME CONFIG ####### ####### JWT SERVICE RUNTIME CONFIG #######
@@ -166,11 +246,13 @@ EOF
path = [ pkgs.coreutils ]; path = [ pkgs.coreutils ];
script = '' script = ''
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling) ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
MATRIX=$(cat /var/lib/domains/matrix)
mkdir -p /run/lk-jwt-service mkdir -p /run/lk-jwt-service
cat > /run/lk-jwt-service/env <<EOF cat > /run/lk-jwt-service/env <<EOF
LIVEKIT_URL=wss://$ELEMENT_CALLING LIVEKIT_URL=wss://$ELEMENT_CALLING
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX
EOF EOF
chmod 640 /run/lk-jwt-service/env chmod 640 /run/lk-jwt-service/env
Executable → Regular
+121 -56
View File
@@ -2,70 +2,104 @@
lib.mkIf config.sovran_systemsOS.features.rdp { lib.mkIf config.sovran_systemsOS.features.rdp {
users.users.gnome-remote-desktop = {
isSystemUser = true;
group = "gnome-remote-desktop";
home = "/var/lib/gnome-remote-desktop";
createHome = true;
};
users.groups.gnome-remote-desktop = {};
# Enable the GNOME Remote Desktop service at the system level # Enable the GNOME Remote Desktop service at the system level
services.gnome.gnome-remote-desktop.enable = true; services.gnome.gnome-remote-desktop.enable = true;
# Open RDP port in the firewall # Open RDP port in the firewall
networking.firewall.allowedTCPPorts = [ 3389 ]; networking.firewall.allowedTCPPorts = [ 3389 ];
# Ensure the service actually starts and waits for setup to complete # Ensure the service only starts after setup succeeds
systemd.services.gnome-remote-desktop = { systemd.services.gnome-remote-desktop = {
wantedBy = [ "graphical.target" ]; wantedBy = [ "graphical.target" ];
after = [ "gnome-remote-desktop-setup.service" ]; after = [ "gnome-remote-desktop-setup.service" ];
wants = [ "gnome-remote-desktop-setup.service" ]; requires = [ "gnome-remote-desktop-setup.service" ];
}; };
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -" "d /var/lib/gnome-remote-desktop/.local 0700 gnome-remote-desktop gnome-remote-desktop -"
"d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -" "d /var/lib/gnome-remote-desktop/.local/share 0700 gnome-remote-desktop gnome-remote-desktop -"
"d /var/lib/gnome-remote-desktop/.local/share 0750 gnome-remote-desktop gnome-remote-desktop -" "d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0700 gnome-remote-desktop gnome-remote-desktop -"
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -" "d /var/lib/gnome-remote-desktop/tls 0700 gnome-remote-desktop gnome-remote-desktop -"
]; ];
systemd.services.gnome-remote-desktop-setup = { systemd.services.gnome-remote-desktop-setup = {
description = "Configure GNOME Remote Desktop RDP"; description = "Configure GNOME Remote Desktop RDP";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "graphical.target" ];
before = [ "gnome-remote-desktop.service" ]; before = [ "gnome-remote-desktop.service" ];
after = [ "systemd-tmpfiles-setup.service" "network-online.target" ]; after = [
wants = [ "network-online.target" ]; "dbus.service"
"systemd-tmpfiles-setup.service"
"network-online.target"
"gnome-remote-desktop-configuration.service"
];
wants = [
"network-online.target"
"gnome-remote-desktop-configuration.service"
];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; TimeoutStartSec = "2min";
}; };
path = [ path = [
pkgs.gnome-remote-desktop pkgs.coreutils
pkgs.polkit
pkgs.openssl
pkgs.hostname
pkgs.gawk pkgs.gawk
pkgs.gnome-remote-desktop
pkgs.hostname
pkgs.openssl
pkgs.systemd
]; ];
script = '' script = ''
# Ensure directory structure exists set -euo pipefail
mkdir -p /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop
chown -R gnome-remote-desktop:gnome-remote-desktop /var/lib/gnome-remote-desktop
TLS_DIR="/var/lib/gnome-remote-desktop/tls" # GRD 50.x invokes pkexec internally for every grdctl --system call, even
CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials" # when the caller is root. NixOS exposes the required setuid wrapper at
# /run/wrappers/bin/pkexec; the Nix-store polkit binary is not setuid and
# must not shadow it. Prepend the wrapper directory so every subsequent
# grdctl --system resolves the correct binary.
export PATH="/run/wrappers/bin:$PATH"
STATE_DIR="/var/lib/gnome-remote-desktop"
TLS_DIR="$STATE_DIR/tls"
USERNAME_FILE="$STATE_DIR/rdp-username"
PASSWORD_FILE="$STATE_DIR/rdp-password"
CRED_FILE="$STATE_DIR/rdp-credentials"
DEFAULT_USERNAME="sovran"
grdctl_system() {
local rc=0
if timeout --kill-after=5s 10s \
grdctl --system "$@"; then
return 0
else
rc=$?
fi
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
echo "grdctl command timed out: $*" >&2
fi
echo "grdctl command failed (exit $rc): $*" >&2
return "$rc"
}
mkdir -p "$STATE_DIR/.local/share/gnome-remote-desktop" "$TLS_DIR"
chown -R gnome-remote-desktop:gnome-remote-desktop "$STATE_DIR"
chmod 700 \
"$STATE_DIR" \
"$STATE_DIR/.local" \
"$STATE_DIR/.local/share" \
"$STATE_DIR/.local/share/gnome-remote-desktop" \
"$TLS_DIR"
# Regenerate TLS certificate if missing OR if ownership is wrong
# (disable/re-enable cycle can break ownership or grdctl state)
NEED_REGEN=0 NEED_REGEN=0
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
NEED_REGEN=1 NEED_REGEN=1
elif [ "$(stat -c '%U' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop" ]; then elif [ "$(stat -c '%U:%G' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop:gnome-remote-desktop" ]; then
NEED_REGEN=1 NEED_REGEN=1
fi fi
if [ "$NEED_REGEN" = "1" ]; then if [ "$NEED_REGEN" = "1" ]; then
mkdir -p "$TLS_DIR"
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt" rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-sha256 -nodes -days 3650 \ -sha256 -nodes -days 3650 \
@@ -75,39 +109,59 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
echo "Generated new RDP TLS certificate" echo "Generated new RDP TLS certificate"
fi fi
# Always fix ownership and permissions (handles re-enable after disable) chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
chmod 600 "$TLS_DIR/rdp-tls.key" chmod 600 "$TLS_DIR/rdp-tls.key"
chmod 644 "$TLS_DIR/rdp-tls.crt" chmod 644 "$TLS_DIR/rdp-tls.crt"
# Configure TLS certificate if [ ! -f "$USERNAME_FILE" ]; then
grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt" printf '%s\n' "$DEFAULT_USERNAME" > "$USERNAME_FILE"
grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key" fi
USERNAME="$(tr -d '\n' < "$USERNAME_FILE")"
if [ -z "$USERNAME" ]; then
USERNAME="$DEFAULT_USERNAME"
printf '%s\n' "$USERNAME" > "$USERNAME_FILE"
fi
if [ "''${#USERNAME}" -gt 32 ]; then
echo "RDP username is too long (''${#USERNAME} characters, maximum 32): $USERNAME from $USERNAME_FILE" >&2
exit 1
fi
case "$USERNAME" in
[A-Za-z_][A-Za-z0-9_-]*)
;;
*)
echo "RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens: $USERNAME from $USERNAME_FILE" >&2
exit 1
;;
esac
chown gnome-remote-desktop:gnome-remote-desktop "$USERNAME_FILE"
chmod 600 "$USERNAME_FILE"
# Generate password on first boot only if [ ! -f "$PASSWORD_FILE" ]; then
PASSWORD="" openssl rand -base64 16 > "$PASSWORD_FILE"
if [ ! -f /var/lib/gnome-remote-desktop/rdp-password ]; then fi
PASSWORD=$(openssl rand -base64 16) PASSWORD="$(tr -d '\n' < "$PASSWORD_FILE")"
echo "$PASSWORD" > /var/lib/gnome-remote-desktop/rdp-password if [ -z "$PASSWORD" ]; then
chmod 600 /var/lib/gnome-remote-desktop/rdp-password echo "RDP password file is empty: $PASSWORD_FILE" >&2
else exit 1
PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password) fi
if [ "''${#PASSWORD}" -lt 8 ]; then
echo "RDP password is too short (''${#PASSWORD} characters, minimum 8): $PASSWORD_FILE" >&2
exit 1
fi
chown gnome-remote-desktop:gnome-remote-desktop "$PASSWORD_FILE"
chmod 600 "$PASSWORD_FILE"
LOCAL_IP="$(hostname -I | awk '{print $1}')"
if [ -z "$LOCAL_IP" ]; then
LOCAL_IP="127.0.0.1"
fi fi
# Write username to a separate file for the hub
echo "sovran" > /var/lib/gnome-remote-desktop/rdp-username
chmod 600 /var/lib/gnome-remote-desktop/rdp-username
# Get current IP address
LOCAL_IP=$(hostname -I | awk '{print $1}')
# Always rewrite the credentials file with the current IP
cat > "$CRED_FILE" <<EOF cat > "$CRED_FILE" <<EOF
======================================== ========================================
GNOME Remote Desktop (RDP) Credentials GNOME Remote Desktop (RDP) Credentials
======================================== ========================================
Username: sovran Username: $USERNAME
Password: $PASSWORD Password: $PASSWORD
Connect from any RDP client to: Connect from any RDP client to:
@@ -116,11 +170,22 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
======================================== ========================================
EOF EOF
chown gnome-remote-desktop:gnome-remote-desktop "$CRED_FILE"
chmod 600 "$CRED_FILE" chmod 600 "$CRED_FILE"
# Enable RDP backend and set credentials # Preflight: the NixOS setuid pkexec wrapper must be present and executable
grdctl --system rdp enable # before any grdctl --system call. Absence means the system was booted
grdctl --system rdp set-credentials sovran "$PASSWORD" # without security.wrappers or the wrapper directory is not mounted yet.
if ! test -x /run/wrappers/bin/pkexec; then
echo "Preflight check failed: /run/wrappers/bin/pkexec is absent or not executable." >&2
echo "GNOME Remote Desktop system configuration requires the NixOS setuid pkexec wrapper at /run/wrappers/bin/pkexec." >&2
exit 1
fi
grdctl_system rdp enable
grdctl_system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
grdctl_system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"
echo "GNOME Remote Desktop RDP configured successfully" echo "GNOME Remote Desktop RDP configured successfully"
''; '';
-3
View File
@@ -250,9 +250,6 @@ CREDS
''; '';
}; };
networking.firewall.allowedTCPPorts = [ 8448 ];
networking.firewall.allowedUDPPorts = [ 8448 ];
sovran_systemsOS.domainRequirements = [ sovran_systemsOS.domainRequirements = [
{ name = "matrix"; label = "Matrix Synapse"; example = "matrix.yourdomain.com"; } { name = "matrix"; label = "Matrix Synapse"; example = "matrix.yourdomain.com"; }
]; ];