708 Commits
Author SHA1 Message Date
naturallaw777 a97cb1adba updated nixpkgs and btc clients 2026-07-25 09:37:18 -05:00
Sovran SystemsandGitHub 210cef99f9 Merge pull request #334 from naturallaw777/copilot/fix-manual-backup-rsync-failure
Fix rsync destination-directory failure: sync_tree with mkdir -p, mount re-verification, and path-safety guards
2026-07-21 00:06:54 +00:00
copilot-swe-agent[bot]andGitHub 1732bb0a2f Fix CodeQL false-positive: rename secret.key test fixture to bitcoin-key.txt 2026-07-21 00:04:58 +00:00
copilot-swe-agent[bot]andGitHub 68b86bdf49 Fix rsync destination-directory failure: add sync_tree with mkdir -p, mount check, path-safety, stale-marker cleanup, and 19 behavioral tests 2026-07-21 00:03:12 +00:00
copilot-swe-agent[bot]andGitHub e294a4057d Initial plan 2026-07-20 23:56:15 +00:00
Sovran SystemsandGitHub b98c82886f Merge pull request #333 from naturallaw777/copilot/replace-manual-backup-implementation
Replace Manual Backup: tar+DB+LND → ext4+rsync mirror
2026-07-20 22:51:38 +00:00
copilot-swe-agent[bot]andGitHub e16eaabdde refactor: rename CURRENT_DIR_NAME to BACKUP_SUBPATH for clarity 2026-07-20 22:47:53 +00:00
copilot-swe-agent[bot]andGitHub 0fa804a430 feat: replace tar+DB+LND backup with ext4+rsync workflow
- Rewrite sovran-hub-backup.sh: rsync-based mirror to stable current/
  path, ext4 validation, no tar/pg_dump/mariadb-dump/LND orchestration,
  exit-24 nonfatal for /home, INCOMPLETE/BACKUP_COMPLETE markers, flock
- Update server.py: _is_supported_backup_fstype accepts only ext4
- Update support.js: require ext4, explain database limitations, update
  failure message from exFAT to ext4
- Update sovran-hub.nix: replace pkgs.gnutar with pkgs.rsync + pkgs.acl
- Rewrite test_manual_backup_workflow.py: 40 new tests covering rsync
  options, ext4 acceptance, exit-24 handling, no-delete, stable current/
  path, INCOMPLETE markers, behavioral rsync tests, and regressions
2026-07-20 22:45:04 +00:00
copilot-swe-agent[bot]andGitHub 01db44f0d2 Initial plan 2026-07-20 22:34:57 +00:00
naturallaw777 978a82ade3 nixpkgs update 2026-07-19 16:59:19 -05:00
Sovran SystemsandGitHub c202c69898 Merge pull request #332 from naturallaw777/copilot/fix-manual-backup-failure
Fix home.tar exit-1 failure on active desktop; harden full backup workflow
2026-07-18 18:21:07 +00:00
copilot-swe-agent[bot]andGitHub d1251bf238 Final polish: explicit LOCK_FD close, tar --list SIGPIPE comment, marker timing comment, _extract_bash_function docstring 2026-07-18 18:11:19 +00:00
copilot-swe-agent[bot]andGitHub de25110493 Address code review round 2: Python regex for function extraction, bash 4.1+ comment, find -print0 for manifest, mktemp 0600 comment 2026-07-18 18:08:33 +00:00
copilot-swe-agent[bot]andGitHub ab31f4f60b Address code review: source allowlist function from script in tests, fix find exclusions for BACKUP_COMPLETE, simplify log message 2026-07-18 18:05:46 +00:00
copilot-swe-agent[bot]andGitHub f5a5e99de4 Fix test assertion pattern and replace em dashes in new log messages 2026-07-18 18:02:41 +00:00
copilot-swe-agent[bot]andGitHub a30d4d5e74 Fix home.tar exit-1 failure: add tar tolerance, partial atomics, INCOMPLETE marker, flock, browser cache exclusions 2026-07-18 18:00:29 +00:00
copilot-swe-agent[bot]andGitHub 12b8872847 Initial plan 2026-07-18 17:44:54 +00:00
Sovran SystemsandGitHub 56e0c4dcd2 Merge pull request #331 from naturallaw777/copilot/fix-manual-backup-failure
Fix Manual Backup exit-code-127: add bash+gawk to service PATH, harden launcher
2026-07-18 16:40:29 +00:00
copilot-swe-agent[bot]andGitHub 22aa251d64 Address code review: compute stderr_text only when chunks are present 2026-07-18 16:30:38 +00:00
copilot-swe-agent[bot]andGitHub 35c4de412f Fix Manual Backup exit-code-127: add bash+gawk to service PATH, harden launcher 2026-07-18 16:29:10 +00:00
copilot-swe-agent[bot]andGitHub 16067be909 Initial plan 2026-07-18 16:23:49 +00:00
Sovran SystemsandGitHub 36abece7f9 Merge pull request #330 from naturallaw777/copilot/implement-manual-backup-workflow
Implement reliable exFAT Manual Backup with tar artifacts, DB exports, and lifecycle hardening
2026-07-18 15:31:38 +00:00
copilot-swe-agent[bot]andGitHub 36187c0504 Refine backup validation and manifest details 2026-07-17 17:00:32 +00:00
copilot-swe-agent[bot]andGitHub 992c806ed7 Address validation feedback for backup workflow 2026-07-17 16:58:34 +00:00
copilot-swe-agent[bot]andGitHub 9f3d3e7670 Implement reliable exFAT manual backup workflow 2026-07-17 16:56:08 +00:00
copilot-swe-agent[bot]andGitHub 0ec8203557 Initial plan 2026-07-17 16:49:35 +00:00
Sovran SystemsandGitHub ab4de8da7d Merge pull request #329 from naturallaw777/copilot/fix-nix-build-regression
Fix `sovran-hosts-update` ShellCheck build regression from `writeShellApplication`
2026-07-16 20:40:41 +00:00
copilot-swe-agent[bot]andGitHub 92cf417760 test: harden flake configuration detection 2026-07-16 20:38:48 +00:00
copilot-swe-agent[bot]andGitHub ec4c1c851b test: derive nix helper build attr from flake 2026-07-16 20:37:48 +00:00
copilot-swe-agent[bot]andGitHub 38f49e9161 fix: group sovran hosts append redirection 2026-07-16 20:36:33 +00:00
copilot-swe-agent[bot]andGitHub c853853616 Initial plan 2026-07-16 20:33:53 +00:00
Sovran SystemsandGitHub 3e2bc106b1 Merge pull request #328 from naturallaw777/copilot/fix-sovran-hosts-update-runtime-dependency
fix(local-domain-loopback): replace raw /etc script with writeShellApplication, declare explicit runtimeInputs
2026-07-16 20:28:39 +00:00
copilot-swe-agent[bot]andGitHub d4f8c7b431 fix: convert sovran-hosts-update to writeShellApplication with explicit runtimeInputs
- Replace environment.etc raw script with pkgs.writeShellApplication
- Declare runtimeInputs: pkgs.coreutils, pkgs.gawk, pkgs.gnugrep
- Use awk -v for safe marker variable passing (no shell interpolation)
- Point systemd ExecStart and activation script at lib.getExe hostsUpdateScript
- Keep /etc/sovran-hosts-update.sh as a source symlink for operator discoverability
- Remove environment.systemPackages reliance
- Emit warning (not silently swallow) on activation failure
- Add structural regression tests (19 new tests, all passing)
2026-07-16 20:26:59 +00:00
copilot-swe-agent[bot]andGitHub dcbac4760f Initial plan 2026-07-16 20:23:44 +00:00
naturallaw777 2b8ee5ff26 updated readme 2026-07-15 15:25:14 -05:00
Sovran SystemsandGitHub b4990d70ef Merge pull request #327 from naturallaw777/copilot/remove-restart-sidebar-action
Move Reboot from sidebar to compact header button
2026-07-15 18:59:25 +00:00
copilot-swe-agent[bot]andGitHub 59b734995b feat: move Reboot button from sidebar to header, between role badge and Sign Out 2026-07-15 18:58:10 +00:00
copilot-swe-agent[bot]andGitHub 599405ce18 Initial plan 2026-07-15 18:55:54 +00:00
Sovran SystemsandGitHub 4b31255f13 Merge pull request #326 from naturallaw777/copilot/add-system-restart-action
feat: implement consistent whole-system restart UX across the Hub
2026-07-15 18:26:26 +00:00
copilot-swe-agent[bot]andGitHub 6b79c212a8 refactor: improve variable name in openRestartConfirmDialog for clarity 2026-07-15 16:51:52 +00:00
copilot-swe-agent[bot]andGitHub c3950547b0 feat: implement consistent restart UX across Sovran_SystemsOS Hub
- Add Restart Entire System sidebar action with amber treatment and divider
- Add shared restart confirmation dialog with conflict detection
- Update reboot overlay: new title, body copy, status progression, error card
- Improve doReboot(): failure handling, aria-live status messages
- Standardize 'restart required' / 'Restart Entire System' terminology
- Update security.js reboot flow to use shared doReboot()
- Update installer.py button label
- Add .btn-restart-amber, .restart-conflict-box, .sidebar-restart-btn CSS
2026-07-15 16:50:01 +00:00
copilot-swe-agent[bot]andGitHub b28d6dd32c Initial plan 2026-07-15 16:41:20 +00:00
Sovran SystemsandGitHub 7ac3775a96 Merge pull request #325 from naturallaw777/copilot/implement-server-side-loopback-solution
feat: server-side loopback overrides + Hub diagnostic fixes for NAT loopback
2026-07-15 16:21:10 +00:00
copilot-swe-agent[bot]andGitHub de9b069a88 fix: address code review feedback
- Use set -eu (not set -euf) in sovran-hosts-update.sh
- Add sync note for domain validation regex between shell and Python
- Rename `seen` to `unique_addresses` in _resolve_all_addresses
- Improve loopback override UI message with external DNS check guidance
- Add docstring note about first-address display in _resolve_all_addresses"
2026-07-15 15:26:43 +00:00
copilot-swe-agent[bot]andGitHub 2cb0c734d8 feat: server-side loopback overrides and Hub diagnostic fixes
- Add modules/core/local-domain-loopback.nix: systemd service and
  activation script that write configured service domains to a
  Sovran-managed block in /etc/hosts (127.0.0.1 / ::1) so requests
  originating on this computer reach Caddy without NAT loopback.

- Import local-domain-loopback.nix in modules/modules.nix.

- server.py: add _validate_domain_value, _is_loopback_address,
  _resolve_all_addresses, _trigger_hosts_update helpers.

- server.py: update _check_domain_reachable to use --resolve so
  reachability is checked locally via Caddy, not via NAT loopback.

- server.py: update _evaluate_domain_checklist, api_services inline DNS
  check, and api_domains_check to recognise loopback resolution as an
  intentional local override rather than a DNS mismatch.

- server.py: call _trigger_hosts_update from api_domains_set after
  saving a service domain so the /etc/hosts entry is applied immediately.

- Add app/tests/test_loopback_diagnostics.py with 47 tests covering
  domain validation, loopback detection, diagnostic checklist logic,
  composite health, and api_domains_check."
2026-07-15 15:24:16 +00:00
copilot-swe-agent[bot]andGitHub 6f908513e3 Initial plan 2026-07-15 15:14:53 +00:00
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
naturallaw777 dabb96e1b3 sync and removed element-desktop and bitwarden desktop 2026-06-05 10:29:20 -05:00
naturallaw777 2b5a154b99 updated nix packages 2026-06-05 10:27:51 -05:00
naturallaw777 e475b0f47d updated stable branch 2026-06-05 10:04:13 -05:00
naturallaw777 8f81f8f1e2 moved to new bitcoin-knots with bip110 2026-06-04 16:06:58 -05:00
Sovran SystemsandGitHub cd753a7e28 Merge pull request #308 from naturallaw777/copilot/fix-bip110-detection
Detect Knots `reduced_data` (RDTS) as BIP-110 in live status and add regression coverage
2026-06-04 15:18:10 -05:00
copilot-swe-agent[bot]andGitHub 7ac1985508 Refine BIP110 matching and add regression coverage 2026-06-04 20:15:35 +00:00
copilot-swe-agent[bot]andGitHub 0ecf2eb651 Fix BIP110 detection for reduced_data deployments 2026-06-04 20:11:58 +00:00
copilot-swe-agent[bot]andGitHub 18c7095aaf Initial plan 2026-06-04 20:07:44 +00:00
Sovran SystemsandGitHub dcad276c59 Merge pull request #307 from naturallaw777/copilot/update-bip110-status-surface
Surface live BIP-110 deployment status on Bitcoin Knots tile
2026-06-04 14:51:22 -05:00
copilot-swe-agent[bot]andGitHub 06988d0ff0 Fix docstring accuracy, extract _firstElementFromHtml helper, address all code review feedback 2026-06-04 19:49:01 +00:00
copilot-swe-agent[bot]andGitHub 69b84153b4 Address code review: tighten bip110 key matching, fix redundant condition, extract shared badge config, add CSS classes 2026-06-04 19:46:40 +00:00
copilot-swe-agent[bot]andGitHub df08a7c413 Add live BIP-110 deployment status: new helpers, endpoint, badge UI 2026-06-04 19:42:23 +00:00
copilot-swe-agent[bot]andGitHub 602464189f Initial plan 2026-06-04 19:36:51 +00:00
Sovran SystemsandGitHub 67f4cdc99e Merge pull request #306 from naturallaw777/copilot/remove-bip110-feature-toggle
bip110 deprecation shim: tolerate stale custom.nix and auto-clean on Hub startup
2026-06-04 14:26:06 -05:00
copilot-swe-agent[bot]andGitHub f8c717db25 Address code review: fix whitespace and log migration exceptions 2026-06-04 19:18:28 +00:00
copilot-swe-agent[bot]andGitHub 268abddb28 Add deprecated bip110 no-op shim and Hub migration
- modules/core/roles.nix: re-declare bip110 as a nullOr bool no-op
  option so existing custom.nix files with `lib.mkForce true` continue
  to evaluate; add config.warnings block that fires only when the stale
  flag is explicitly set
- server.py: add DEPRECATED_FEATURE_IDS constant; skip deprecated ids
  in _read_hub_overrides and _write_hub_overrides; add
  _migrate_strip_deprecated_features helper that rewrites the Hub
  Managed section without deprecated lines on startup; add
  @app.on_event("startup") handler _startup_migrate_deprecated_features
2026-06-04 19:16:36 +00:00
copilot-swe-agent[bot]andGitHub c1119b03a8 Initial plan 2026-06-04 19:12:56 +00:00
Sovran SystemsandGitHub 0c273b758d Merge pull request #305 from naturallaw777/copilot/update-hub-feature-manager
Retire deprecated bip110 flake input; collapse Bitcoin node tiles from three to two
2026-06-04 13:57:52 -05:00
copilot-swe-agent[bot]andGitHub 6f98c478e8 Fix bitcoin-core confirmation dialog: show always when enabling (not just on conflicts) 2026-06-04 18:55:46 +00:00
copilot-swe-agent[bot]andGitHub 875a6a9297 Retire deprecated bip110 flake input; collapse Bitcoin node tiles to two 2026-06-04 18:52:28 +00:00
copilot-swe-agent[bot]andGitHub 1dbfe3cd94 Initial plan 2026-06-04 18:47:49 +00:00
naturallaw777 e0d4b3544d updated config for gnome 50 support 2026-05-27 11:15:09 -05:00
Sovran SystemsandGitHub 3e3fbed470 Merge pull request #304 from naturallaw777/copilot/fix-sovran-hub-updater-retry-issue
Harden Hub updater against cache.nixos.org narinfo stalls
2026-05-27 11:06:11 -05:00
Sovran SystemsandGitHub ada9f25c41 Merge pull request #303 from naturallaw777/copilot/fix-missing-status-text-update-modal
Add unified asset-version cache busting for Hub templates to prevent stale update modal UI
2026-05-27 11:05:49 -05:00
copilot-swe-agent[bot]andGitHub 66cacaaf9d Harden asset-version fallback hashing separators 2026-05-27 16:03:31 +00:00
copilot-swe-agent[bot]andGitHub 15cd07d12f Add resilient Nix download/fallback settings for hub update flows 2026-05-27 16:02:37 +00:00
copilot-swe-agent[bot]andGitHub fae57c0375 Initial plan 2026-05-27 15:59:57 +00:00
copilot-swe-agent[bot]andGitHub 3745eedd74 Add unified template asset cache-busting version 2026-05-27 15:59:50 +00:00
copilot-swe-agent[bot]andGitHub 1cd4fc8b40 Initial plan 2026-05-27 15:56:18 +00:00
Sovran SystemsandGitHub 732ab6f2aa Merge pull request #302 from naturallaw777/staging-dev
overall nixpkgs update
2026-05-27 09:38:22 -05:00
naturallaw777 bea26c55c3 overall nixpkgs update 2026-05-27 09:35:36 -05:00
Sovran SystemsandGitHub a841665b07 Refine networking and security section in README 2026-05-23 15:46:24 -05:00
Sovran Systems d574f96379 Update README.md 2026-05-23 15:42:59 -05:00
Sovran Systems 2388039b63 Add Sovran Hub icon SVG referenced by README 2026-05-23 11:35:09 -05:00
Sovran Systems aa69d40f08 README: use new Sovran Hub icon 2026-05-23 11:33:46 -05:00
Sovran Systems 31cb48cc2b Add new Sovran Hub icon (v3) 2026-05-23 11:30:45 -05:00
naturallaw777 170bd14a34 update readme 2026-05-23 11:28:02 -05:00
Sovran SystemsandGitHub 2553e0dce0 Merge pull request #301 from naturallaw777/copilot/fix-udp-port-range-in-onboarding
Sync Element Calling port guidance with LiveKit UDP mux (7882)
2026-05-21 22:39:58 -05:00
b8e7b2b4cc fix: update element calling onboarding UDP mux port text
Agent-Logs-Url: https://github.com/naturallaw777/sovran-systems/sessions/d45e1a45-fc4e-4bd5-adfd-c798c0ff3987

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-05-22 03:37:52 +00:00
copilot-swe-agent[bot]andGitHub 24098a209a Initial plan 2026-05-22 03:34:50 +00:00
Sovran SystemsandGitHub 8ad7509b02 Merge pull request #300 from naturallaw777/copilot/fix-rtc-udp-port-configuration
Fix LiveKit ICE failure: use single integer for rtc.udp_port
2026-05-21 22:30:11 -05:00
a350d4e2f7 Fix LiveKit rtc.udp_port: use integer 7882 instead of string range, update firewall rules
Agent-Logs-Url: https://github.com/naturallaw777/sovran-systems/sessions/f531f757-8ab7-4742-9c75-8d1e57d73380

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-05-22 03:23:10 +00:00
copilot-swe-agent[bot]andGitHub ec3782991d Initial plan 2026-05-22 03:21:40 +00:00
naturallaw777 bc4b4630a3 overall nixpkgs update 2026-05-21 17:02:38 -05:00
Sovran SystemsandGitHub 342f60ce0d Delete docs directory 2026-05-21 16:50:48 -05:00
Sovran SystemsandGitHub 8c8e8f43a2 Merge pull request #299 from naturallaw777/copilot/update-installer-pinning-to-stable
Pin GUI installer deployed flake to `stable` instead of `staging-dev`
2026-05-21 09:39:37 -05:00
559e0218eb fix(installer): pin deployed flake and log message to stable
Agent-Logs-Url: https://github.com/naturallaw777/sovran-systems/sessions/4648ebc7-45b1-4fd2-8636-29c15ee484fe

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-05-21 14:33:28 +00:00
copilot-swe-agent[bot]andGitHub fd2e12ced1 Initial plan 2026-05-21 14:28:00 +00:00
Sovran SystemsandGitHub efdf1e05d0 Merge pull request #298 from naturallaw777/copilot/add-prerequisites-notice-installer
Add Server + Desktop prerequisites notice to installer role-selection screen
2026-05-20 20:40:08 -05:00
cd3ab47aa0 Use theme-safe installer notice styling
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a71df5f0-f463-4c08-b54d-946d97d0aafd

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-05-21 01:38:36 +00:00
c12a680d27 Add installer prerequisites notice
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a71df5f0-f463-4c08-b54d-946d97d0aafd

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-05-21 01:37:54 +00:00
copilot-swe-agent[bot]andGitHub c728eee924 Initial plan 2026-05-21 01:32:55 +00:00
Sovran SystemsandGitHub ca704b24a2 Merge pull request #297 from naturallaw777/copilot/add-flatpak-retry-policy
Harden `flatpak-repo` boot unit against transient Flathub fetch failures
2026-05-17 09:08:01 -05:00
db068ba994 Harden flatpak-repo systemd service retries
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/42ce6628-372d-4bd8-82c5-b10097fc8003

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-05-17 14:06:58 +00:00
copilot-swe-agent[bot]andGitHub 53ee31c5d2 Initial plan 2026-05-17 14:05:39 +00:00
naturallaw777 283b439d59 nixpkgs update 2026-05-14 10:17:11 -05:00
Sovran SystemsandGitHub 9e09f9eb40 Merge pull request #296 from naturallaw777/copilot/fix-switch-inhibitors-check
Fix switchInhibitors fallback detection race in sovran-hub update/rebuild scripts
2026-05-09 10:07:41 -05:00
08bfa73e74 fix: detect switchInhibitors from captured nixos-rebuild output
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/637e0a15-b70f-44b0-abe8-8ba3dd25a359

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-05-09 15:00:10 +00:00
copilot-swe-agent[bot]andGitHub 2b76a766ad Initial plan 2026-05-09 14:58:55 +00:00
Sovran SystemsandGitHub d245e2ce0b Merge pull request #295 from naturallaw777/copilot/update-wipe-paths-array
Expand security reset wipe scope to include nix-bitcoin and Bisq state
2026-05-08 16:00:04 -05:00
bb2603bea0 Add nix-bitcoin and Bisq data paths to security reset wipe list
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8f922dd0-a5b0-42c6-af40-4bbd78a29ffa

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-05-08 20:55:29 +00:00
copilot-swe-agent[bot]andGitHub 8f96625c26 Initial plan 2026-05-08 20:53:48 +00:00
naturallaw777 9c0ddf0dbe blocked rxrpc kernal moduel 2026-05-08 15:20:18 -05:00
naturallaw777 2a352c35f9 updated nixpkgs 2026-05-08 08:04:35 -05:00
Sovran SystemsandGitHub 56b965b847 Merge pull request #294 from naturallaw777/copilot/update-sparrow-wallet-auto-connect
Fix Sparrow wallet defaulting to public Electrum server on first launch
2026-05-03 17:38:03 -05:00
8e5bb766a6 fix: add mode ONLINE to Sparrow config so Electrum backend is active on first launch
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/69818c91-2127-4392-8a39-76953e17497c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-05-03 22:37:14 +00:00
copilot-swe-agent[bot]andGitHub 646d877c4d Initial plan 2026-05-03 22:33:54 +00:00
Sovran SystemsandGitHub 045c5d6a12 Merge pull request #293 from naturallaw777/copilot/make-custom-nix-readable-writable
Make custom.nix writable on new installs
2026-04-30 12:47:20 -05:00
f0f690eae4 Make custom.nix read-write (644) on all new installs
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/1606cde2-b484-4570-a64e-649f80384367

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-30 17:45:45 +00:00
copilot-swe-agent[bot]andGitHub 459536d478 Initial plan 2026-04-30 17:44:53 +00:00
naturallaw777 6c5f261d8a fix for gnome keyring 2026-04-30 11:42:35 -05:00
Sovran SystemsandGitHub 91cc0152ba Merge pull request #292 from naturallaw777/copilot/move-tmpfiles-rules-to-user-level
Fix GNOME Keyring permission corruption on fresh installs: move tmpfiles to user level
2026-04-30 11:33:45 -05:00
bfc60eeb2c Fix GNOME Keyring permission issue: move tmpfiles rules to user level
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3ed85d6b-ada9-48e1-941f-1150e1491157

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-30 16:32:36 +00:00
copilot-swe-agent[bot]andGitHub 976d8f3609 Initial plan 2026-04-30 16:31:14 +00:00
naturallaw777 10a1d0f7ba fix for gnome keyring 2026-04-30 10:46:58 -05:00
Sovran SystemsandGitHub 3a8e9a2dd0 Merge pull request #291 from naturallaw777/copilot/make-brave-default-browser
Make Brave the default browser on fresh installs
2026-04-30 09:33:32 -05:00
6872c8d820 feat: make Brave the default browser on fresh installs
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/fbb8cbcc-6f16-419a-b732-2457c1e67384

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-30 14:28:24 +00:00
copilot-swe-agent[bot]andGitHub 175f48ef37 Initial plan 2026-04-30 14:26:43 +00:00
Sovran SystemsandGitHub 49912a2760 Merge pull request #290 from naturallaw777/copilot/refactor-gnome-keyring-setup
Refactor GNOME Keyring management to native NixOS tmpfiles
2026-04-30 09:17:14 -05:00
c450dcab9e refactor: use systemd.tmpfiles for GNOME Keyring, simplify reset scripts
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/71dab9c7-081f-4e45-80c2-080e88ae6207

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-30 13:52:27 +00:00
copilot-swe-agent[bot]andGitHub 953fb04671 Initial plan 2026-04-30 13:48:25 +00:00
Sovran SystemsandGitHub c02655a840 Merge pull request #289 from naturallaw777/copilot/fix-gnome-keyring-initialization-issues
fix: seed GNOME Keyring default pointer on fresh installs, resets, and migrations
2026-04-30 08:24:10 -05:00
f87e9982b0 fix: seed GNOME Keyring default pointer on fresh installs, resets, and migrations
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c3f5b4ac-12ed-4ff9-ac7c-f07be1f178d9

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-30 13:14:10 +00:00
copilot-swe-agent[bot]andGitHub 02e662454c Initial plan 2026-04-30 13:10:40 +00:00
Sovran SystemsandGitHub 4d8eaf71ca Merge pull request #288 from naturallaw777/copilot/update-credential-labels-rtl-mempool
Improve Tor vs. Local Network credential labels across Bitcoin services
2026-04-30 03:40:20 -05:00
a0f42d3e7b feat: improve credential labels for Tor/Local access across all services
- RTL: rename 'Tor Access' → 'Tor Address — Access from anywhere via Tor Browser'
         rename 'Local Network' → 'Local Network — Access on your home network only'
         add 'How to Access' explanation credential
- Mempool: same label improvements + 'How to Access' credential
- Bitcoin Knots, Bitcoin Core, Electrs: update 'Tor Address' label to include
  'Access from anywhere via Tor Browser' for consistency

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/63c3edb0-9fbf-4dd8-91e5-404ff6e4097d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-30 08:38:27 +00:00
copilot-swe-agent[bot]andGitHub ef683a6aa9 Initial plan 2026-04-30 08:36:41 +00:00
Sovran SystemsandGitHub 02a9dbf39c Merge pull request #286 from naturallaw777/copilot/remove-connection-url-zeus-connect
Zeus Connect Hub UI: show QR code only, suppress raw URL text
2026-04-29 22:55:27 -05:00
6d72f70fe5 Fix Zeus Connect: show only QR code, hide raw URL text
- modules/core/sovran-hub.nix: rename credential label from 'Scan QR Code' to 'QR Code'
- server.py: forward qronly flag in _resolve_credential so JS can hide the URL text/copy button

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0292564f-8e75-4c34-b938-1a6c98f3ff0d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-30 03:50:17 +00:00
copilot-swe-agent[bot]andGitHub c2887b60b2 Initial plan 2026-04-30 03:46:21 +00:00
Sovran Systems 9e76bad58a hub: upgrade modal — Njalla is the only supported domain provider 2026-04-29 22:31:22 -05:00
Sovran Systems b3f6efef8a hub: Zeus Connect — skip value/copy button when qronly is set 2026-04-29 22:24:24 -05:00
Sovran Systems 53813e775d hub: Zeus Connect — show QR code only, remove Connection URL text 2026-04-29 22:22:21 -05:00
Sovran SystemsandGitHub 060f81393c Merge pull request #284 from naturallaw777/copilot/fix-gdm-login-loop-pam-config
Fix GDM login loop: replace broken PAM hook with free-password-migration systemd service
2026-04-29 20:45:46 -05:00
Sovran SystemsandGitHub c1c0827604 Merge pull request #285 from naturallaw777/copilot/fix-legacy-migration-flow
Fix legacy migration flow: defer chpasswd to password-acknowledge
2026-04-29 20:45:20 -05:00
b5715e05c6 Fix legacy migration flow: move chpasswd to password-acknowledge endpoint
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6ad42ef5-884b-4945-b49e-76b3e6c34088

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-30 01:42:01 +00:00
copilot-swe-agent[bot]andGitHub 68c3aa95fd Initial plan 2026-04-30 01:39:53 +00:00
281b08dcd4 Fix GDM login loop: replace broken PAM hook with free-password-migration systemd service
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c958784d-bc79-4784-9ec6-6d52fd3f574e

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-30 01:27:01 +00:00
copilot-swe-agent[bot]andGitHub ca1ff3ee20 Initial plan 2026-04-30 01:25:15 +00:00
Sovran SystemsandGitHub 1cd5bd4496 Merge pull request #283 from naturallaw777/copilot/fix-free-password-setup-script
fix(credentials): enforce boot ordering and error visibility for password-setup services
2026-04-29 19:54:14 -05:00
6512bf4356 fix: add set -euo pipefail and boot ordering to password-setup services
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/2f9c39e8-d673-4314-bff7-28f1fffd48a0

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-30 00:52:17 +00:00
copilot-swe-agent[bot]andGitHub 7da0463dce Initial plan 2026-04-30 00:51:11 +00:00
Sovran SystemsandGitHub 466582bcdc Merge pull request #282 from naturallaw777/copilot/remove-plymouth-and-add-cpu-performance
Remove Plymouth entirely; add quiet boot params and cpu-performance module
2026-04-29 19:35:01 -05:00
c23ae5543d Remove Plymouth, add quiet boot params, add cpu-performance module
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/eda71495-cd38-4408-8d3b-b9d793f6445f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-30 00:33:32 +00:00
copilot-swe-agent[bot]andGitHub 569e0de59d Initial plan 2026-04-30 00:31:49 +00:00
naturallaw777 4a7d9615db nix package update 2026-04-29 16:10:09 -05:00
naturallaw777 7c1b603200 updated bitcoind for better launch 2026-04-29 16:00:27 -05:00
Sovran SystemsandGitHub 4dae7836dd Merge pull request #279 from naturallaw777/copilot/fix-update-script-inhibitor-logic
Handle NixOS switchInhibitors: fall back to nixos-rebuild boot and surface reboot-required state in UI
2026-04-29 15:14:43 -05:00
e821da6c2a Handle NixOS switchInhibitors: detect reboot-required case and show correct UI state
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d72be7a1-ec3f-41da-9753-611b95bc9903

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-29 20:04:59 +00:00
copilot-swe-agent[bot]andGitHub 17fbd5fd2c Initial plan 2026-04-29 20:00:45 +00:00
Sovran SystemsandGitHub 8712ac43c6 Merge pull request #278 from naturallaw777/copilot/fix-new-services-inactive-state
Auto-start newly enabled services after NixOS rebuild
2026-04-29 14:53:21 -05:00
38e4a296ee Auto-start newly enabled services after successful NixOS rebuild
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3d0aaa70-7eb3-4496-abe4-095e4c4d3dea

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-29 19:51:20 +00:00
copilot-swe-agent[bot]andGitHub d1ef6ba1cd Initial plan 2026-04-29 19:48:47 +00:00
Sovran Systems ffd2029852 fix: disable BTCPayServer by default in node-only mode
The BTCPayServer hub entry was using `cfg.services.bitcoin` as its
`enabled` flag, which is `true` in node mode. This caused the Hub UI
to show BTCPayServer as enabled even though the underlying NixOS service
is correctly gated on `cfg.web.btcpayserver` (which defaults to `false`
for the node role via role-logic.nix).

Change the enabled field to `cfg.web.btcpayserver` so the Hub UI
accurately reflects the service state and BTCPayServer is disabled by
default on a fresh node-only install.
2026-04-28 17:49:25 -05:00
Sovran SystemsandGitHub 761af09166 Merge pull request #277 from naturallaw777/copilot/fix-deprecated-logind-options
Migrate `no-sleep` logind lid handling to `services.logind.settings.Login`
2026-04-22 09:28:54 -05:00
48d7e8a459 Fix deprecated logind lid switch options in no-sleep module
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8a4eee86-6cb7-411d-9e71-1bcfae42374e

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-22 14:24:05 +00:00
copilot-swe-agent[bot]andGitHub d3327e05d4 Initial plan 2026-04-22 14:22:01 +00:00
Sovran SystemsandGitHub 2c8dd91cf0 Merge pull request #276 from naturallaw777/copilot/fix-no-sleep-module-roles
Scope `no-sleep` module to non-desktop roles
2026-04-22 07:39:52 -05:00
448c4b9094 Fix no-sleep module to skip desktop role
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d008099d-2fd3-4b86-a10c-ed7f9337e51c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-22 12:37:36 +00:00
copilot-swe-agent[bot]andGitHub e147fd8f4d Initial plan 2026-04-22 12:34:17 +00:00
Sovran SystemsandGitHub b8feea3711 Merge pull request #275 from naturallaw777/copilot/fix-deprecated-services-logind-extraconfig
[WIP] Fix deprecated services.logind.extraConfig in modules/core/no-sleep.nix
2026-04-22 07:09:36 -05:00
0cc1f50aa4 fix: replace deprecated logind extraConfig with settings.Login
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a9bcbedf-7dfa-47e2-a9f5-b288ff5c5f42

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-22 12:09:00 +00:00
copilot-swe-agent[bot]andGitHub 71d8eae6d8 Initial plan 2026-04-22 12:05:50 +00:00
Sovran SystemsandGitHub 878392d998 Merge pull request #274 from naturallaw777/copilot/add-no-sleep-nixos-module
Add always-loaded core no-sleep module to disable suspend/hibernate system-wide
2026-04-22 07:03:14 -05:00
d6471aad55 feat(core): add system-level no-sleep module
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b0e72301-13fd-4c14-9b3b-584e8c04267f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-22 12:00:45 +00:00
copilot-swe-agent[bot]andGitHub 4b939affaf Initial plan 2026-04-22 11:58:07 +00:00
naturallaw777 c1b02e0562 bitcon knots fix 2026-04-18 10:08:20 -05:00
naturallaw777 6b962ff51d nixpkgs update 2026-04-18 09:45:57 -05:00
Sovran_SystemsandGitHub 3843f8ea22 Merge pull request #273 from naturallaw777/copilot/update-backup-script-to-backup-var-lib
[WIP] Update backup script to back up entire /var/lib directory
2026-04-18 08:50:45 -05:00
c85eea719d backup: harden desktop var-lib exclusions
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d8d4b876-dfc7-42fd-954c-a9e5b05dc497

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-18 13:49:57 +00:00
5309618747 backup: tighten rsync var-lib exclude patterns
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d8d4b876-dfc7-42fd-954c-a9e5b05dc497

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-18 13:49:07 +00:00
725aad3aac backup: include full /var/lib in manual backup stages
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d8d4b876-dfc7-42fd-954c-a9e5b05dc497

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-18 13:47:22 +00:00
copilot-swe-agent[bot]andGitHub 070ab61131 Initial plan 2026-04-18 13:44:03 +00:00
naturallaw777 164f052b1f updated rpc bitcoinnd port 2026-04-17 18:05:24 -05:00
Sovran_SystemsandGitHub 8841a8d628 Merge pull request #272 from naturallaw777/copilot/fix-clear-site-data-header
Preserve Hub login session on `sovransystemsos.local` by narrowing `Clear-Site-Data`
2026-04-17 17:57:21 -05:00
d500d15e12 fix(caddy): preserve hub session cookie on mDNS vhost
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/45cc1510-356d-4d59-a6d2-b9b4903cff23

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-17 22:56:23 +00:00
copilot-swe-agent[bot]andGitHub 16898e8eb9 Initial plan 2026-04-17 22:55:25 +00:00
Sovran_SystemsandGitHub c809045014 Merge pull request #271 from naturallaw777/copilot/fix-wordpress-permissions-ownership
[WIP] Fix WordPress directory ownership and permissions
2026-04-17 10:42:01 -05:00
158d369371 Fix WordPress ownership and tighten permissions to match php-fpm pool
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3c7ded55-8f08-46f7-af17-6bbbdadba84b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-17 15:41:13 +00:00
copilot-swe-agent[bot]andGitHub c40db26e6f Initial plan 2026-04-17 15:38:41 +00:00
Sovran_SystemsandGitHub 42305f7f22 Merge pull request #270 from naturallaw777/copilot/remove-orphaned-mypool
[WIP] Clean up PHP-FPM pool architecture and expose custom PHP properly
2026-04-17 08:08:29 -05:00
539ede00cb refactor php-fpm pool wiring to shared phpPackage option
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/13105350-82a0-4135-b8a4-55016f202195

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-17 13:07:25 +00:00
copilot-swe-agent[bot]andGitHub 5324344eed Initial plan 2026-04-17 13:05:11 +00:00
Sovran_SystemsandGitHub d2c9dd1fbd Merge pull request #269 from naturallaw777/copilot/fix-nextcloud-wordpress-inactive-status
Use canonical domain files for pre-existing Nextcloud/WordPress credentials and fix Nextcloud reset command
2026-04-17 07:21:22 -05:00
a1db5773fc Use canonical domain files in detect-existing credentials
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f9e2b5f9-b25b-4ab9-a3cf-5b8bd4ea22de

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-17 12:17:17 +00:00
copilot-swe-agent[bot]andGitHub 5e33a250d5 Initial plan 2026-04-17 12:15:03 +00:00
Sovran_SystemsandGitHub 7e0cda17f3 Remove localhost from url_preview_ip_ranger_whitelist 2026-04-16 23:49:04 -05:00
Sovran_SystemsandGitHub 15821207dc Merge pull request #268 from naturallaw777/copilot/fix-php-fpm-unit-mismatch
Restore Nextcloud/WordPress hub visibility for pre-existing installs (dedicated PHP-FPM pools + detect-existing credential/domain recovery)
2026-04-16 23:07:50 -05:00
cdb93ad8dc fix: detect existing Nextcloud/WordPress installs and add dedicated php-fpm pools
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6d5b7710-ee06-40ff-8975-f8edca8b879f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-17 03:51:10 +00:00
copilot-swe-agent[bot]andGitHub 0c596fb396 Initial plan 2026-04-17 03:48:26 +00:00
Sovran_SystemsandGitHub a3c1b849f2 Merge pull request #267 from naturallaw777/copilot/implement-password-reset-flow
Add migration-safe free password handoff for desktop roles (GDM + onboarding)
2026-04-16 22:39:16 -05:00
7262694425 fix migration checklist to mark noted only after acknowledgement
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/59fc567c-4bd4-44ab-a2ff-8e74854030e5

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-17 03:34:09 +00:00
ff1defcaab refactor onboarding migration state flags
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/59fc567c-4bd4-44ab-a2ff-8e74854030e5

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-17 03:32:42 +00:00
6ac9a7cd4c fix migration-safe free password flow for desktop roles
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/59fc567c-4bd4-44ab-a2ff-8e74854030e5

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-17 03:30:26 +00:00
copilot-swe-agent[bot]andGitHub cb9172d069 Initial plan 2026-04-17 03:24:31 +00:00
Sovran_SystemsandGitHub 92dd718362 Merge pull request #266 from naturallaw777/copilot/remove-restart-service-button
[WIP] Remove Restart Service button from Nextcloud and WordPress service modals
2026-04-16 12:54:05 -05:00
7d15b67463 fix: hide restart troubleshooting for nextcloud and wordpress php-fpm services
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/fb1bb511-22f7-4b0b-b07e-2bc59ee468ac

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-16 17:53:01 +00:00
copilot-swe-agent[bot]andGitHub 12b2d85fb4 Initial plan 2026-04-16 17:50:02 +00:00
Sovran_SystemsandGitHub 8657bdc23a Merge pull request #265 from naturallaw777/copilot/remove-bisq-auto-link-tile
[WIP] Remove Bisq Auto-Link tile and associated code
2026-04-16 12:49:31 -05:00
59cbc8d4e9 remove bisq auto-link tile and autoconnect service
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/50ccdba4-2fbf-4c08-b7ae-7d1b92f7a75e

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-16 17:47:29 +00:00
copilot-swe-agent[bot]andGitHub 80fea3301b Initial plan 2026-04-16 17:40:57 +00:00
Sovran_SystemsandGitHub 91a3e68119 Remove server configuration from Bitcoin ecosystem 2026-04-16 12:21:19 -05:00
Sovran_SystemsandGitHub bda9c3cd0e Merge pull request #263 from naturallaw777/copilot/add-restart-button-to-service-modal
Add safe service restart action to Service Detail modal
2026-04-16 12:18:02 -05:00
Sovran_SystemsandGitHub a84e958182 Refactor wallet-autoconnect configuration
Removed waiting mechanism for bitcoind RPC readiness and updated btcNodes configuration.
2026-04-16 12:10:27 -05:00
Sovran_SystemsandGitHub 21e0f284b6 Merge pull request #264 from naturallaw777/copilot/fix-reboot-button-issue
[WIP] Fix reboot button functionality after update
2026-04-16 12:06:58 -05:00
e83b4ff5b1 fix(web): exempt reboot endpoint from auth middleware
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c4b5b663-c6a6-4c78-a788-9dd47ef85628

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-16 17:06:00 +00:00
copilot-swe-agent[bot]andGitHub 0445a1c1cc Initial plan 2026-04-16 17:04:06 +00:00
naturallaw777 dc1d89b441 updated hub to proper bitcoinnd port 2026-04-16 11:30:02 -05:00
naturallaw777 0da964bfca updated electrs and bitciond 2026-04-16 11:17:33 -05:00
b5e89c38f8 Improve restart fallback error message
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8e6c98f7-8b24-4ec0-944b-0310e0989495

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-16 15:19:16 +00:00
c37816d257 Address review nits for restart flow
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8e6c98f7-8b24-4ec0-944b-0310e0989495

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-16 15:18:34 +00:00
fce4608647 Add service restart API and modal restart action
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8e6c98f7-8b24-4ec0-944b-0310e0989495

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-16 15:13:07 +00:00
copilot-swe-agent[bot]andGitHub 8fd08057d8 Initial plan 2026-04-16 14:58:54 +00:00
Sovran_SystemsandGitHub 0563c6b96b Merge pull request #262 from naturallaw777/copilot/fix-bisq-hub-node-connection
Align Bisq and Hub local Bitcoin node endpoint with nix-bitcoin default port (8335)
2026-04-15 20:12:23 -05:00
b29ed2cce7 Fix Bisq and Hub local node port to 8335
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/05dac8a9-797a-49d0-9b41-4b4e5be56ecf

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-16 01:11:10 +00:00
copilot-swe-agent[bot]andGitHub f8ecbf3ee3 Initial plan 2026-04-16 01:09:40 +00:00
naturallaw777 20aa66a160 updated electrs for localhost 2026-04-15 19:18:17 -05:00
naturallaw777 976d3b0fa7 updated bitcoind and eletrs for localhost 2026-04-15 19:13:33 -05:00
Sovran_SystemsandGitHub 2e9d989444 Merge pull request #261 from naturallaw777/copilot/check-timechain-data-drive
Preserve existing Bitcoin timechain data drive during OS reinstall
2026-04-15 15:25:05 -05:00
38207e8b2f Harden GUI timechain detection temp mount handling
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8a51f052-83d0-4079-8338-5cfdbb849aa2

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 20:18:04 +00:00
5fe2ecd56d Refine preservation detection messaging and label fallback
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8a51f052-83d0-4079-8338-5cfdbb849aa2

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 20:15:59 +00:00
846e2af705 Preserve existing Bitcoin data drive during reinstall
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8a51f052-83d0-4079-8338-5cfdbb849aa2

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 20:13:18 +00:00
copilot-swe-agent[bot]andGitHub c8eb452a70 Initial plan 2026-04-15 20:11:07 +00:00
Sovran_SystemsandGitHub 46b8c23578 Merge pull request #260 from naturallaw777/copilot/fix-health-status-discrepancy
[WIP] Fix health status discrepancy between service tile and modal
2026-04-15 15:02:26 -05:00
db32796675 Add DNS mismatch check to tile health computation
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b23873c9-fca8-4e98-8300-003c3302aee4

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 20:02:01 +00:00
copilot-swe-agent[bot]andGitHub ecd5ecd659 Initial plan 2026-04-15 19:59:35 +00:00
Sovran_SystemsandGitHub 99f86e1cda Merge pull request #259 from naturallaw777/copilot/fix-service-tile-status
Fix domain service tile health precedence and remove blocking Step 3 reachability from service detail
2026-04-15 14:03:46 -05:00
630cfef690 Fix domain service health precedence and cached checklist reachability
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d3af0b56-5b37-4eaa-a4fc-e7ffa2872c21

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 19:00:47 +00:00
copilot-swe-agent[bot]andGitHub 6c0afc0e6b Initial plan 2026-04-15 18:55:17 +00:00
Sovran_SystemsandGitHub 709bd51413 Merge pull request #258 from naturallaw777/copilot/fix-nextcloud-dotfiles-issue
Fix Nextcloud core integrity check by preserving dotfiles during install
2026-04-15 13:41:01 -05:00
37370fd12f fix(nextcloud): copy extracted dotfiles during init install
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/bf1371c0-14ee-477b-9d30-baf97d8f853c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 18:37:38 +00:00
Sovran_SystemsandGitHub 1e2b11b235 Merge pull request #257 from naturallaw777/copilot/optimize-dns-resolution-delay
Remove blocking DNS checks from `/api/services` by making domain health cache-only
2026-04-15 13:37:15 -05:00
copilot-swe-agent[bot]andGitHub d636e0fa38 Initial plan 2026-04-15 18:35:51 +00:00
31c7b796f8 Use cached domain reachability only in api_services health
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3212805f-2cc0-4576-8cda-c3c303f0de47

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 18:34:18 +00:00
copilot-swe-agent[bot]andGitHub 8a57734a42 Initial plan 2026-04-15 18:31:55 +00:00
Sovran_SystemsandGitHub 0b76a257ce Merge pull request #256 from naturallaw777/copilot/fix-security-setup-warnings
Nextcloud first-launch hardening: clear Security & Setup warnings via init-time OCC + PHP-FPM override
2026-04-15 13:22:06 -05:00
7a0a43dfd3 Add server_id guard and AppAPI rationale
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e94844f0-187d-4b52-9302-7e61d3e5804a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 18:19:49 +00:00
0d318d60ac Harden server_id setup and app_api disable flow
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e94844f0-187d-4b52-9302-7e61d3e5804a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 18:19:13 +00:00
25fe8844e5 Refine server_id generation and AppAPI disable guard
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e94844f0-187d-4b52-9302-7e61d3e5804a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 18:18:32 +00:00
d468678d00 Fix Nextcloud first-launch security/setup warnings
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e94844f0-187d-4b52-9302-7e61d3e5804a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 18:17:37 +00:00
Sovran_SystemsandGitHub 0af4c391e8 Merge pull request #255 from naturallaw777/copilot/fix-tile-loading-spinner
Show per-tile “Checking…” state while domain reachability cache warms up
2026-04-15 13:17:15 -05:00
5bb8af7a3e refactor: reuse cached reachability lookup in service health
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3208f380-e8fe-4f12-b83c-723ecee6cd4c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 18:15:30 +00:00
copilot-swe-agent[bot]andGitHub c86cb9afe0 Initial plan 2026-04-15 18:15:23 +00:00
9c34eb0694 feat: add checking state for domain reachability on service tiles
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3208f380-e8fe-4f12-b83c-723ecee6cd4c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 18:12:13 +00:00
copilot-swe-agent[bot]andGitHub 337f858a7a Initial plan 2026-04-15 18:08:40 +00:00
Sovran_SystemsandGitHub 063c76f8ce Merge pull request #254 from naturallaw777/copilot/update-wallpaper-application-logic
Apply GNOME wallpaper by version on all machines while preserving one-time theme bootstrap
2026-04-15 13:07:09 -05:00
a0e110b376 fix(desktop): harden wallpaper version stamp read
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3c085026-21a9-4afb-b39f-1d04f1ddd49f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 18:04:02 +00:00
8cf43fd3d1 chore(desktop): simplify wallpaper path usage in init script
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3c085026-21a9-4afb-b39f-1d04f1ddd49f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 18:03:14 +00:00
6f63e0f4d0 fix(desktop): apply wallpaper on version changes before legacy guards
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3c085026-21a9-4afb-b39f-1d04f1ddd49f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 18:02:30 +00:00
copilot-swe-agent[bot]andGitHub d458d8c07a Initial plan 2026-04-15 18:00:21 +00:00
Sovran_SystemsandGitHub 6c7b1587b3 Merge pull request #252 from naturallaw777/copilot/fix-root-directory-ownership
Adjust Nextcloud ownership model and data directory initialization path
2026-04-15 12:41:52 -05:00
Sovran_SystemsandGitHub be8d5ccf16 Merge pull request #253 from naturallaw777/copilot/fix-reboot-functionality-issue
Prevent Hub auto-restart from interrupting machine reboot
2026-04-15 12:39:58 -05:00
18c60bf085 Add reboot conflict and SIGTERM restart prevention for hub service
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/83e39fad-8cf8-4008-8977-a07a77b2f7a3

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 17:38:12 +00:00
copilot-swe-agent[bot]andGitHub d3d90f6e94 Initial plan 2026-04-15 17:35:39 +00:00
d874c97b2f Adjust Nextcloud ownership and data directory handling
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c9190fb9-a4ac-42d9-b85d-2b9367c1a901

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 17:27:19 +00:00
copilot-swe-agent[bot]andGitHub 2e93514a4d Initial plan 2026-04-15 17:25:34 +00:00
Sovran_SystemsandGitHub 990ded6d1d Merge pull request #251 from naturallaw777/copilot/fix-service-status-checks
Normalize health status for enabled-but-inactive domain services in services + detail APIs
2026-04-15 12:14:48 -05:00
Sovran_SystemsandGitHub 4e501548ac Merge pull request #250 from naturallaw777/copilot/fix-hub-reboot-functionality
Fix Hub reboot path to use forced systemd reboot unit
2026-04-15 12:12:30 -05:00
2073303b18 Refine inactive branch variable naming in services health logic
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0baad662-d798-4d3e-a079-eefece637ab7

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 17:10:45 +00:00
1651f8de37 Clean up inactive health variable naming in service detail
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0baad662-d798-4d3e-a079-eefece637ab7

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 17:09:21 +00:00
1b2c0f2c1c Fix inactive domain services health to show needs_attention on domain/port issues
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0baad662-d798-4d3e-a079-eefece637ab7

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 17:08:02 +00:00
40c2d17833 fix: route hub reboot through forced systemd reboot unit
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c72ca380-983e-4811-98f7-98f883ef46dc

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 17:07:07 +00:00
copilot-swe-agent[bot]andGitHub 2ff983f5f4 Initial plan 2026-04-15 17:05:40 +00:00
copilot-swe-agent[bot]andGitHub fc6f58b00e Initial plan 2026-04-15 17:04:40 +00:00
Sovran_SystemsandGitHub 09c4249cae Merge pull request #248 from naturallaw777/copilot/fix-port-forwarding-notification
Make enable-feature port requirements modal context-aware (show only closed ports)
2026-04-15 11:41:06 -05:00
Sovran_SystemsandGitHub 8be2a4fe44 Merge pull request #249 from naturallaw777/copilot/fix-reboot-flow-issue
Fix Hub reboot flow to invoke system reboot directly
2026-04-15 11:39:14 -05:00
d973fae4db Handle non-OK port status responses in enable flow
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e1d94cfc-9b91-48a3-99e3-64d7609ba710

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 16:38:07 +00:00
05c08532b3 Log port status fetch failures before fallback modal
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e1d94cfc-9b91-48a3-99e3-64d7609ba710

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 16:36:53 +00:00
8970e8a689 fix: call reboot binary directly and drop reboot oneshot unit
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/1d9cb014-ee8b-44f1-9638-67e38cc2417b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 16:35:43 +00:00
8d97184105 Make port requirement modal check only closed ports
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e1d94cfc-9b91-48a3-99e3-64d7609ba710

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 16:34:56 +00:00
copilot-swe-agent[bot]andGitHub 1ce4a2a520 Initial plan 2026-04-15 16:33:30 +00:00
copilot-swe-agent[bot]andGitHub 97a868e0f9 Initial plan 2026-04-15 16:31:03 +00:00
Sovran_SystemsandGitHub 50a2fc0807 Merge pull request #247 from naturallaw777/copilot/fix-tile-health-discrepancy
Align `/api/services` tile health with full domain diagnostics via background reachability cache
2026-04-15 11:15:02 -05:00
Sovran_SystemsandGitHub b6c7c039b2 Merge pull request #246 from naturallaw777/copilot/fix-port-status-indicator
Remove misleading port-status diagnostics from Enable Feature port-forwarding modal
2026-04-15 11:13:20 -05:00
49e8a96aab Ensure background checker cancellation is not swallowed
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/038b6d9a-0298-41d7-949f-40069cd3320f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 16:10:51 +00:00
19273e6d10 refactor: simplify modal handler wiring for rerender safety
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/15310f2a-9bf2-4813-b2be-7462cb923c9c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 16:10:48 +00:00
bb07fbd2c3 Add safe startup/shutdown lifecycle for domain reachability task
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/038b6d9a-0298-41d7-949f-40069cd3320f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 16:09:39 +00:00
6ea8810881 chore: log network fetch failures in port requirements modal
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/15310f2a-9bf2-4813-b2be-7462cb923c9c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 16:09:23 +00:00
587f2a09f8 Refine background checker constants and repeated failure logging
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/038b6d9a-0298-41d7-949f-40069cd3320f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 16:08:25 +00:00
1d15997745 fix: remove misleading port status from enable-feature modal
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/15310f2a-9bf2-4813-b2be-7462cb923c9c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 16:07:22 +00:00
da0c79d479 Add background domain reachability cache for service tile health
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/038b6d9a-0298-41d7-949f-40069cd3320f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 16:07:00 +00:00
copilot-swe-agent[bot]andGitHub 4119a4ef61 Initial plan 2026-04-15 16:04:21 +00:00
copilot-swe-agent[bot]andGitHub 604eb11584 Initial plan 2026-04-15 16:01:35 +00:00
Sovran_SystemsandGitHub 7986de0b63 Merge pull request #245 from naturallaw777/copilot/fix-reboot-issue-in-sovran-hub
Decouple Hub-triggered reboot from `sovran-hub-web` cgroup via dedicated systemd unit
2026-04-15 10:40:37 -05:00
3f345dbc02 fix: detach reboot via dedicated systemd oneshot unit
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0338009f-7d7f-4c99-94c1-32cb9b68b5e0

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 15:37:40 +00:00
copilot-swe-agent[bot]andGitHub 9998306a0c Initial plan 2026-04-15 15:32:38 +00:00
Sovran_SystemsandGitHub 42e2e3dd16 Merge pull request #244 from naturallaw777/copilot/optimize-api-services-response-time
Decouple `/api/services` tile health from slow curl-based domain diagnostics
2026-04-15 10:24:33 -05:00
6b44c03fd8 perf: avoid curl-based domain checks in /api/services tile health
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f8a8cbe6-164d-4ddc-a248-e535a2fad801

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 15:17:09 +00:00
Sovran_SystemsandGitHub 1931a99e65 Merge pull request #243 from naturallaw777/copilot/add-domain-setup-modals
Split domain setup UX into distinct Configure vs Reconfigure modal flows
2026-04-15 10:11:28 -05:00
copilot-swe-agent[bot]andGitHub 4ce6341eb3 Initial plan 2026-04-15 15:11:07 +00:00
4301629606 feat: add dedicated domain reconfigure modal flow
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/34ab0742-1af8-46e9-9b12-a480c93366f1

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 15:07:23 +00:00
copilot-swe-agent[bot]andGitHub cf39e28921 Initial plan 2026-04-15 15:01:26 +00:00
Sovran_SystemsandGitHub b252014158 Merge pull request #242 from naturallaw777/copilot/update-domain-health-check-system
[WIP] Update domain health check system to improve diagnostics
2026-04-15 07:47:19 -05:00
86942ebc33 feat: replace domain port table with sequential domain diagnostics
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/93de7af8-10f9-438e-b9bc-8c6e9d39d787

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 12:45:02 +00:00
copilot-swe-agent[bot]andGitHub 13f15cb845 Initial plan 2026-04-15 12:34:39 +00:00
Sovran_SystemsandGitHub 5c19de6fb8 Merge pull request #241 from naturallaw777/copilot/fix-reboot-command-path
Use absolute NixOS `systemctl` path for Hub reboot command
2026-04-14 20:49:52 -05:00
dfcc3858f0 fix: use absolute systemctl path for reboot command
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a2a440f0-d3a9-47ba-9278-98cac7789dfa

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 01:44:59 +00:00
copilot-swe-agent[bot]andGitHub bebca8f1ab Initial plan 2026-04-15 01:43:37 +00:00
Sovran_SystemsandGitHub 30b3f14292 Merge pull request #240 from naturallaw777/copilot/fix-domain-health-checks
Align domain health across dashboard/detail and cache external IP server-side
2026-04-14 20:36:12 -05:00
f24c9c45b2 fix: cache external ip and align domain health checks
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/01540c60-35d3-481a-8558-945a81d86976

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-15 01:31:47 +00:00
copilot-swe-agent[bot]andGitHub 04587efad3 Initial plan 2026-04-15 01:30:18 +00:00
Sovran_SystemsandGitHub 023b00d297 Merge pull request #239 from naturallaw777/copilot/fix-sovran-hub-service-path
[WIP] Fix sovran-hub service PATH for required binaries
2026-04-14 17:01:45 -05:00
7ec47abe17 fix(hub): add network utility binaries to sovran-hub-web service PATH
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/cc568566-7619-4546-af51-5173b55440a6

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-14 22:01:21 +00:00
copilot-swe-agent[bot]andGitHub 9c47c99645 Initial plan 2026-04-14 22:00:01 +00:00
Sovran_SystemsandGitHub cba3d1d092 Merge pull request #238 from naturallaw777/copilot/fix-nft-regex-firewall-allowed-ports
[WIP] Fix nft regex to correctly capture allowed ports
2026-04-14 16:45:01 -05:00
a135e652bc Fix hub port parsing and status checks for accurate open-port reporting
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/1101b3b2-686b-4023-8229-1b9258214546

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-14 21:44:42 +00:00
copilot-swe-agent[bot]andGitHub 6c2cbd5b3b Initial plan 2026-04-14 21:42:15 +00:00
Sovran_SystemsandGitHub 7a28b138a9 Merge pull request #236 from naturallaw777/copilot/fix-listening-ports-detection
[WIP] Fix listening ports detection in the Hub dashboard
2026-04-14 16:20:37 -05:00
a687c05f6c Fix _get_listening_ports() to reliably detect wildcard-bound ports (80/443)
Rewrite the ss output parser to:
- Skip header lines (State/Netid) explicitly
- Only process LISTEN/UNCONN state lines
- Always read parts[3] for local address (the ss column layout is fixed)
- Defensively skip wildcard (*) port values

The previous fix (PR #235) tried both parts[3] and parts[4], but reading
parts[4] (peer address column) was unnecessary. The ss LISTEN output always
places the local address at index 3 when split by whitespace, for all address
formats: 0.0.0.0:PORT, *:PORT, [::]:PORT, 127.0.0.1:PORT.

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f7ab1d7c-d624-4f1a-9e62-5a9ce4fd4446

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-14 21:20:03 +00:00
copilot-swe-agent[bot]andGitHub 8c4a8e4313 Initial plan 2026-04-14 21:16:39 +00:00
Sovran_SystemsandGitHub 2d4a3fcdf2 Merge pull request #235 from naturallaw777/copilot/fix-ss-output-parsing-issue
[WIP] Fix parsing of local address in _get_listening_ports function
2026-04-14 16:06:15 -05:00
adad79c7e8 fix: parse both parts[3] and parts[4] in _get_listening_ports() for ss output
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/380a4877-aaea-47ea-8998-4c60ff6d49d2

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-14 21:05:43 +00:00
copilot-swe-agent[bot]andGitHub 2e6d88daec Initial plan 2026-04-14 21:03:53 +00:00
naturallaw777 2cd9d7cf20 updated elementcalling firewall typo 2026-04-14 13:30:27 -05:00
naturallaw777 8500e1de05 updated elementcalling firewall 2026-04-14 13:28:35 -05:00
Sovran_SystemsandGitHub fefc7ff81a Merge pull request #234 from naturallaw777/copilot/open-turn-ports-for-livekit
fix(element-call): open TURN firewall ports 5349/TCP and 3478/UDP
2026-04-14 13:24:18 -05:00
1727755942 fix: open TURN firewall ports 5349 (TCP) and 3478 (UDP) in element-calling.nix
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/484cfc63-13c7-4008-8a94-cff4d554c27c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-14 18:21:21 +00:00
copilot-swe-agent[bot]andGitHub c6bfe1200c Initial plan 2026-04-14 18:20:38 +00:00
naturallaw777 5095052a53 nixpkgs update 2026-04-14 08:50:54 -05:00
naturallaw777 cc9b41fd37 cleaned up code 2026-04-14 08:49:26 -05:00
Sovran_SystemsandGitHub 8dedd59cc0 Merge pull request #233 from naturallaw777/copilot/fix-99053422-1193689005-4a507209-0427-496c-b028-fcf5e0d153d0
[WIP] Move hardcoded firewall ports from configuration.nix to their respective modules
2026-04-14 08:46:21 -05:00
57d12aab9e Move firewall ports to their respective service modules
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e4dbc0e0-e273-4e3e-a1ec-059ae9b06a50

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-14 13:45:51 +00:00
copilot-swe-agent[bot]andGitHub 99413a5dbe Initial plan 2026-04-14 13:44:07 +00:00
naturallaw777 a23e9f5c45 update readme 2026-04-13 19:59:42 -05:00
Sovran_SystemsandGitHub cfadd90d24 Merge pull request #232 from naturallaw777/copilot/fix-upgrade-to-server-desktop
Fix node→server+desktop upgrade: defer rebuild until after onboarding collects domains/SSL/ports
2026-04-13 19:48:20 -05:00
ac47f39117 Fix node-to-server upgrade: reboot before rebuild so onboarding collects domains first
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8d0387a6-f66c-4fe8-8df1-0abf657b2fba

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-14 00:46:50 +00:00
copilot-swe-agent[bot]andGitHub 55cd583569 Initial plan 2026-04-14 00:43:21 +00:00
naturallaw777 77bdf710c7 update readme 2026-04-13 19:05:47 -05:00
Sovran_SystemsandGitHub 60723689e1 Merge pull request #231 from naturallaw777/copilot/fix-reboot-button-issues
[WIP] Fix reboot button functionality after system update
2026-04-13 19:03:06 -05:00
7576c0fe85 Fix reboot flow: add /api/ping, fix waitForServerReboot polling, fix security.js handler
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ee8673cf-ad65-4f65-b5c8-2f170e78022f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-14 00:02:45 +00:00
copilot-swe-agent[bot]andGitHub c5bbb5220e Initial plan 2026-04-13 23:59:21 +00:00
naturallaw777 57dcf312bc update readme 2026-04-13 18:50:04 -05:00
Sovran_SystemsandGitHub 9fe6e108a9 Merge pull request #230 from naturallaw777/copilot/fix-reboot-button-local-computer
Fix reboot button hang and overlay spin-forever on local machine and LAN
2026-04-13 18:42:36 -05:00
a6dc3fd647 Fix reboot button and overlay bugs on local machine and LAN
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c8d3bf30-c3ea-40e7-8da0-b4baa28eaf36

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 23:41:33 +00:00
copilot-swe-agent[bot]andGitHub e33f4b570a Initial plan 2026-04-13 23:38:45 +00:00
naturallaw777 18b454e07b updated caddy icon 2026-04-13 18:24:58 -05:00
Sovran_SystemsandGitHub 20b1486547 Merge pull request #229 from naturallaw777/copilot/fix-reboot-issue-fetch-call
fix: use apiFetch in doReboot() to send session credentials with reboot request
2026-04-13 18:17:47 -05:00
068c78bd27 fix: use apiFetch instead of fetch in doReboot() to include session credentials
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/34f637cc-b8b9-42e1-bdeb-e4b252fae648

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 23:16:13 +00:00
copilot-swe-agent[bot]andGitHub 32b2ee7117 Initial plan 2026-04-13 23:15:32 +00:00
Sovran_SystemsandGitHub 9b5786fce1 Merge pull request #227 from naturallaw777/copilot/fix-btcpay-default-state
[WIP] Fix BTCPay Server default state in Node mode
2026-04-13 17:56:01 -05:00
Sovran_SystemsandGitHub 9c15b458c4 Merge pull request #228 from naturallaw777/copilot/fix-reboot-overlay-stuck
[WIP] Fix reboot overlay getting stuck after system update
2026-04-13 17:55:44 -05:00
b86fe94d82 Fix: BTCPay off by default in Node role, Caddy conditional ACME/ports
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/2e2b84a8-c5e9-4eea-8bee-fc587bb3a6fa

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 22:55:37 +00:00
7b7947db9d fix: robust reboot detection with AbortController timeout and serverWentDown flag
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8b653481-74f2-450f-a543-c94eb664645a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 22:55:18 +00:00
copilot-swe-agent[bot]andGitHub 38b45013b3 Initial plan 2026-04-13 22:54:20 +00:00
copilot-swe-agent[bot]andGitHub 2db344f91f Initial plan 2026-04-13 22:52:35 +00:00
Sovran_SystemsandGitHub a8d182ffc5 Merge pull request #226 from naturallaw777/copilot/remove-sidebar-upgrade-btn-class
Remove constant accent highlight from "Upgrade to Full Server" sidebar button
2026-04-13 17:38:09 -05:00
75d3aca1b6 Remove sidebar-upgrade-btn highlight; blend Upgrade button with other sidebar buttons
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/13a21819-24b1-4aee-b9fa-1e6830992e53

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 22:37:06 +00:00
copilot-swe-agent[bot]andGitHub 21be0af9c1 Initial plan 2026-04-13 22:36:23 +00:00
Sovran_SystemsandGitHub f2a94fa1b5 Merge pull request #225 from naturallaw777/copilot/rename-sparrow-bisq-auto-link
Rename Sparrow/Bisq "Auto-Connect" to "Auto-Link" in Hub
2026-04-13 17:33:47 -05:00
a086ab689e Rename Sparrow/Bisq Auto-Connect to Auto-Link in sovran-hub.nix
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c1eaaa7d-cfa4-4202-b37c-e6d0c1e49fcd

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 22:32:04 +00:00
copilot-swe-agent[bot]andGitHub 5b0babed1f Initial plan 2026-04-13 22:31:26 +00:00
Sovran_SystemsandGitHub df88f7c30a Merge pull request #224 from naturallaw777/copilot/update-wallpaper-installation
[WIP] Update wallpaper installation to only include ultrawide version
2026-04-13 17:26:00 -05:00
fc5432398f Remove standard wallpaper and screen-detection logic, use ultrawide only
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3d5ffac9-c152-4ea7-ba54-cb024dc4acae

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 22:25:36 +00:00
copilot-swe-agent[bot]andGitHub bf9f596bb3 Initial plan 2026-04-13 22:24:18 +00:00
Sovran_SystemsandGitHub 40dd601e21 Merge pull request #223 from naturallaw777/copilot/replace-locks-with-initialization-script
Replace dconf locks with first-login theme initialization script
2026-04-13 17:20:40 -05:00
2f67a91b70 feat: replace dconf locks with first-login sovran-theme-init script
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/bb7956f3-e618-4998-8f80-4437478df0f9

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 22:14:15 +00:00
copilot-swe-agent[bot]andGitHub 5e4e2c8d71 Initial plan 2026-04-13 22:11:54 +00:00
Sovran_SystemsandGitHub e47a238cc8 Merge pull request #222 from naturallaw777/copilot/update-password-labels-clarity
Clarify System Passwords labels and Change Password modal copy in Sovran Hub
2026-04-13 17:00:53 -05:00
5678b69d4f fix: clarify password labels and change password UI in Sovran Hub
- sovran-hub.nix: rename credential labels for clarity
  - "Free Account — Password" → "Free Account / Hub Login — Password"
  - "Root Password" → "Administrator (root) Password"
  - "SSH Passphrase" → "SSH Passphrase — use via: ssh root@localhost"
- service-detail.js: update Change Password button text
  - "Change Password" → "Change Free Account Password"
- service-detail.js: update Change Password modal
  - Title: "Change Free Account & Hub Login Password"
  - Description: adds Hub login warning
  - Warning note: warns about desktop AND Hub login change
  - Success message: "Free account & Hub login password changed successfully."

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/2ac6f3cf-cf94-47e9-86ac-1321cd5ff728

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 21:59:35 +00:00
copilot-swe-agent[bot]andGitHub 6126fbf0ca Initial plan 2026-04-13 21:58:15 +00:00
Sovran_SystemsandGitHub 7244f559c1 Merge pull request #221 from naturallaw777/copilot/update-sovran-hub-desktop-file
fix: match StartupWMClass to Brave's actual Wayland app_id for dock icon
2026-04-12 22:59:04 -05:00
892305e416 fix: set StartupWMClass to actual Brave Wayland app_id, remove ignored flags
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/fa122077-31dc-41d8-8a54-720d747d4dda

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 03:58:23 +00:00
copilot-swe-agent[bot]andGitHub 6da8730453 Initial plan 2026-04-13 03:57:26 +00:00
Sovran_SystemsandGitHub 08b3e5645e Merge pull request #220 from naturallaw777/copilot/fix-brave-wrapper-flags
[WIP] Restore critical Brave flags in hub-brave-wrapper
2026-04-12 20:20:49 -05:00
852098439e Fix: Restore missing Brave Wayland app-id flags and revert StartupWMClass
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ca8bd019-d5f6-42e5-bc39-1de367224ae5

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 01:20:29 +00:00
copilot-swe-agent[bot]andGitHub be6766871a Initial plan 2026-04-13 01:19:35 +00:00
Sovran_SystemsandGitHub 0e1dbb8809 Merge pull request #219 from naturallaw777/copilot/fix-hub-autolaunch-script
fix: add coreutils to hub-autolaunch-script PATH
2026-04-12 20:14:21 -05:00
d82d871b88 fix: add coreutils to hub-autolaunch-script PATH so seq and touch are available
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4265b3b7-44b2-4209-8364-19b9d44d4f99

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 01:09:05 +00:00
copilot-swe-agent[bot]andGitHub 9d81af72ff Initial plan 2026-04-13 01:08:24 +00:00
Sovran_SystemsandGitHub 40180b5cb7 Merge pull request #218 from naturallaw777/copilot/fix-login-ux-issue
Auto-login on desktop launch: skip duplicate password prompt
2026-04-12 20:04:07 -05:00
b25c077835 Add localhost-only /auto-login endpoint and update Brave launch URL
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a4089cd6-1729-441f-adbf-1fb1c990a4f5

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 01:01:58 +00:00
copilot-swe-agent[bot]andGitHub 97a7a9163e Initial plan 2026-04-13 00:59:53 +00:00
Sovran_SystemsandGitHub 75e79f2a16 Merge pull request #217 from naturallaw777/copilot/fix-sovran-hub-icon-matching
[WIP] Fix Sovran Hub icon matching in GNOME dock
2026-04-12 19:52:39 -05:00
340c1cd0f5 Fix GNOME dock icon matching for Sovran Hub on Wayland
- Change StartupWMClass from 'sovran-hub' to 'brave-localhost__-Default' in
  the .desktop file so GNOME Shell matches the window to the correct launcher
- Remove --gtk-application-id and --wayland-app-id flags from hub-brave-wrapper
  since Brave ignores them in --app= mode on Wayland

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/daa85aaf-5b87-448d-8336-d94dc2dfe727

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 00:52:07 +00:00
copilot-swe-agent[bot]andGitHub 211a32db45 Initial plan 2026-04-13 00:50:55 +00:00
Sovran_SystemsandGitHub 1da6fb9cd6 Merge pull request #216 from naturallaw777/copilot/fix-sovran-hub-icon-issue-again
Fix Sovran Hub dock icon: set Wayland app_id to match .desktop StartupWMClass
2026-04-12 19:34:17 -05:00
5bdcba8a90 Add --gtk-application-id and --wayland-app-id flags to fix GNOME dock icon on Wayland
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e02d4ee6-56de-49d6-8852-3368232d8d77

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 00:26:34 +00:00
copilot-swe-agent[bot]andGitHub 9f47fd6ba3 Initial plan 2026-04-13 00:25:41 +00:00
Sovran_SystemsandGitHub 0f8cdc9376 Merge pull request #215 from naturallaw777/copilot/fix-infinite-recursion-error
fix(nixos): break circular dependency between hub-brave-wrapper and sovran-hub-web
2026-04-12 19:16:57 -05:00
aaa2743fcc fix: break circular dependency in hub-brave-wrapper by using stable system path
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7e18de52-3666-415d-b5cb-eff532805a89

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 00:15:12 +00:00
copilot-swe-agent[bot]andGitHub 377bb63122 Initial plan 2026-04-13 00:14:35 +00:00
Sovran_SystemsandGitHub a2269c927e Merge pull request #214 from naturallaw777/copilot/fix-sovran-hub-icon-issue
[WIP] Fix Sovran Hub icon not matching Brave window
2026-04-12 19:08:56 -05:00
314123fcd8 fix: use stable profile dir and GNOME hints in hub-brave-wrapper
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/005f3030-3821-4677-8744-f76770fbbc25

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-13 00:08:13 +00:00
copilot-swe-agent[bot]andGitHub e6c76e636e Initial plan 2026-04-13 00:07:01 +00:00
Sovran_SystemsandGitHub 5084d6ebb8 Merge pull request #213 from naturallaw777/copilot/remove-chroot-chpasswd-block
[WIP] Remove chroot/chpasswd block from installer script
2026-04-12 18:10:37 -05:00
b610e76659 Fix chpasswd: remove broken chroot from installer, defer to first-boot service
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e55c1e70-0958-4d77-a222-52dccc9459b2

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 23:10:19 +00:00
copilot-swe-agent[bot]andGitHub 3fd000ac4e Initial plan 2026-04-12 23:08:49 +00:00
Sovran_SystemsandGitHub 3a59944967 Merge pull request #212 from naturallaw777/copilot/update-installer-password-prompt
[WIP] Update installer to show generated login password
2026-04-12 16:38:34 -05:00
533c981a70 fix(installer): generate diceware password during install and display before reboot
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ed1c266b-2f38-4831-9ba0-fa0f59cd162b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 21:38:18 +00:00
copilot-swe-agent[bot]andGitHub 92b46d1bba Initial plan 2026-04-12 21:35:00 +00:00
Sovran_SystemsandGitHub 317157de2d Merge pull request #211 from naturallaw777/copilot/fix-headscale-cli-syntax
[WIP] Fix Headscale CLI syntax issues in documentation
2026-04-12 15:54:01 -05:00
543a9df0bf feat: add sovran-provisioner.nix and fix headscale 0.28.0 CLI syntax in docs
- Create modules/core/sovran-provisioner.nix with Flask provisioner API,
  Headscale 0.28.0 config, Caddy reverse proxy, auto-bootstrap service,
  and firewall rules. Python script uses get_user_id() + -u <id> syntax.
- Fix docs/remote-deploy-headscale.md:
  - nodes register now uses -u <id> instead of --user <name>
  - preauthkeys create one-liner uses -u <id> -e 2h -o json
  - preauthkeys list/expire updated to 0.28.0 syntax (no --user on list)
  - tailscale up in Part 2 now includes --accept-dns=false
  - Add Troubleshooting section: VPN conflicts, RATELIMIT logs,
    connection refused, user ID lookup

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/25b789a6-8b2c-4e42-afd4-f8e8e5c61f2c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 20:53:10 +00:00
copilot-swe-agent[bot]andGitHub 8f792bb192 Initial plan 2026-04-12 20:50:10 +00:00
Sovran_SystemsandGitHub 7ba223a25a Merge pull request #209 from naturallaw777/copilot/update-gnome-app-folders
[WIP] Update GNOME app folders to organize missing apps
2026-04-12 14:08:24 -05:00
9db77b84bd Add missing apps to Office and Terminal GNOME app folders
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8d9e25f6-c812-4f06-8a8b-d19728caea05

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 19:08:08 +00:00
copilot-swe-agent[bot]andGitHub 8af03e53db Initial plan 2026-04-12 19:07:20 +00:00
Sovran_SystemsandGitHub e190cce593 Merge pull request #208 from naturallaw777/copilot/fix-ui-hang-on-update
fix: exempt update/rebuild status endpoints from auth to resolve post-restart UI hang
2026-04-12 13:49:07 -05:00
c498064e80 fix: exempt update/rebuild status endpoints from auth to fix post-restart polling hang
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d6d0347e-37d0-48bf-8e38-b7828ce4bb3f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 18:48:00 +00:00
copilot-swe-agent[bot]andGitHub 56652c37f4 Initial plan 2026-04-12 18:47:12 +00:00
Sovran_SystemsandGitHub 8565cda7b4 Merge pull request #207 from naturallaw777/copilot/fix-security-reset-overlay-issues
Fix security reset overlay: backdrop blur + close support modal on activation
2026-04-12 13:38:59 -05:00
18124ff2a1 Fix broken UI on security reset overlay: add backdrop-filter and close support modal
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/747e607a-1980-434c-9278-344ea75b8bc1

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 18:34:38 +00:00
copilot-swe-agent[bot]andGitHub d61211e300 Initial plan 2026-04-12 18:33:50 +00:00
Sovran_SystemsandGitHub 17e6d0d180 Merge pull request #206 from naturallaw777/copilot/update-gnome-power-settings
[WIP] Update GNOME power management settings for server roles
2026-04-12 13:18:24 -05:00
46a112a8e1 feat: disable GNOME auto-suspend, screen dim, and screen lock defaults
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0ef47453-73af-4c18-a63f-d4bcccce2f37

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 18:17:52 +00:00
copilot-swe-agent[bot]andGitHub a8efb3d880 Initial plan 2026-04-12 18:16:38 +00:00
Sovran_SystemsandGitHub be0eebdb8b Merge pull request #205 from naturallaw777/copilot/fix-security-banner-issue
Fix security banner reappearing after global security reset
2026-04-12 13:13:18 -05:00
8caee2ec22 fix: write SECURITY_BANNER_DISMISSED_FLAG after security reset to prevent banner reappearing after reboot
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8a33795f-2791-4029-98c3-1d703054404f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 18:08:22 +00:00
copilot-swe-agent[bot]andGitHub fe51d69700 Initial plan 2026-04-12 18:06:28 +00:00
Sovran_SystemsandGitHub 116197e9fb Merge pull request #204 from naturallaw777/copilot/add-login-page-for-hub-authentication
Add password authentication to Sovran Hub web interface
2026-04-12 10:42:05 -05:00
56e1da93c1 Address code review feedback: improve session secret generation, document rate-limit design
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/afb996f6-f6f5-4d4a-9f99-e46e3f89b4d7

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 15:40:25 +00:00
02e40e6634 Add Hub web authentication: login page, session middleware, logout button
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/afb996f6-f6f5-4d4a-9f99-e46e3f89b4d7

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 15:37:35 +00:00
copilot-swe-agent[bot]andGitHub 650d693849 Initial plan 2026-04-12 15:31:33 +00:00
Sovran_SystemsandGitHub 1dac6a0527 Merge pull request #203 from naturallaw777/copilot/fix-gnome-keyring-authentication
fix: disable auto-login, diceware passwords, decoupled security reset UX
2026-04-12 10:12:12 -05:00
17f89fa773 fix: disable auto-login, diceware passwords, improved security reset UX, fix GNOME keyring
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/82a54a25-4844-4a41-afcc-c034cebbd6ed

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 15:08:25 +00:00
copilot-swe-agent[bot]andGitHub 38acee7319 Initial plan 2026-04-12 15:01:51 +00:00
naturallaw777 7cba8e6258 readme update 2026-04-12 09:26:15 -05:00
Sovran_SystemsandGitHub ec641b1b9b Merge pull request #202 from naturallaw777/copilot/fix-sovran-hub-update-issue
Fix update process killed mid-run by nixos-rebuild switch
2026-04-12 09:23:04 -05:00
008a003fa1 fix: prevent nixos-rebuild from killing in-flight update; improve stale status recovery
Part A (modules/core/sovran-hub.nix):
- Add restartIfChanged=false and stopIfChanged=false to sovran-hub-update service
- Add restartIfChanged=false and stopIfChanged=false to sovran-hub-rebuild service
These prevent nixos-rebuild switch from terminating an in-flight update mid-execution.

Part B (app/sovran_systemsos_web/server.py):
- Replace _recover_stale_status() with improved version
- Use MainPID + os.kill() to guard against transient is-active lies during daemon-reload
- Use ExecMainStatus (actual exit code) instead of Result (may be stale from prior run)

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/63bf2cd5-9c02-4542-8926-44aa9ed63bf0

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 13:47:49 +00:00
copilot-swe-agent[bot]andGitHub 8310028546 Initial plan 2026-04-12 13:45:19 +00:00
naturallaw777 ac235f2e38 readme update 2026-04-12 08:32:54 -05:00
naturallaw777 e3ecdfafb7 readme update 2026-04-12 08:30:11 -05:00
Sovran_SystemsandGitHub 37bc0c6192 Merge pull request #201 from naturallaw777/copilot/fix-update-modal-message
fix: check_for_updates() tri-state return to unblock updates on inconclusive checks
2026-04-12 07:59:45 -05:00
536b3bfa78 fix: tri-state check_for_updates() to prevent blocking updates on inconclusive checks
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6bdd26ad-7b2f-455c-8b34-6be3de48bd9a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 12:58:42 +00:00
copilot-swe-agent[bot]andGitHub 57de0d31a7 Initial plan 2026-04-12 12:56:17 +00:00
Sovran_SystemsandGitHub bd1acfa266 Merge pull request #200 from naturallaw777/copilot/fix-dconf-settings-priority
[WIP] Fix dconf settings priority for GNOME custom configurations
2026-04-12 07:25:48 -05:00
a05ca90b2d Add dconf locks to enforce GNOME custom settings on new installs
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5363d209-197f-4011-ac43-2e5ae3f9931f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 12:25:19 +00:00
copilot-swe-agent[bot]andGitHub ece73148cd Initial plan 2026-04-12 12:24:20 +00:00
Sovran_SystemsandGitHub 3ead52583f Merge pull request #199 from naturallaw777/copilot/fix-stale-status-logging
Fix update modal UX when hub restarts mid-update
2026-04-12 07:17:59 -05:00
c7005c93b5 fix: user-friendly stale recovery messages and complete log on reconnect
- _recover_stale_status(): returns True when corrected; changes message from
  internal '[Hub] Stale RUNNING...' to user-friendly text
- _startup_recover_stale_status(): sets _update_recovery_happened flag when
  update recovery happens at startup
- api_updates_status(): uses offset=0 when recovery happened so frontend
  receives the full log, not just a stale delta
- pollUpdateStatus(): when reconnecting after server-down with update done,
  resets offset to 0, re-fetches full log, shows '[Server restarted — update
  completed successfully.]' instead of '[Server reconnected]'

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/90b535d1-bc3b-4147-9d62-3c7a93b1c8e4

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 12:16:22 +00:00
copilot-swe-agent[bot]andGitHub d2d2ed58a6 Initial plan 2026-04-12 12:05:34 +00:00
Sovran_SystemsandGitHub a48fe1c882 Merge pull request #198 from naturallaw777/copilot/add-gnome-keyring-unlock-service
Unlock GNOME Keyring on session start using stored free-user password
2026-04-12 06:49:28 -05:00
d07ea9a227 Add gnome-keyring-unlock service and update change-free-password to re-key keyring
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/311643b0-e3d5-4ee5-a8f8-da5baa59cab8

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 11:47:42 +00:00
copilot-swe-agent[bot]andGitHub 0f77c6834c Initial plan 2026-04-12 11:45:49 +00:00
naturallaw777 f1188abff5 readme update 2026-04-12 00:11:46 -05:00
Sovran_SystemsandGitHub f66de58b78 Merge pull request #197 from naturallaw777/copilot/fix-update-modal-bad-ux
[WIP] Fix update modal to show system status correctly
2026-04-12 00:01:37 -05:00
90d423e94b fix: remove _recover_stale_status() from api_updates_run, verify unit active before trusting RUNNING status
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3a855cab-5d0d-4c32-984c-5c88d922934e

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 05:00:03 +00:00
copilot-swe-agent[bot]andGitHub e1a06079fd Initial plan 2026-04-12 04:58:42 +00:00
Sovran_SystemsandGitHub f0f9f6854c Merge pull request #196 from naturallaw777/copilot/fix-sparrow-desktop-references
fix: correct Sparrow desktop entry filename in dock favorites and Bitcoin app folder
2026-04-11 23:49:10 -05:00
73dd4fbb4b fix: correct Sparrow desktop file name from sparrow-desktop.desktop to sparrow.desktop
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9085bdd6-ea69-4652-b862-dbc96b85eed0

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 04:48:33 +00:00
copilot-swe-agent[bot]andGitHub 2edc4c1829 Initial plan 2026-04-12 04:47:49 +00:00
Sovran_SystemsandGitHub 9dab44f7e3 Merge pull request #195 from naturallaw777/copilot/fix-disabled-bitcoin-tile-sync
Fix disabled Bitcoin tile incorrectly showing "Syncing Timechain" when another variant is active
2026-04-11 23:36:19 -05:00
c8fb773be4 Fix disabled Bitcoin tile incorrectly showing Syncing Timechain
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b87b574a-c97e-45c0-a4c6-396fe3c9c418

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 04:33:38 +00:00
Sovran_SystemsandGitHub 5d43f7e0d7 Merge pull request #194 from naturallaw777/copilot/fix-update-system-ux-issue
Fix false "Update complete" + Reboot button when no updates are available
2026-04-11 23:30:26 -05:00
31f1e16a3c Fix stale update state causing false Update complete when no updates available
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/1cc7ff30-4a1b-46f7-a20a-2ec0cbdfe291

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 04:29:11 +00:00
copilot-swe-agent[bot]andGitHub 458b8fae0b Initial plan 2026-04-12 04:28:13 +00:00
copilot-swe-agent[bot]andGitHub 6e5863ed48 Initial plan 2026-04-12 04:26:11 +00:00
Sovran_SystemsandGitHub 70d5286f87 Merge pull request #193 from naturallaw777/copilot/fix-update-system-flow
fix: skip update flow when no updates are available
2026-04-11 23:14:11 -05:00
ad6cf6c498 fix: show already up to date instead of full update flow when no updates available
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/fb575403-f4f0-41bb-8fb1-12f7d9874009

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 04:13:13 +00:00
copilot-swe-agent[bot]andGitHub 51537c47b3 Initial plan 2026-04-12 04:09:58 +00:00
Sovran_SystemsandGitHub 82ecf581c3 Merge pull request #192 from naturallaw777/copilot/fix-iso-build-warning-root-password-options
Fix ISO build warning: multiple root password options set
2026-04-11 21:20:24 -05:00
a5ff38786c Fix ISO build warning: use lib.mkForce to override root password options
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/aa57a263-33e8-4379-9cc3-379125371b46

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 02:14:14 +00:00
copilot-swe-agent[bot]andGitHub eb3463cdfe Initial plan 2026-04-12 02:13:26 +00:00
Sovran_SystemsandGitHub ad4337c12f Merge pull request #191 from naturallaw777/copilot/fix-update-modal-race-condition
fix: correct stale RUNNING update status after hub restart mid-update
2026-04-11 20:51:19 -05:00
d5b16da57e fix: detect and correct stale RUNNING update status on poll and startup
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3c41cfb3-08f3-4e27-900c-7312a9204d4c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 01:47:20 +00:00
copilot-swe-agent[bot]andGitHub 1a7ed3cb6c Initial plan 2026-04-12 01:43:05 +00:00
naturallaw777 1ecd1e1470 nixpkgs update 2026-04-11 20:21:24 -05:00
naturallaw777 7e1c7b0dbd fixed icon color 2026-04-11 20:12:12 -05:00
Sovran_SystemsandGitHub ab1150266b Merge pull request #190 from naturallaw777/copilot/update-vaultwarden-icon-color
[WIP] Change Vaultwarden icon color to white
2026-04-11 20:10:12 -05:00
97ceb0c0ce Change Vaultwarden icon stroke color from black to white
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8377658e-a339-407d-9813-b3ada2b710a5

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 01:09:51 +00:00
copilot-swe-agent[bot]andGitHub e41b78897d Initial plan 2026-04-12 01:09:00 +00:00
naturallaw777 67678b7927 cleaned up repo 2026-04-11 20:02:43 -05:00
Sovran_SystemsandGitHub 903be87154 Merge pull request #189 from naturallaw777/copilot/fix-headscale-database-deprecation
Fix deprecated Headscale database settings for nixos-unstable
2026-04-11 19:54:36 -05:00
b767b6d53b fix: replace deprecated db_type/db_path with database block in headscale settings
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/690ff811-901e-4539-b11b-998bc120186f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 00:49:03 +00:00
copilot-swe-agent[bot]andGitHub 35e56afa0f Initial plan 2026-04-12 00:48:14 +00:00
Sovran_SystemsandGitHub aae1aa8779 Merge pull request #188 from naturallaw777/copilot/cleanup-remove-reverse-ssh-tunnel-code
Remove reverse SSH tunnel, fix enrollToken footgun, fix RDP/deploy-key docs
2026-04-11 19:21:04 -05:00
3ca15d0da4 Cleanup: Remove reverse SSH tunnel code, fix documentation accuracy
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3941ead1-cb20-4686-92bb-46e447791ae3

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-12 00:19:25 +00:00
copilot-swe-agent[bot]andGitHub af14622e45 Initial plan 2026-04-12 00:14:43 +00:00
Sovran_SystemsandGitHub 8f8cc15ff4 Merge pull request #187 from naturallaw777/copilot/build-remote-deployment-system
Add Headscale-based remote deployment system with auto-provisioning ISO support
2026-04-11 18:37:58 -05:00
8f97aa416f Build remote deployment system using Headscale (self-hosted Tailscale)
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7fa16927-250f-4af4-bb11-e22ef7b2c997

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-11 23:33:35 +00:00
copilot-swe-agent[bot]andGitHub 9ec8618f7d Initial plan 2026-04-11 23:27:26 +00:00
Sovran_SystemsandGitHub 163afcc7e3 Merge pull request #186 from naturallaw777/copilot/add-remote-deployment-mode
feat: Remote deployment mode for headless install and post-install access
2026-04-11 17:31:14 -05:00
6fc66ba13f feat: add remote deployment mode (remote-deploy.nix, headless installer, ISO SSH/mDNS)
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8e2ed0be-2db9-4437-81d7-c6efec45d6db

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-11 22:27:55 +00:00
copilot-swe-agent[bot]andGitHub 357624193b Initial plan 2026-04-11 22:23:50 +00:00
Sovran_SystemsandGitHub 5a3432a93b Merge pull request #185 from naturallaw777/copilot/add-svg-wallpapers
Replace fetchurl wallpaper with in-repo SVGs + one-shot resolution-aware init
2026-04-11 16:57:20 -05:00
ccdde31654 Add SVG wallpapers and rewrite wallpaper system with resolution detection
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/2649badc-c159-40bd-b569-5be0feb18f74

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-11 21:40:42 +00:00
copilot-swe-agent[bot]andGitHub 9c8f359c0d Initial plan 2026-04-11 21:37:04 +00:00
Sovran_SystemsandGitHub e9b94123b5 Merge pull request #184 from naturallaw777/copilot/add-legacy-cleanup-module
Add legacy Sovran_Systems cleanup on nixos-rebuild switch
2026-04-11 16:15:04 -05:00
6aa7a5a40b Add legacy-cleanup.nix to remove deprecated Sovran_Systems artifacts
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0c97ec90-556f-4bc9-86fe-c54022414704

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-11 21:14:19 +00:00
copilot-swe-agent[bot]andGitHub 1a742578d1 Initial plan 2026-04-11 21:12:49 +00:00
Sovran_Systems 0af6a7cf66 Updated landing page to highlight ways to use Sovran_SystemsOS and added donation information. 2026-04-09 17:58:41 -05:00
Sovran_SystemsandGitHub bd9f889982 Merge pull request #183 from naturallaw777/copilot/add-glass-effect-to-ui
[WIP] Add glass effect to hub and onboarding UI surfaces
2026-04-09 17:38:32 -05:00
1242f0bc0b Add glass/frosted-glass effect to hub and onboarding UI surfaces
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/47ed7298-e001-4ae0-9d35-7dd1e869d836

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 22:38:12 +00:00
copilot-swe-agent[bot]andGitHub 1bda7e9920 Initial plan 2026-04-09 22:33:20 +00:00
Sovran_SystemsandGitHub 03b711247e Merge pull request #182 from naturallaw777/copilot/align-sovran-hub-theme
Align hub visual theme with website: Inter font stack and softer green backgrounds
2026-04-09 17:26:48 -05:00
86672c3c28 Align hub CSS with website: Inter font stack and softer green backgrounds
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4472f4ff-0bf5-4150-997b-adb0a9b54898

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 22:25:53 +00:00
copilot-swe-agent[bot]andGitHub 47049a94bd Initial plan 2026-04-09 22:24:36 +00:00
Sovran_SystemsandGitHub 7bda7abeb8 Merge pull request #181 from naturallaw777/copilot/soften-green-colors-hub-ui
[WIP] Update green colors for a calmer UI experience
2026-04-09 17:15:50 -05:00
db093a04ad style: soften green accent colors across hub CSS files
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8c50fde4-c2f8-49f8-953b-1a9e066041e5

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 22:15:27 +00:00
copilot-swe-agent[bot]andGitHub df353d1318 Initial plan 2026-04-09 22:11:49 +00:00
Sovran_SystemsandGitHub 78252b9d07 Merge pull request #180 from naturallaw777/copilot/update-sovran-hub-theme
Hub theme v8: shift surfaces to near-black, keep green for accents only
2026-04-09 14:52:22 -05:00
db3ff345cf feat: rebalance Hub theme to near-black with green accents (v8)
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7d5cc1b4-de38-4abc-be28-fc279e97d7b1

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 19:50:39 +00:00
copilot-swe-agent[bot]andGitHub d398faa0ed Initial plan 2026-04-09 19:45:43 +00:00
Sovran_SystemsandGitHub c3558b4505 Merge pull request #179 from naturallaw777/copilot/update-sovran-hub-visual-theme
[WIP] Update visual theme of Sovran Hub to align with new app icon
2026-04-09 14:37:39 -05:00
7f135da474 Re-theme Sovran Hub UI to deep green palette matching new icon
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7aff0b39-5cd6-4008-b1d0-1519a7c0793d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 19:36:10 +00:00
copilot-swe-agent[bot]andGitHub 7fdee314c8 Initial plan 2026-04-09 19:30:28 +00:00
Sovran_SystemsandGitHub cfd9d4c284 Merge pull request #178 from naturallaw777/copilot/update-sovran-hub-branding-again
[WIP] Update Sovran Hub branding with new icon and styling
2026-04-09 14:24:33 -05:00
1efd5c2086 Update Sovran Hub branding to finalized Rounded + Gradient Plus Arc + Core icon
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/309d3e00-70ba-4927-88c7-0a72cc5ed660

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 19:24:16 +00:00
copilot-swe-agent[bot]andGitHub bd5ae05e20 Initial plan 2026-04-09 19:22:49 +00:00
Sovran_SystemsandGitHub bcdef677b7 Merge pull request #177 from naturallaw777/copilot/update-sovran-hub-branding
[WIP] Update Sovran Hub branding to new Arc + Core icon
2026-04-09 14:12:03 -05:00
785e5539b3 Replace sovran-hub-icon.svg with Arc+Core design and update header logo reference
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9cda1def-017d-452a-8df6-43a6adbf021c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 19:11:09 +00:00
copilot-swe-agent[bot]andGitHub 21ea5e5303 Initial plan 2026-04-09 19:09:56 +00:00
Sovran_SystemsandGitHub 04a675cb84 Merge pull request #176 from naturallaw777/copilot/add-scoped-sudo-rules
[WIP] Add scoped sudo rules for sovran-support user
2026-04-09 13:46:34 -05:00
b331c49b61 Add scoped sudo rules to tech-support.nix for sovran-support user
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e108b70d-de49-4d19-87a7-f093df3b05d3

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 18:46:13 +00:00
copilot-swe-agent[bot]andGitHub 21723a6860 Initial plan 2026-04-09 18:44:42 +00:00
naturallaw777 3d646fec0a key 2026-04-09 12:59:11 -05:00
Sovran_SystemsandGitHub 4f997b1c4a Merge pull request #174 from naturallaw777/copilot/fix-security-reset-user-experience
[WIP] Improve user feedback during security reset process
2026-04-09 12:30:51 -05:00
0fb532d46f Fix security reset UX: full-screen overlay, CSS class bug, and auto-reconnect
- Add security-reset-overlay HTML element to index.html that shows immediately
  when the user confirms "Erase & Reset", before the synchronous API call runs
- Add .security-reset-overlay CSS to security.css (reuses reboot-card styles,
  adds fade-in animation, z-index 1000 to sit above all other content)
- Fix reboot overlay class bug: classList.add("open") → classList.add("visible")
  so the overlay actually renders per the .reboot-overlay.visible CSS rule
- Show overlay step text "Erasing data and resetting credentials…" during wipe,
  update to "Reset complete. Rebooting now…" when API returns
- Call waitForServerReboot() (globally defined in update.js) after reset so the
  page auto-reloads when the system comes back online
- Hide the security-reset-overlay and re-enable the button on error

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/185d0b41-d54d-4ea2-93d6-bfb7c15b8aed

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 17:29:46 +00:00
copilot-swe-agent[bot]andGitHub 5bb2c67b1b Initial plan 2026-04-09 17:26:39 +00:00
Sovran_SystemsandGitHub e8b1195c66 Merge pull request #173 from naturallaw777/copilot/fix-onboarding-timezone-locale
[WIP] Fix onboarding timezone and locale step for NixOS
2026-04-09 12:22:35 -05:00
a415431d93 Fix NixOS timezone/locale: use declarative custom.nix config + nixos-rebuild instead of timedatectl/localectl
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/334ffeb7-2160-4938-bc4e-fb7693a1154f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 17:22:06 +00:00
copilot-swe-agent[bot]andGitHub 885fc7f099 Initial plan 2026-04-09 17:17:07 +00:00
Sovran_SystemsandGitHub 95440529ef Merge pull request #172 from naturallaw777/copilot/fix-timezone-locale-endpoints
[WIP] Fix timezone and locale endpoints to remove unnecessary sudo
2026-04-09 06:00:45 -05:00
8ea133a2a1 Remove sudo from timedatectl and localectl subprocess calls
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/925a72d0-2cdd-4940-b338-07772f3f8a68

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 11:00:18 +00:00
copilot-swe-agent[bot]andGitHub 701c400916 Initial plan 2026-04-09 10:49:18 +00:00
Sovran_SystemsandGitHub b4ca6fe7fe Merge pull request #171 from naturallaw777/copilot/fix-verify-integrity-flag
[WIP] Fix integrity verification by removing no-build-output flag
2026-04-08 23:05:55 -05:00
1843c98e98 Remove invalid --no-build-output flag from nix build command
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/2989f7c9-f544-4a2e-b073-7d9518b41e60

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 04:05:27 +00:00
copilot-swe-agent[bot]andGitHub 2cfb23e670 Initial plan 2026-04-09 04:04:38 +00:00
Sovran_SystemsandGitHub 54fb17c9c8 Merge pull request #170 from naturallaw777/copilot/fix-nixos-rebuild-usage-error
[WIP] Fix nixos-rebuild command by replacing it with nix build
2026-04-08 22:53:38 -05:00
61cee57d4e Fix verify-integrity: replace nixos-rebuild with nix build (nixos-rebuild does not support -o)
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3c0ca796-fe76-4985-9956-5915b2993b08

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 03:53:21 +00:00
copilot-swe-agent[bot]andGitHub dca53835f4 Initial plan 2026-04-09 03:51:28 +00:00
Sovran_SystemsandGitHub 385a3cb215 Merge pull request #169 from naturallaw777/copilot/fix-nixos-rebuild-result-symlink
[WIP] Fix missing result symlink in verify integrity check
2026-04-08 22:44:25 -05:00
2bf9c6657b Fix nixos-rebuild result symlink by passing explicit -o flag
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/52242cb6-8038-446d-bafb-9fe6666b31b9

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 03:43:45 +00:00
copilot-swe-agent[bot]andGitHub d77dde4020 Initial plan 2026-04-09 03:43:01 +00:00
Sovran_SystemsandGitHub 967df9664d Merge pull request #168 from naturallaw777/copilot/fix-nixos-rebuild-command
Fix "Running System Match" always failing due to unsupported --print-out-paths flag
2026-04-08 22:24:39 -05:00
489e326ccc Fix Verify System Integrity: use temp dir + result symlink instead of --print-out-paths
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b90b9352-56a0-4987-822b-ea4b9d4fdf92

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 03:23:42 +00:00
copilot-swe-agent[bot]andGitHub 5cd9b6bb3d Initial plan 2026-04-09 03:21:30 +00:00
Sovran_SystemsandGitHub fa71a7da97 Merge pull request #167 from naturallaw777/copilot/fix-system-integrity-check
Fix: Running System Match always fails due to cwd-relative result symlink
2026-04-08 22:03:36 -05:00
5bd5c03e2f Fix: Use --print-out-paths instead of ./result symlink in verify-integrity endpoint
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b365659c-e6c9-45bf-9b12-b89addfbbbdd

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 02:59:32 +00:00
copilot-swe-agent[bot]andGitHub bf9e82cd79 Initial plan 2026-04-09 02:58:35 +00:00
Sovran_SystemsandGitHub a18ef1447a Merge pull request #166 from naturallaw777/copilot/fix-subprocess-command-paths
[WIP] Fix subprocess command paths in verify-integrity endpoint
2026-04-08 21:08:58 -05:00
ecfd2e9f51 fix: use absolute paths for nix and nixos-rebuild in verify-integrity endpoint
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7de38316-a649-4395-bfb4-c12a07741078

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 02:08:29 +00:00
copilot-swe-agent[bot]andGitHub f8bdc1cb15 Initial plan 2026-04-09 02:06:47 +00:00
Sovran_SystemsandGitHub 431773f6d5 Merge pull request #165 from naturallaw777/copilot/implement-random-password-generation
[WIP] Implement random password generation for free user
2026-04-08 20:59:14 -05:00
2fae4ccc79 Implement security overhaul: remove seal/legacy system, add Security modal and random passwords
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6e7593c4-f741-4ddc-9bce-8c558a4af014

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 01:58:42 +00:00
copilot-swe-agent[bot]andGitHub 477d265de8 Initial plan 2026-04-09 01:49:35 +00:00
Sovran_SystemsandGitHub 6b14f811d6 Merge pull request #162 from naturallaw777/copilot/update-dconf-settings-app-folders
[WIP] Add GNOME app-folder dconf settings and remove updater from favorites
2026-04-08 19:18:51 -05:00
8e8082d2ae Add GNOME app-folder dconf settings and remove Sovran_SystemsOS_Updater from favorites
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ec32495a-2919-4b25-bd7c-459f9bdc3ba9

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 00:18:08 +00:00
copilot-swe-agent[bot]andGitHub b073564cce Initial plan 2026-04-09 00:16:48 +00:00
Sovran_SystemsandGitHub 586eba824a Merge pull request #161 from naturallaw777/copilot/add-timezone-locale-onboarding-step
[WIP] Add timezone and locale selection to onboarding wizard
2026-04-08 19:14:08 -05:00
9e081bec05 Add timezone/locale onboarding step (new Step 2), renumber existing steps 2-5 to 3-6
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/47f2ee8f-bd6c-4151-bd2d-3e9283cb02c0

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 00:13:44 +00:00
Sovran_SystemsandGitHub 4eac3c3498 Merge pull request #160 from naturallaw777/copilot/fix-brave-keyring-prompt
fix: suppress Brave keyring prompt on Hub launch and GDM auto-login
2026-04-08 19:08:46 -05:00
copilot-swe-agent[bot]andGitHub d9fba84243 Initial plan 2026-04-09 00:08:33 +00:00
f5d44e5c4b fix: suppress Brave keyring prompt via --password-store=basic and PAM GNOME Keyring auto-unlock
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/731f35e7-10c0-4641-8ec4-bd02f0dc98b4

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-09 00:03:09 +00:00
copilot-swe-agent[bot]andGitHub b17ccce53a Initial plan 2026-04-09 00:02:10 +00:00
Sovran_SystemsandGitHub a9e4bc2656 Merge pull request #159 from naturallaw777/copilot/fix-gnome-remote-desktop-service-again
[WIP] Fix gnome-remote-desktop service block in rdp.nix
2026-04-08 17:11:41 -05:00
c4238f2590 fix(rdp): add wantedBy block and remove || true from grdctl enable
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/47126fb3-6167-424d-9599-cd75e6447717

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 22:11:23 +00:00
copilot-swe-agent[bot]andGitHub 059eaefa0c Initial plan 2026-04-08 22:10:31 +00:00
Sovran_SystemsandGitHub cfa103f7b5 Merge pull request #158 from naturallaw777/copilot/revert-rdp-nix-to-914ad0e
revert: restore rdp.nix to pre-PR#147 working state
2026-04-08 16:30:59 -05:00
1bbf6094b3 revert: restore rdp.nix to pre-PR#147 working state with || true fix
- Remove systemd.services."gnome-remote-desktop".wantedBy = lib.mkForce []
  (was preventing the service from ever auto-starting)
- Remove systemctl start gnome-remote-desktop.service || true
  (was creating a systemd deadlock with before = ["gnome-remote-desktop.service"])
- Remove pkgs.systemd from setup script path
- Remove grdctl --system rdp disable-view-only || true
- Add || true to grdctl --system rdp enable (suppress harmless EROFS warning)

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/75c67389-947f-437d-95ba-427504935156

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 21:29:48 +00:00
copilot-swe-agent[bot]andGitHub 73401353e4 Initial plan 2026-04-08 21:28:22 +00:00
Sovran_SystemsandGitHub 64c32dfb4f Merge pull request #157 from naturallaw777/copilot/fix-gnome-remote-desktop-issues
Fix RDP: revert to system-level grdctl with declarative service masking + explicit start
2026-04-08 16:04:39 -05:00
ebd41797f7 Fix RDP: revert to system-level approach with declarative service masking and explicit start
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/cedebc7f-683e-469d-bd91-a0b87495d055

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 21:02:49 +00:00
copilot-swe-agent[bot]andGitHub 4c5e639cfa Initial plan 2026-04-08 21:00:10 +00:00
Sovran_SystemsandGitHub 4aaea32c39 Merge pull request #156 from naturallaw777/copilot/fix-gnome-remote-desktop-service
[WIP] Fix gnome-remote-desktop service handling on NixOS
2026-04-08 15:44:05 -05:00
e3916d48dd Fix RDP on NixOS: declarative service masking instead of grdctl --system rdp disable
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0701acec-7c63-419b-be17-57a912daedaf

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 20:43:35 +00:00
copilot-swe-agent[bot]andGitHub f7af81eba4 Initial plan 2026-04-08 20:41:59 +00:00
Sovran_SystemsandGitHub 0bafcee8af Merge pull request #155 from naturallaw777/copilot/update-gnome-remote-desktop-config
Fix RDP "Session Already Running" by switching to user-session screen sharing
2026-04-08 13:19:23 -05:00
b77fb2ed70 Fix RDP Session Already Running by using user-session screen sharing
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ab7b63b5-2a0a-4933-9fb2-36ac793e9f1a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 18:18:04 +00:00
copilot-swe-agent[bot]andGitHub 48926d1937 Initial plan 2026-04-08 18:15:54 +00:00
Sovran_SystemsandGitHub d3458da56b Merge pull request #154 from naturallaw777/copilot/fix-rdp-session-running-conflict
[WIP] Fix RDP 'Session Already Running' conflict using system-level screen sharing
2026-04-08 12:51:00 -05:00
45ee8da166 fix(rdp): remove session-level setup to fix Session Already Running conflict
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9ba5618b-db30-41c3-8031-68b9a9e5448c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 17:50:40 +00:00
copilot-swe-agent[bot]andGitHub f15e5616b7 Initial plan 2026-04-08 17:48:16 +00:00
Sovran_SystemsandGitHub 65e4086682 Merge pull request #153 from naturallaw777/copilot/fix-desktop-file-whitespace-bug
[WIP] Fix whitespace issue in desktop file causing unresponsive RDP session
2026-04-08 12:39:52 -05:00
0f1ebe339e Use warning message for system-level disable-view-only for consistency
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7683cf3e-15ca-4f1b-a485-5522fa4d6cd6

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 17:38:54 +00:00
4bbf7a3a67 Fix RDP frozen/unclickable screen: desktop whitespace, disable-view-only session+system
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7683cf3e-15ca-4f1b-a485-5522fa4d6cd6

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 17:38:24 +00:00
copilot-swe-agent[bot]andGitHub 6012373501 Initial plan 2026-04-08 17:37:18 +00:00
naturallaw777 71a08ae4d3 updated locals 2026-04-08 12:18:52 -05:00
naturallaw777 41f17c205c updated for user timezonez and languges and removed unsued code 2026-04-08 12:12:47 -05:00
Sovran_SystemsandGitHub 72bec5b77f Merge pull request #152 from naturallaw777/copilot/fix-branding-in-installer
[WIP] Fix branding in iso/installer.py
2026-04-08 11:59:54 -05:00
bcd4a49942 Fix installer branding and replace complete screen with auto-reboot
- Replace all "Sovran SystemsOS" (with space) with "Sovran_SystemsOS"
  - Line 241: landing page title
  - Line 262: internet notice text
  - Line 328: welcome/role-selection hero title (was "Sovran Systems")
  - Line 910: install progress title
- Replace push_complete method: remove credentials screen (username,
  password, first-boot note, reboot button) and replace with a simple
  status page that says "Rebooting…" then auto-reboots after 3 seconds
  via GLib.timeout_add_seconds

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/2a39f2d5-6aef-42cf-a94a-e1db5c6a601a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 16:59:14 +00:00
copilot-swe-agent[bot]andGitHub b47f0986d5 Initial plan 2026-04-08 16:57:47 +00:00
Sovran_SystemsandGitHub 4c67e3984f Merge pull request #151 from naturallaw777/copilot/remove-file-fixes-and-new-services
Remove legacy file_fixes_and_new_services system
2026-04-08 11:52:06 -05:00
08e8d6bf69 Remove legacy file_fixes_and_new_services system
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ae9f019a-3743-48b8-b251-feb17b1adbd4

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 16:50:58 +00:00
copilot-swe-agent[bot]andGitHub 3365ab5639 Initial plan 2026-04-08 16:49:49 +00:00
Sovran_SystemsandGitHub 973d2cec92 Merge pull request #150 from naturallaw777/copilot/fix-domains-dir-ownership
Fix /var/lib/domains caddy ownership and WordPress ADMIN_EMAIL for bare domains
2026-04-08 11:11:55 -05:00
fb4c268b8e Fix /var/lib/domains ownership and WordPress ADMIN_EMAIL generation
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/755b414e-9b63-448b-a57c-41d0ca45b5eb

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 16:09:10 +00:00
copilot-swe-agent[bot]andGitHub 3673ccf39b Initial plan 2026-04-08 16:00:59 +00:00
Sovran_SystemsandGitHub 21bf0ff03f Merge pull request #149 from naturallaw777/copilot/add-cache-busting-headers
[WIP] Add cache-busting and data-clearing HTTP headers
2026-04-08 09:52:40 -05:00
a2d2dac2b9 Add cache-busting and Clear-Site-Data headers for sovransystemsos.local browser access
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0e1cbb58-3e7f-412b-be95-8907caaab6f3

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 14:52:13 +00:00
Sovran_SystemsandGitHub 46f8eb5308 Merge pull request #148 from naturallaw777/copilot/create-brave-launcher-wrapper
Use ephemeral Brave profile for Hub desktop app to prevent data persistence
2026-04-08 09:48:08 -05:00
Sovran_SystemsandGitHub 907542b651 Merge pull request #147 from naturallaw777/copilot/fix-gnome-remote-desktop-capture
[WIP] Fix RDP frozen screen issue in GNOME Remote Desktop
2026-04-08 09:47:40 -05:00
5ab4021100 Fix RDP frozen screen: add session-level GNOME Remote Desktop configuration
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e099592f-2d1e-4894-a91c-b4ef9b4a5244

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 14:46:52 +00:00
73cd5faab0 Add Brave wrapper script for isolated, ephemeral Hub sessions
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ebc41311-f7da-40dd-b85b-87db3176a69a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 14:45:00 +00:00
copilot-swe-agent[bot]andGitHub 0ac7ac4343 Initial plan 2026-04-08 14:43:14 +00:00
copilot-swe-agent[bot]andGitHub 92734dd251 Initial plan 2026-04-08 14:36:01 +00:00
copilot-swe-agent[bot]andGitHub 08c8b7d09c Initial plan 2026-04-08 14:33:44 +00:00
Sovran_SystemsandGitHub 914ad0edf8 Merge pull request #146 from naturallaw777/copilot/fix-dock-icon-size-issue
Fix dock icon whitespace and RDP freeze from brave --app= on Wayland
2026-04-08 08:46:10 -05:00
cfd416002d Fix dock icon size and RDP frozen screen regressions from PR #144
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/25eb7e56-2284-4030-a9dd-75f2f9a2917c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 13:43:57 +00:00
copilot-swe-agent[bot]andGitHub 349de76b6b Initial plan 2026-04-08 13:40:51 +00:00
Sovran_SystemsandGitHub 9345e18259 Merge pull request #145 from naturallaw777/copilot/remove-sparrow-bisq-launch-feature
[WIP] Remove Sparrow and Bisq desktop launch feature from Hub tiles
2026-04-08 08:25:40 -05:00
360654fe58 Remove Sparrow and Bisq desktop launch feature from Hub tiles
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ffb330a3-9863-4f00-8476-67331a02a0b9

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 13:22:52 +00:00
Sovran_SystemsandGitHub 253ce8d16c Merge pull request #144 from naturallaw777/copilot/fix-dock-icon-size-issues
[WIP] Fix dock icon size issues for Sovran Hub
2026-04-08 08:17:43 -05:00
78b08758f1 fix: brave --app mode, StartupWMClass, and icon PNGs for Sovran Hub dock
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6f932322-cc0e-4fff-aca1-b853770c0817

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 13:17:09 +00:00
copilot-swe-agent[bot]andGitHub 9470ce74c1 Initial plan 2026-04-08 13:13:41 +00:00
copilot-swe-agent[bot]andGitHub cb0bcdb94c Initial plan 2026-04-08 13:09:47 +00:00
Sovran_SystemsandGitHub 5580e8a8b7 Merge pull request #141 from naturallaw777/copilot/fix-wayland-launch-issue
[WIP] Fix application launch issue in Wayland GNOME
2026-04-08 07:43:34 -05:00
Sovran_SystemsandGitHub 212f2f86fc Merge pull request #142 from naturallaw777/copilot/remove-tooltip-from-disabled-tiles
Remove internal `custom.nix` tooltip from disabled Hub tiles
2026-04-08 07:43:20 -05:00
Sovran_SystemsandGitHub e6fefb2510 Merge pull request #143 from naturallaw777/copilot/update-confirmation-messages
[WIP] Update confirmation messages for Bitcoin feature toggles
2026-04-08 07:43:06 -05:00
5b10ab4823 Fix api_desktop_launch for Wayland-only GNOME: run as free user with correct session env vars
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/237927a7-a65c-4c67-b1e2-e5bfd1b3bef7

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 12:42:39 +00:00
f021f56318 Update Bitcoin feature toggle confirmation messages to mention timechain preservation
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c318d542-bd1b-4fd8-a100-7ec8e5041623

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 12:41:45 +00:00
d5521ea681 Remove custom.nix tooltip from disabled tiles
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8bda7f98-8019-4dc6-8705-94cc21b53b23

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 12:40:54 +00:00
copilot-swe-agent[bot]andGitHub 7781d6c849 Initial plan 2026-04-08 12:40:12 +00:00
copilot-swe-agent[bot]andGitHub 4227024fba Initial plan 2026-04-08 12:39:45 +00:00
copilot-swe-agent[bot]andGitHub 7243f4444f Initial plan 2026-04-08 12:34:30 +00:00
Sovran_SystemsandGitHub c8d24998e8 Merge pull request #140 from naturallaw777/copilot/add-icon-pngs-for-sovran-hub
[WIP] Add PNG icons for Sovran Hub desktop application
2026-04-08 07:26:30 -05:00
f0b7152c41 fix: rasterize sovran-hub icon to PNG at standard hicolor sizes
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e414bb3e-f166-48b2-bac9-ad36c24aceb6

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 12:25:36 +00:00
copilot-swe-agent[bot]andGitHub 8d6a20d375 Initial plan 2026-04-08 12:22:13 +00:00
Sovran_SystemsandGitHub 8413093d43 Merge pull request #139 from naturallaw777/copilot/fix-flake-lock-issue
installer: pre-resolve flake lock to staging-dev instead of deleting it
2026-04-07 21:49:58 -05:00
1a8a1736bf fix: pre-resolve flake lock to staging-dev during installation
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/14550e27-a253-453b-b454-097575e924fa

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 02:48:59 +00:00
copilot-swe-agent[bot]andGitHub 51c7d172b3 Initial plan 2026-04-08 02:46:49 +00:00
Sovran_SystemsandGitHub 6999ae5680 Merge pull request #138 from naturallaw777/copilot/fix-onboarding-wizard-issues
onboarding: remove scroll boxes, fix footer spacing, add per-field domain saves
2026-04-07 19:55:56 -05:00
0c3f74e7de Fix onboarding wizard: remove scroll boxes, fix footer spacing, add per-field save buttons
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0b500e06-d8c5-4745-9768-29523ffc99c6

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 00:55:08 +00:00
copilot-swe-agent[bot]andGitHub d2703ff84b Initial plan 2026-04-08 00:51:40 +00:00
Sovran_SystemsandGitHub 1a9e0825fc Merge pull request #137 from naturallaw777/copilot/fix-onboarding-visual-consistency
Fix onboarding wizard: consistent card styling, footer spacing, and password description
2026-04-07 19:43:02 -05:00
284a861927 Fix onboarding wizard: consistent card styling, footer spacing, and password description
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ce004fc7-c96f-4765-bc21-87ce579352d0

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 00:39:45 +00:00
Sovran_SystemsandGitHub 02b4e6b5b4 Merge pull request #136 from naturallaw777/copilot/fix-domain-configuration-in-modal
Fix: Replace dead "Feature Manager" sidebar references with inline Configure Domain button
2026-04-07 19:39:43 -05:00
copilot-swe-agent[bot]andGitHub 60084c292e Initial plan 2026-04-08 00:38:42 +00:00
fa22a080b9 fix: replace broken Feature Manager references with Configure Domain button
- server.py: add domain_name to /api/service-detail response
- service-detail.js: replace both Feature Manager references with Configure Domain / Reconfigure Domain buttons with click handlers
- tiles.css: add .svc-detail-domain-btn class for button spacing

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ae38c98e-28bb-4d1e-8dae-78ebde64ad44

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 00:37:40 +00:00
copilot-swe-agent[bot]andGitHub 70f0af98f6 Initial plan 2026-04-08 00:33:37 +00:00
Sovran_SystemsandGitHub cd4df316ae Merge pull request #135 from naturallaw777/copilot/fix-bitcoind-i-o-error
Fix bitcoind/electrs I/O crash when second drive mounts after service start
2026-04-07 19:24:22 -05:00
ff55dce746 Add mount dependency for bitcoind and electrs systemd services
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/1def4c7b-d90d-4b0c-87a7-87dc729661b1

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-08 00:23:35 +00:00
copilot-swe-agent[bot]andGitHub 5a86c03f74 Initial plan 2026-04-08 00:22:26 +00:00
naturallaw777 1c2df46ac4 updated installer.py 2026-04-07 17:59:53 -05:00
naturallaw777 8839620e63 updated caddy.nix 2026-04-07 17:36:26 -05:00
naturallaw777 c03126e8f8 .iso update 2026-04-07 17:02:43 -05:00
Sovran_SystemsandGitHub 10ef36859d Merge pull request #132 from naturallaw777/copilot/fix-ownership-permissions
Replace tmpfiles rules with systemd oneshot service for recursive ownership fix on second drive
2026-04-07 16:41:54 -05:00
Sovran_SystemsandGitHub 4acb75f2bd Merge pull request #133 from naturallaw777/copilot/update-deployed-flake-url
Point installer DEPLOYED_FLAKE at staging-dev branch
2026-04-07 16:41:20 -05:00
77e2fb2537 Fix installer DEPLOYED_FLAKE to point to staging-dev branch
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/43e96fac-1140-42e5-9981-00069570967c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 21:40:26 +00:00
copilot-swe-agent[bot]andGitHub c7bbb97a68 Initial plan 2026-04-07 21:39:42 +00:00
6d1c360c02 Replace tmpfiles rules with systemd oneshot service for recursive chown on second drive
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/96b8f8fe-5a1d-42e5-8b2d-5dd5aee96044

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 21:29:33 +00:00
copilot-swe-agent[bot]andGitHub 3b73eb3bd1 Initial plan 2026-04-07 21:28:36 +00:00
90 changed files with 12006 additions and 3246 deletions
+3
View File
@@ -6,3 +6,6 @@ role-state.nix
__pycache__/
*.pyc
*.pyo
iso/secrets/enroll-token
iso/secrets/provisioner-url
result
+1045
View File
File diff suppressed because it is too large Load Diff
+93 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

+252 -59
View File
@@ -1,66 +1,259 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg version="1.1" viewBox="0 0 256 256" id="svg383" sodipodi:docname="vaultwarden-icon.svg" inkscape:version="1.2.1 (9c6d41e410, 2022-07-14, custom)" width="256" height="256" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs id="defs387" />
<sodipodi:namedview id="namedview385" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" showgrid="false" inkscape:zoom="3.3359375" inkscape:cx="128" inkscape:cy="128" inkscape:window-width="1874" inkscape:window-height="1056" inkscape:window-x="46" inkscape:window-y="24" inkscape:window-maximized="1" inkscape:current-layer="svg383" />
<title id="title287">Vaultwarden Icon</title>
<g id="logo" transform="matrix(2.4381018,0,0,2.4381018,128,128)">
<g id="gear" mask="url(#holes)">
<path d="m-31.1718-33.813208 26.496029 74.188883h9.3515399l26.49603-74.188883h-9.767164l-16.728866 47.588948q-1.662496 4.571864-2.805462 8.624198-1.142966 3.948427-1.870308 7.585137-.72734199-3.63671-1.8703079-7.689043-1.142966-4.052334-2.805462-8.728104l-16.624959-47.381136z" stroke="#000" stroke-width="4.51171" id="path289" />
<circle transform="scale(-1,1)" r="43" fill="none" stroke="#000" stroke-width="9" id="circle291" />
<g id="cogs" transform="scale(-1,1)">
<polygon id="cog" points="51 0 46 -3 46 3" stroke="#000" stroke-linejoin="round" stroke-width="3" />
<use transform="rotate(11.25)" xlink:href="#cog" id="use294" />
<use transform="rotate(22.5)" xlink:href="#cog" id="use296" />
<use transform="rotate(33.75)" xlink:href="#cog" id="use298" />
<use transform="rotate(45)" xlink:href="#cog" id="use300" />
<use transform="rotate(56.25)" xlink:href="#cog" id="use302" />
<use transform="rotate(67.5)" xlink:href="#cog" id="use304" />
<use transform="rotate(78.75)" xlink:href="#cog" id="use306" />
<use transform="rotate(90)" xlink:href="#cog" id="use308" />
<use transform="rotate(101.25)" xlink:href="#cog" id="use310" />
<use transform="rotate(112.5)" xlink:href="#cog" id="use312" />
<use transform="rotate(123.75)" xlink:href="#cog" id="use314" />
<use transform="rotate(135)" xlink:href="#cog" id="use316" />
<use transform="rotate(146.25)" xlink:href="#cog" id="use318" />
<use transform="rotate(157.5)" xlink:href="#cog" id="use320" />
<use transform="rotate(168.75)" xlink:href="#cog" id="use322" />
<use transform="scale(-1)" xlink:href="#cog" id="use324" />
<use transform="rotate(191.25)" xlink:href="#cog" id="use326" />
<use transform="rotate(202.5)" xlink:href="#cog" id="use328" />
<use transform="rotate(213.75)" xlink:href="#cog" id="use330" />
<use transform="rotate(225)" xlink:href="#cog" id="use332" />
<use transform="rotate(236.25)" xlink:href="#cog" id="use334" />
<use transform="rotate(247.5)" xlink:href="#cog" id="use336" />
<use transform="rotate(258.75)" xlink:href="#cog" id="use338" />
<use transform="rotate(-90)" xlink:href="#cog" id="use340" />
<use transform="rotate(-78.75)" xlink:href="#cog" id="use342" />
<use transform="rotate(-67.5)" xlink:href="#cog" id="use344" />
<use transform="rotate(-56.25)" xlink:href="#cog" id="use346" />
<use transform="rotate(-45)" xlink:href="#cog" id="use348" />
<use transform="rotate(-33.75)" xlink:href="#cog" id="use350" />
<use transform="rotate(-22.5)" xlink:href="#cog" id="use352" />
<use transform="rotate(-11.25)" xlink:href="#cog" id="use354" />
</g>
<g id="mounts" transform="scale(-1,1)">
<polygon id="mount" points="0 -35 7 -42 -7 -42" stroke="#000" stroke-linejoin="round" stroke-width="6" />
<use transform="rotate(72)" xlink:href="#mount" id="use358" />
<use transform="rotate(144)" xlink:href="#mount" id="use360" />
<use transform="rotate(216)" xlink:href="#mount" id="use362" />
<use transform="rotate(-72)" xlink:href="#mount" id="use364" />
</g>
</g>
<mask id="holes">
<rect x="-60" y="-60" width="120" height="120" fill="#fff" id="rect368" />
<circle id="hole" cy="-40" r="3" />
<use transform="rotate(72)" xlink:href="#hole" id="use371" />
<use transform="rotate(144)" xlink:href="#hole" id="use373" />
<use transform="rotate(216)" xlink:href="#hole" id="use375" />
<use transform="rotate(-72)" xlink:href="#hole" id="use377" />
<svg
version="1.1"
viewBox="0 0 256 256"
id="svg383"
sodipodi:docname="vaultwarden.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
width="256"
height="256"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs387">
<mask
id="holes">
<rect
x="-60"
y="-60"
width="120"
height="120"
fill="#fff"
id="rect368" />
<circle
id="hole"
cy="-40"
r="3" />
<use
transform="rotate(72)"
xlink:href="#hole"
id="use371" />
<use
transform="rotate(144)"
xlink:href="#hole"
id="use373" />
<use
transform="rotate(216)"
xlink:href="#hole"
id="use375" />
<use
transform="rotate(-72)"
xlink:href="#hole"
id="use377" />
</mask>
</defs>
<sodipodi:namedview
id="namedview385"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="3.3359375"
inkscape:cx="128.14988"
inkscape:cy="127.85012"
inkscape:window-width="3440"
inkscape:window-height="1363"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="gear" />
<title
id="title287">Vaultwarden Icon</title>
<g
id="gear"
mask="url(#holes)"
transform="matrix(2.4381018,0,0,2.4381018,128,128)">
<path
d="M -31.1718,-33.813208 -4.675771,40.375675 H 4.6757689 L 31.171799,-33.813208 H 21.404635 L 4.6757689,13.77574 q -1.662496,4.571864 -2.805462,8.624198 -1.142966,3.948427 -1.870308,7.585137 -0.72734199,-3.63671 -1.8703079,-7.689043 -1.142966,-4.052334 -2.805462,-8.728104 L -21.30073,-33.813208 Z"
stroke="#ffffff"
stroke-width="4.51171"
id="path289"
style="fill:#ffffff;fill-opacity:1" />
<circle
transform="scale(-1,1)"
r="43"
fill="none"
stroke="#ffffff"
stroke-width="9"
id="circle291"
cx="0"
cy="0" />
<g
id="cogs"
transform="scale(-1,1)">
<polygon
id="cog"
points="46,3 51,0 46,-3 "
stroke="#ffffff"
stroke-linejoin="round"
stroke-width="3" />
<use
transform="rotate(11.25)"
xlink:href="#cog"
id="use294" />
<use
transform="rotate(22.5)"
xlink:href="#cog"
id="use296" />
<use
transform="rotate(33.75)"
xlink:href="#cog"
id="use298" />
<use
transform="rotate(45)"
xlink:href="#cog"
id="use300" />
<use
transform="rotate(56.25)"
xlink:href="#cog"
id="use302" />
<use
transform="rotate(67.5)"
xlink:href="#cog"
id="use304" />
<use
transform="rotate(78.75)"
xlink:href="#cog"
id="use306" />
<use
transform="rotate(90)"
xlink:href="#cog"
id="use308" />
<use
transform="rotate(101.25)"
xlink:href="#cog"
id="use310" />
<use
transform="rotate(112.5)"
xlink:href="#cog"
id="use312" />
<use
transform="rotate(123.75)"
xlink:href="#cog"
id="use314" />
<use
transform="rotate(135)"
xlink:href="#cog"
id="use316" />
<use
transform="rotate(146.25)"
xlink:href="#cog"
id="use318" />
<use
transform="rotate(157.5)"
xlink:href="#cog"
id="use320" />
<use
transform="rotate(168.75)"
xlink:href="#cog"
id="use322" />
<use
transform="scale(-1)"
xlink:href="#cog"
id="use324" />
<use
transform="rotate(-168.75)"
xlink:href="#cog"
id="use326" />
<use
transform="rotate(-157.5)"
xlink:href="#cog"
id="use328" />
<use
transform="rotate(-146.25)"
xlink:href="#cog"
id="use330" />
<use
transform="rotate(-135)"
xlink:href="#cog"
id="use332" />
<use
transform="rotate(-123.75)"
xlink:href="#cog"
id="use334" />
<use
transform="rotate(-112.5)"
xlink:href="#cog"
id="use336" />
<use
transform="rotate(-101.25)"
xlink:href="#cog"
id="use338" />
<use
transform="rotate(-90)"
xlink:href="#cog"
id="use340" />
<use
transform="rotate(-78.75)"
xlink:href="#cog"
id="use342" />
<use
transform="rotate(-67.5)"
xlink:href="#cog"
id="use344" />
<use
transform="rotate(-56.25)"
xlink:href="#cog"
id="use346" />
<use
transform="rotate(-45)"
xlink:href="#cog"
id="use348" />
<use
transform="rotate(-33.75)"
xlink:href="#cog"
id="use350" />
<use
transform="rotate(-22.5)"
xlink:href="#cog"
id="use352" />
<use
transform="rotate(-11.25)"
xlink:href="#cog"
id="use354" />
</g>
<g
id="mounts"
transform="scale(-1,1)">
<polygon
id="mount"
points="-7,-42 0,-35 7,-42 "
stroke="#ffffff"
stroke-linejoin="round"
stroke-width="6" />
<use
transform="rotate(72)"
xlink:href="#mount"
id="use358" />
<use
transform="rotate(144)"
xlink:href="#mount"
id="use360" />
<use
transform="rotate(-144)"
xlink:href="#mount"
id="use362" />
<use
transform="rotate(-72)"
xlink:href="#mount"
id="use364" />
</g>
</g>
<metadata id="metadata381">
<metadata
id="metadata381">
<rdf:RDF>
<cc:Work rdf:about="">
<cc:Work
rdf:about="">
<dc:title>Vaultwarden Icon</dc:title>
<dc:creator>
<cc:Agent>

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# ── Sovran Hub External Backup Script ────────────────────────────
# Backs up Sovran_SystemsOS data to an external USB hard drive.
# Backs up Sovran_SystemsOS data to an external USB hard drive using rsync.
# Designed for the Hub web UI (no GUI dependencies).
#
# Your Sovran Pro already backs up your data automatically to its
@@ -8,6 +8,15 @@
# This script creates an additional copy on an external USB drive —
# storing your data in a third location for maximum protection.
#
# The external drive must be formatted as ext4. Files are stored as
# directly browsable files under Sovran_SystemsOS_Backup/current/.
# Later runs update the same mirror and only transfer changed or new
# files, making repeat backups fast.
#
# PostgreSQL and MariaDB/MySQL databases are NOT included. Bitcoin
# blockchain and Electrs index data are NOT included (they live on
# the internal second drive).
#
# Usage:
# BACKUP_TARGET=/run/media/<user>/<drive> bash sovran-hub-backup.sh
# (or run with no env var to auto-detect the first external USB drive)
@@ -17,18 +26,28 @@ set -euo pipefail
BACKUP_LOG="/var/log/sovran-hub-backup.log"
BACKUP_STATUS="/var/log/sovran-hub-backup.status"
MEDIA_ROOT="/run/media"
MIN_FREE_GB=10
HUB_CONFIG_JSON="/var/lib/sovran-hub/config.json"
ROLE_STATE_NIX="/etc/nixos/role-state.nix"
SECOND_DRIVE_MOUNT="/run/media/Second_Drive"
SAFETY_MARGIN_BYTES=$((1024 * 1024 * 1024))
# ── Internal drive labels/paths to NEVER use as backup targets ───
INTERNAL_LABELS=("BTCEcoandBackup" "sovran_systemsos")
INTERNAL_MOUNTS=("/run/media/Second_Drive" "/boot/efi" "/")
INTERNAL_MOUNTS=("$SECOND_DRIVE_MOUNT" "/boot/efi" "/")
FAILED_ALREADY=0
BACKUP_COMPLETE=0
RSYNC_WARNINGS=()
# Stable rsync mirror sub-path under the target drive. Not timestamped
# so later runs update the same destination and only transfer new or changed files.
BACKUP_SUBPATH="Sovran_SystemsOS_Backup/current"
# ── Logging helpers ──────────────────────────────────────────────
log() {
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
local msg
msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
echo "$msg" | tee -a "$BACKUP_LOG"
}
@@ -37,16 +56,47 @@ set_status() {
}
fail() {
FAILED_ALREADY=1
log "ERROR: $*"
set_status "FAILED"
exit 1
}
cleanup() {
local rc=$?
# Release the concurrency lock file descriptor if it was opened
if [[ -n "${LOCK_FD:-}" ]]; then
exec {LOCK_FD}>&- 2>/dev/null || true
fi
if [[ "$BACKUP_COMPLETE" -eq 1 && "$rc" -eq 0 ]]; then
return
fi
if [[ "$FAILED_ALREADY" -eq 0 ]]; then
log "ERROR: Backup terminated unexpectedly (exit code $rc)."
set_status "FAILED"
fi
# Mark the backup directory as incomplete so failed runs are identifiable
if [[ -n "${BACKUP_DIR:-}" && -d "${BACKUP_DIR:-}" && ! -f "${BACKUP_DIR:-}/BACKUP_COMPLETE" ]]; then
touch "${BACKUP_DIR}/INCOMPLETE" 2>/dev/null || true
fi
}
trap cleanup EXIT
trap 'exit 1' INT TERM
require_cmd() {
local cmd="$1"
command -v "$cmd" >/dev/null 2>&1 || fail "Required command not found: $cmd"
}
# ── Check whether a mount point is an internal drive ────────────
is_internal() {
local mnt="$1"
# Reject known internal mount points and their subdirectories
for internal in "${INTERNAL_MOUNTS[@]}"; do
if [[ "$mnt" == "$internal" || "$mnt" == "${internal}/"* ]]; then
return 0
@@ -59,39 +109,37 @@ is_internal() {
find_external_drive() {
local target=""
# lsblk JSON output: NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE
if command -v lsblk &>/dev/null; then
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
# Must be a partition or disk, and be removable/hotplug
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
[[ -n "$mountpoint" ]] || continue
# Filter out internal labels
local skip=0
for lbl in "${INTERNAL_LABELS[@]}"; do
[[ "$label" == "$lbl" ]] && skip=1 && break
done
[[ "$skip" -eq 1 ]] && continue
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
[[ -n "$mountpoint" ]] || continue
# Filter out internal mount points
is_internal "$mountpoint" && continue
local skip=0
for lbl in "${INTERNAL_LABELS[@]}"; do
[[ "$label" == "$lbl" ]] && skip=1 && break
done
[[ "$skip" -eq 1 ]] && continue
if mountpoint -q "$mountpoint" 2>/dev/null; then
target="$mountpoint"
break
fi
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
python3 -c "
is_internal "$mountpoint" && continue
if mountpoint -q "$mountpoint" 2>/dev/null; then
target="$mountpoint"
break
fi
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
python3 -c "
import sys, json
data = json.load(sys.stdin)
def flatten(devs):
for d in devs:
yield d
for c in d.get('children', []):
yield from flatten([c])
data = json.load(sys.stdin)
for d in flatten(data.get('blockdevices', [])):
print('\t'.join([
print('\\t'.join([
d.get('type') or '',
str(d.get('hotplug') or '0'),
str(d.get('rm') or '0'),
@@ -99,24 +147,10 @@ for d in flatten(data.get('blockdevices', [])):
d.get('mountpoint') or '',
]))
" 2>/dev/null || true)
fi
# Fallback: walk /run/media/ if lsblk produced nothing
if [[ -z "$target" && -d "$MEDIA_ROOT" ]]; then
while IFS= read -r -d '' mnt; do
is_internal "$mnt" && continue
# Check label via lsblk on the device backing this mount
local dev
dev=$(findmnt -n -o SOURCE "$mnt" 2>/dev/null || true)
if [[ -n "$dev" ]]; then
local lbl
lbl=$(lsblk -n -o LABEL "$dev" 2>/dev/null || true)
local skip=0
for internal_lbl in "${INTERNAL_LABELS[@]}"; do
[[ "$lbl" == "$internal_lbl" ]] && skip=1 && break
done
[[ "$skip" -eq 1 ]] && continue
fi
if mountpoint -q "$mnt" 2>/dev/null; then
target="$mnt"
break
@@ -128,16 +162,10 @@ for d in flatten(data.get('blockdevices', [])):
}
# ── Detect the configured system role ───────────────────────────
#
# Priority:
# 1. Hub config JSON (/var/lib/sovran-hub/config.json) — "role" key
# 2. role-state.nix (/etc/nixos/role-state.nix) — grep for true flag
# 3. Default: server_plus_desktop
detect_role() {
local role="server_plus_desktop"
# 1. Try the Hub config JSON
if [[ -f "$HUB_CONFIG_JSON" ]] && command -v python3 &>/dev/null; then
local r
r=$(python3 -c \
@@ -149,7 +177,6 @@ detect_role() {
fi
fi
# 2. Fall back to parsing role-state.nix
if [[ -f "$ROLE_STATE_NIX" ]]; then
if grep -q 'roles\.desktop = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
role="desktop"
@@ -161,6 +188,119 @@ detect_role() {
echo "$role"
}
validate_target_mount() {
local target="$1"
[[ "$target" == "${MEDIA_ROOT}/"* ]] || fail "Target '$target' must be mounted under $MEDIA_ROOT."
[[ -d "$target" ]] || fail "Target path '$target' does not exist."
mountpoint -q "$target" || fail "Target path '$target' is not a mount point."
local fstype=""
fstype=$(findmnt -n -o FSTYPE -T "$target" 2>/dev/null || true)
[[ -n "$fstype" ]] || fail "Could not determine filesystem type for '$target'."
if [[ "$fstype" != "ext4" ]]; then
fail "Target '$target' must be formatted as ext4 (detected filesystem: $fstype). Manual Backup requires an ext4-formatted external drive for Linux metadata preservation. exFAT, FAT32, and NTFS are not supported."
fi
local write_test
write_test="$target/.sovran-write-test-$$"
if ! ( : > "$write_test" && echo "ok" >> "$write_test" && rm -f "$write_test" ); then
fail "Target '$target' is not writable."
fi
log "Verified backup target filesystem: $fstype"
}
estimate_path_bytes() {
local path="$1"
shift || true
[[ -e "$path" ]] || {
echo 0
return
}
local size
size=$(du -s -B1 -x "$@" "$path" 2>/dev/null | awk '{print $1}' || true)
[[ -n "$size" ]] || size=0
echo "$size"
}
# ── Sync one source tree to its backup destination ───────────────
# Usage: sync_tree <label> <allow_vanished> <source> <destination> [rsync options...]
#
# allow_vanished: "yes" means rsync exit 24 (vanished files) is nonfatal.
# Used for /home only — files may disappear while the desktop is active.
# All other nonzero exit codes are always fatal.
#
# Before every rsync call this helper:
# 1. Re-verifies $TARGET is still a mount point (fails if drive disconnected).
# 2. Verifies the destination path remains beneath $BACKUP_DIR and $BACKUP_DIR
# remains beneath $TARGET (safe-path check).
# 3. Creates the full destination directory hierarchy with mkdir -p so that
# rsync never fails trying to create a directory whose parent is absent.
sync_tree() {
local label="$1"
local allow_vanished="$2"
local source="$3"
local destination="$4"
shift 4
# Remaining "$@" are rsync options (--exclude, etc.)
# ── Re-verify the external drive is still mounted ────────────────
mountpoint -q "$TARGET" 2>/dev/null || \
fail "Stage $label: external drive '$TARGET' is no longer mounted. Refusing to write."
# ── Verify path safety ────────────────────────────────────────────
# BACKUP_DIR must remain beneath TARGET.
case "$BACKUP_DIR" in
"$TARGET"/*) ;;
*) fail "Stage $label: BACKUP_DIR '$BACKUP_DIR' is outside TARGET '$TARGET'." ;;
esac
# Destination must remain beneath BACKUP_DIR.
case "$destination" in
"$BACKUP_DIR"/*|"$BACKUP_DIR") ;;
*) fail "Stage $label: destination '$destination' is outside BACKUP_DIR '$BACKUP_DIR'. Refusing to write." ;;
esac
# ── Create complete destination directory hierarchy ───────────────
# This is the fix for the production failure:
# rsync: [Receiver] mkdir ".../current/etc/nixos" failed: No such file or directory
# mkdir -p creates all intermediate parents (e.g. current/etc/) before rsync runs.
mkdir -p -- "$destination" || \
fail "Stage $label: failed to create destination directory '$destination' (source: '$source')."
local rsync_err_tmp
rsync_err_tmp="$(mktemp /tmp/sovran-rsync-err.XXXXXX)"
local rc=0
rsync \
--archive \
--acls \
--xattrs \
--hard-links \
--numeric-ids \
--one-file-system \
--partial \
"$@" "$source" "$destination" 2>"$rsync_err_tmp" || rc=$?
if [[ -s "$rsync_err_tmp" ]]; then
while IFS= read -r rline; do
log "rsync: $rline"
done < "$rsync_err_tmp"
fi
rm -f "$rsync_err_tmp"
if [[ "$rc" -eq 0 ]]; then
return 0
elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then
log "NOTE: $label — some files vanished during sync (normal on an active desktop). Your important data is backed up."
RSYNC_WARNINGS+=("$label: some files vanished during sync (rsync exit 24 — normal on active desktop)")
return 0
else
fail "rsync failed for $label (exit code $rc). See the rsync errors above."
fi
}
# ── Initialise log file ──────────────────────────────────────────
: > "$BACKUP_LOG"
@@ -169,14 +309,38 @@ set_status "RUNNING"
log "=== Sovran_SystemsOS External Hub Backup ==="
log "Starting backup process…"
# ── Acquire exclusive run lock ────────────────────────────────────
# Prevents two simultaneous backup runs (e.g. from double-click or
# stale RUNNING status after a Hub restart).
LOCK_FILE="/var/lock/sovran-hub-backup.lock"
# Note: exec {LOCK_FD}>>file requires bash 4.1+ (NixOS provides bash 5.x).
exec {LOCK_FD}>>"$LOCK_FILE" 2>/dev/null || \
fail "Cannot open lock file: $LOCK_FILE. Ensure /var/lock is writable."
flock --nonblock "$LOCK_FD" 2>/dev/null || \
fail "Another backup is already running. Wait for it to complete or check $BACKUP_STATUS."
require_cmd rsync
require_cmd findmnt
require_cmd lsblk
require_cmd mountpoint
require_cmd df
require_cmd du
require_cmd awk
require_cmd find
require_cmd hostname
require_cmd date
require_cmd python3
require_cmd flock
# ── Detect system role ───────────────────────────────────────────
ROLE="$(detect_role)"
case "$ROLE" in
desktop) ROLE_LABEL="Desktop Only" ;;
node) ROLE_LABEL="Node (Bitcoin-only)" ;;
server_plus_desktop) ROLE_LABEL="Server + Desktop" ;;
*) ROLE_LABEL="$ROLE" ;;
desktop) ROLE_LABEL="Desktop Only" ;;
node) ROLE_LABEL="Node (Bitcoin-only)" ;;
server_plus_desktop) ROLE_LABEL="Server + Desktop" ;;
*) ROLE_LABEL="$ROLE" ;;
esac
log "Detected role: $ROLE_LABEL"
@@ -184,7 +348,6 @@ log "Detected role: $ROLE_LABEL"
if [[ -n "${BACKUP_TARGET:-}" ]]; then
TARGET="$BACKUP_TARGET"
# Safety: never allow internal drives even if explicitly passed
if is_internal "$TARGET"; then
fail "Target '$TARGET' is an internal system drive and cannot be used for external backup."
fi
@@ -193,39 +356,74 @@ else
log "Auto-detecting external USB drives…"
TARGET="$(find_external_drive)"
if [[ -z "$TARGET" ]]; then
fail "No external USB drive detected. " \
"Please plug in an exFAT-formatted USB drive (≥500 GB) and try again."
fail "No external USB drive detected. Please plug in an ext4-formatted USB drive and try again."
fi
log "Detected external drive: $TARGET"
fi
# ── Verify mount point ───────────────────────────────────────────
validate_target_mount "$TARGET"
[[ -d "$TARGET" ]] || fail "Target path '$TARGET' does not exist."
mountpoint -q "$TARGET" || fail "Target path '$TARGET' is not a mount point."
# ── Set up stable backup destination ────────────────────────────
# Subsequent runs update the same mirror, transferring only new or changed files.
# ── Check free disk space (require ≥ 10 GB) ──────────────────────
BACKUP_DIR="${TARGET}/${BACKUP_SUBPATH}"
mkdir -p -- "$BACKUP_DIR"
FREE_KB=$(df -k --output=avail "$TARGET" | tail -1)
FREE_GB=$(( FREE_KB / 1024 / 1024 ))
log "Free space on drive: ${FREE_GB} GB"
(( FREE_GB >= MIN_FREE_GB )) || \
fail "Not enough free space on drive (${FREE_GB} GB available, ${MIN_FREE_GB} GB required)."
# Remove any stale BACKUP_COMPLETE left by a previous successful run.
# The new run will re-earn it only after all stages succeed.
rm -f "$BACKUP_DIR/BACKUP_COMPLETE"
# ── Create timestamped backup directory ─────────────────────────
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
BACKUP_DIR="${TARGET}/Sovran_SystemsOS_Backup/${TIMESTAMP}"
mkdir -p "$BACKUP_DIR"
# Write an INCOMPLETE marker immediately; replaced by BACKUP_COMPLETE only
# after all rsync stages and manifest write succeed. Failed or interrupted
# runs keep this marker so they are clearly identifiable.
touch "$BACKUP_DIR/INCOMPLETE"
log "Backup destination: $BACKUP_DIR"
# ── Estimate required free space ─────────────────────────────────
# PostgreSQL/MariaDB raw directories and Bitcoin/Electrs data are excluded
# from the estimate to avoid inflating the required size.
ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos)
HOME_BYTES=$(estimate_path_bytes /home --exclude='*/.cache' --exclude='*/.local/share/Trash' --exclude='*/Trash')
SECRETS_BYTES=0
if [[ "$ROLE" != "desktop" ]]; then
SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets)
fi
VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \
--exclude='postgresql' \
--exclude='mysql' \
--exclude='mariadb' \
--exclude='bitcoind' \
--exclude='electrs' \
--exclude='*/log' \
--exclude='*/logs' \
--exclude='*/cache' \
--exclude='*/tmp')
ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES ))
# Require 20% growth headroom plus a fixed 1 GiB safety margin.
# Later incremental runs need far less space, but a conservative first-run
# check protects against running out of space mid-backup.
REQUIRED_BYTES=$(( ESTIMATED_BYTES + (ESTIMATED_BYTES / 5) + SAFETY_MARGIN_BYTES ))
FREE_BYTES=$(df -B1 --output=avail "$TARGET" | tail -1 | tr -d ' ')
FREE_GB=$(( FREE_BYTES / 1024 / 1024 / 1024 ))
REQUIRED_GB=$(( REQUIRED_BYTES / 1024 / 1024 / 1024 ))
log "Estimated backup size: $(( ESTIMATED_BYTES / 1024 / 1024 / 1024 )) GB"
log "Required free space (with safety margin): ${REQUIRED_GB} GB"
log "Free space on drive: ${FREE_GB} GB"
(( FREE_BYTES >= REQUIRED_BYTES )) || \
fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)."
# ── Stage 1/4: NixOS configuration ──────────────────────────────
log ""
log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────"
if [[ -d /etc/nixos ]]; then
rsync -a --info=progress2 /etc/nixos/ "$BACKUP_DIR/nixos/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 1 failed while copying /etc/nixos"
sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/etc/nixos/"
log "Stage 1 complete."
else
log "WARNING: /etc/nixos not found — skipping."
@@ -234,93 +432,158 @@ fi
# ── Stage 2/4: Secrets ──────────────────────────────────────────
log ""
log "── Stage 2/4: Secrets ───────────────────────────────────────"
mkdir -p "$BACKUP_DIR/secrets"
log "── Stage 2/4: Secrets (/etc/nix-bitcoin-secrets) ───────────"
if [[ "$ROLE" == "desktop" ]]; then
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
# /var/lib/domains is still backed up if present (hub state)
for SRC in /var/lib/domains; do
if [[ -e "$SRC" ]]; then
rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
log "WARNING: Could not copy $SRC — continuing."
else
log " (not found: $SRC — skipping)"
fi
done
elif [[ -e /etc/nix-bitcoin-secrets ]]; then
sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/"
else
for SRC in /etc/nix-bitcoin-secrets /var/lib/domains; do
if [[ -e "$SRC" ]]; then
rsync -a --info=progress2 "$SRC" "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
log "WARNING: Could not copy $SRC — continuing."
else
log " (not found: $SRC — skipping)"
fi
done
log "(not found: /etc/nix-bitcoin-secrets — skipping)"
fi
# Hub state files from /var/lib/secrets/ (backed up for all roles)
if [[ -d /var/lib/secrets ]]; then
mkdir -p "$BACKUP_DIR/secrets/hub-state"
rsync -a --info=progress2 /var/lib/secrets/ "$BACKUP_DIR/secrets/hub-state/" 2>&1 | tee -a "$BACKUP_LOG" || \
log "WARNING: Could not copy /var/lib/secrets — continuing."
else
log " (not found: /var/lib/secrets — skipping)"
fi
log "Stage 2 complete."
# ── Stage 3/4: Home directory ───────────────────────────────────
# ── Stage 3/4: Home directory ───────────────────────────────────
# Rsync exit code 24 (vanished source files) is treated as nonfatal here
# because the desktop may be active and files can disappear between the
# directory scan and the copy. All other nonzero exit codes remain fatal.
log ""
log "── Stage 3/4: Home directory (/home) ───────────────────────"
if [[ -d /home ]]; then
rsync -a --info=progress2 \
sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \
--exclude='.cache/' \
--exclude='.local/share/Trash/' \
--exclude='*/Trash/' \
/home/ "$BACKUP_DIR/home/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 3 failed while copying /home"
--exclude='Trash/' \
--exclude='.mozilla/firefox/*/cache2/' \
--exclude='.mozilla/firefox/*/startupCache/' \
--exclude='.mozilla/firefox/*/thumbnails/' \
--exclude='.config/google-chrome/*/Cache/' \
--exclude='.config/google-chrome/*/Code Cache/' \
--exclude='.config/chromium/*/Cache/' \
--exclude='.config/chromium/*/Code Cache/' \
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
--exclude='.local/share/baloo/' \
--exclude='.thumbnails/' \
--exclude='.xsession-errors' \
--exclude='.xsession-errors.old'
log "Stage 3 complete."
else
log "WARNING: /home not found — skipping."
fi
# ── Stage 4/4: Wallet and node data ─────────────────────────────
# ── Stage 4/4: System data ──────────────────────────────────────
# PostgreSQL/MariaDB raw database directories are excluded. Application
# databases must be backed up separately with native database tools.
# Bitcoin/Electrs data are excluded; they live on the internal second drive.
log ""
log "── Stage 4/4: Wallet and node data (/var/lib/lnd) ──────────"
if [[ "$ROLE" == "desktop" ]]; then
log "Skipping Stage 4 (LND wallet data) — not applicable for Desktop Only role."
elif [[ -d /var/lib/lnd ]]; then
rsync -a --info=progress2 \
--exclude='logs/' \
/var/lib/lnd/ "$BACKUP_DIR/lnd/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 4 failed while copying /var/lib/lnd"
log "── Stage 4/4: System data (/var/lib) ───────────────────────"
if [[ -d /var/lib ]]; then
sync_tree "/var/lib" no /var/lib/ "$BACKUP_DIR/var/lib/" \
--exclude='postgresql/' \
--exclude='mysql/' \
--exclude='mariadb/' \
--exclude='bitcoind/' \
--exclude='electrs/' \
--exclude='*/log/' \
--exclude='*/logs/' \
--exclude='*/cache/' \
--exclude='*/tmp/'
log "Stage 4 complete."
else
log "WARNING: /var/lib/lnd not found — skipping."
log "WARNING: /var/lib not found — skipping."
fi
# ── Generate manifest ────────────────────────────────────────────
log ""
log "Generating BACKUP_MANIFEST.txt …"
MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
{
echo "Sovran_SystemsOS Backup Manifest"
echo "Generated: $(date)"
echo "Updated: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
echo "Hostname: $(hostname)"
echo "Role: $ROLE_LABEL"
echo "Target: $TARGET"
echo ""
echo "Contents:"
find "$BACKUP_DIR" -mindepth 1 -maxdepth 2 | sort
} > "$BACKUP_DIR/BACKUP_MANIFEST.txt"
log "Manifest written to $BACKUP_DIR/BACKUP_MANIFEST.txt"
echo "Backup type: Live rsync mirror (directly browsable files)"
echo "Location: ${BACKUP_DIR}"
echo ""
echo "Source paths mirrored:"
echo "- /etc/nixos → current/etc/nixos/"
if [[ "$ROLE" != "desktop" ]]; then
echo "- /etc/nix-bitcoin-secrets (when present) → current/etc/nix-bitcoin-secrets/"
fi
echo "- /home → current/home/"
echo "- /var/lib → current/var/lib/"
echo ""
echo "Exclusions:"
echo "- /var/lib/postgresql (PostgreSQL raw database files — not included)"
echo "- /var/lib/mysql, /var/lib/mariadb (MariaDB raw database files — not included)"
echo "- /var/lib/bitcoind (Bitcoin blockchain — excluded; lives on internal second drive)"
echo "- /var/lib/electrs (Electrs index — excluded; lives on internal second drive)"
echo "- /run/media/Second_Drive (internal second drive — never traversed)"
echo "- /var/lib/*/log, /var/lib/*/logs, /var/lib/*/cache, /var/lib/*/tmp"
echo "- Browser disk caches, thumbnail caches, trash directories, X session error logs"
echo ""
echo "Important limitations:"
echo "- PostgreSQL and MariaDB/MySQL application databases are NOT included in this"
echo " backup. If you use Nextcloud, Matrix/Synapse, or other database-backed"
echo " applications, their data must be backed up separately using native tools."
echo "- Bitcoin blockchain data and Electrs indexes are NOT included; they are"
echo " reconstructable or stored on the internal second drive."
echo "- This is a live file-level mirror, not a transactional database backup."
echo " Files being written during the backup may be in an inconsistent state."
echo ""
echo "Restore guidance:"
echo "- Files are directly browsable on the backup drive under: ${BACKUP_DIR}"
echo "- To restore a directory:"
echo " sudo rsync -aAXH --numeric-ids current/etc/nixos/ /etc/nixos/"
echo " sudo rsync -aAXH --numeric-ids current/home/ /home/"
echo " sudo rsync -aAXH --numeric-ids current/var/lib/ /var/lib/"
echo "- To copy individual files:"
echo " sudo cp -a current/home/username/ /home/username/"
echo "- When restoring /etc/nixos to replacement hardware, regenerate"
echo " hardware-configuration.nix for the new hardware before rebuilding."
echo ""
echo "Nonfatal warnings:"
if [[ "${#RSYNC_WARNINGS[@]}" -eq 0 ]]; then
echo "- none"
else
for warning in "${RSYNC_WARNINGS[@]}"; do
echo "- $warning"
done
fi
echo ""
echo "Note: Bitcoin blockchain and Electrs index data are intentionally excluded"
echo "from manual external backup because they already live on the internal second drive"
echo "(/run/media/Second_Drive) and are reconstructable/internal-backup data."
} > "$MANIFEST_FILE"
log "Manifest written to $MANIFEST_FILE"
# ── Done ─────────────────────────────────────────────────────────
log ""
if [[ "${#RSYNC_WARNINGS[@]}" -gt 0 ]]; then
log "Backup completed with nonfatal warnings:"
for warning in "${RSYNC_WARNINGS[@]}"; do
log " WARNING: $warning"
done
log "Your important data is backed up. The warnings above indicate files that"
log "vanished during backup, which is normal on an active desktop."
log ""
fi
log "All Finished! Your data is now backed up to a third location."
log "Files are directly browsable on the drive under: ${BACKUP_DIR}"
log "Please eject the drive safely before removing it from your Sovran Pro."
# Remove incomplete marker and write completion marker only after all work succeeds.
# A later successful run will update the same mirror and replace any INCOMPLETE state.
rm -f "$BACKUP_DIR/INCOMPLETE"
date -u '+%Y-%m-%dT%H:%M:%SZ' > "$BACKUP_DIR/BACKUP_COMPLETE"
BACKUP_COMPLETE=1
set_status "SUCCESS"
File diff suppressed because it is too large Load Diff
+23 -19
View File
@@ -1,6 +1,6 @@
/* Sovran_SystemsOS Hub — Web UI Stylesheet
Dark theme matching the Adwaita dark aesthetic
v6Status-only tiles (no controls) */
Dark theme — near-black with green accents matching the Sovran Hub icon
v8Black-forward, green used for accents/borders/highlights only */
*, *::before, *::after {
box-sizing: border-box;
@@ -9,22 +9,22 @@
}
:root {
--bg-color: #1e1e2e;
--surface-color: #2a2a3c;
--card-color: #313244;
--border-color: #45475a;
--text-primary: #cdd6f4;
--text-secondary: #a6adc8;
--text-dim: #6c7086;
--accent-color: #89b4fa;
--green: #2ec27e;
--bg-color: #080a09;
--surface-color: rgba(14, 16, 15, 0.7);
--card-color: rgba(20, 22, 21, 0.6);
--border-color: rgba(255, 255, 255, 0.06);
--text-primary: #ecf3ef;
--text-secondary: #8aaa9a;
--text-dim: #4a6658;
--accent-color: #5EAD8A;
--green: #6DBF8B;
--yellow: #e5a50a;
--red: #e01b24;
--grey: #888888;
--grey: #5E7A6A;
--radius-card: 18px;
--radius-btn: 8px;
--shadow-card: 0 2px 8px rgba(0,0,0,0.4);
--shadow-hover: 0 6px 20px rgba(0,0,0,0.6);
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.5);
}
html, body {
@@ -32,8 +32,10 @@ html, body {
}
body {
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
background-color: var(--bg-color);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background:
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
var(--bg-color);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
@@ -53,8 +55,10 @@ body {
}
.login-card {
background-color: var(--surface-color);
border: 1px solid var(--border-color);
background-color: rgba(14, 16, 15, 0.75);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 48px 40px;
width: 100%;
@@ -112,7 +116,7 @@ body {
padding: 12px;
border-radius: var(--radius-btn);
background-color: var(--accent-color);
color: #1e1e2e;
color: #0A1A10;
font-size: 0.95rem;
font-weight: 700;
margin-top: 8px;
@@ -22,17 +22,17 @@ button:disabled {
.btn-primary {
background-color: var(--accent-color);
color: #1e1e2e;
color: #0A1A10;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.88;
}
/* Update System button: BLUE by default */
/* Update System button: uses accent green by default */
.btn-update {
background-color: #89b4fa;
color: #1e1e2e;
background-color: #4A9474;
color: #E0F2EA;
position: relative;
display: flex;
align-items: center;
@@ -43,14 +43,14 @@ button:disabled {
opacity: 0.88;
}
/* Update System button: GREEN when updates are available */
/* Update System button: brighter green when updates are available */
.btn-update.has-updates {
background-color: #2ec27e;
color: #fff;
background-color: #5EAD8A;
color: #0A1A10;
}
.btn-update.has-updates:hover:not(:disabled) {
background-color: #27ae6e;
background-color: #78C8A2;
}
.update-badge {
@@ -18,7 +18,7 @@ domain-field-label {
domain-field-input {
width: 100%;
background-color: #12121c;
background-color: #0c0f0e;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
@@ -75,7 +75,7 @@ domain-field-actions {
.domain-field-input {
width: 100%;
background-color: #12121c;
background-color: #0c0f0e;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
@@ -145,7 +145,7 @@ domain-field-actions {
.port-req-table td {
padding: 8px 10px;
border-bottom: 1px solid rgba(69, 71, 90, 0.4);
border-bottom: 1px solid rgba(30, 45, 39, 0.5);
color: var(--text-primary);
}
+53 -6
View File
@@ -1,8 +1,10 @@
/* ── Header bar ─────────────────────────────────────────────────── */
.header-bar {
background-color: var(--surface-color);
border-bottom: 1px solid var(--border-color);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
background-color: rgba(10, 12, 11, 0.82);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
padding: 8px 24px;
display: flex;
flex-direction: row;
@@ -35,7 +37,7 @@
.role-badge {
background-color: var(--accent-color);
color: #1e1e2e;
color: #0A1A10;
font-size: 0.72rem;
font-weight: 700;
padding: 3px 10px;
@@ -46,8 +48,10 @@
/* ── IP bar ─────────────────────────────────────────────────────── */
.ip-bar {
background-color: var(--surface-color);
border-bottom: 1px solid var(--border-color);
background-color: rgba(10, 12, 11, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding: 8px 24px;
display: flex;
align-items: center;
@@ -70,4 +74,47 @@
.ip-separator {
color: var(--border-color);
}
}
.btn-logout {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.18);
color: var(--text-secondary);
font-size: 0.78rem;
font-weight: 600;
padding: 4px 12px;
border-radius: var(--radius-btn);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.btn-logout:hover {
border-color: var(--accent-color);
color: var(--accent-color);
}
/* ── Header reboot button ───────────────────────────────────────── */
.btn-header-reboot {
background: transparent;
border: 1px solid rgba(184, 125, 0, 0.35);
color: #c98d08;
font-size: 0.78rem;
font-weight: 600;
padding: 4px 12px;
border-radius: var(--radius-btn);
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background-color 0.15s;
}
.btn-header-reboot:hover {
border-color: #b87d00;
color: #e0a010;
background-color: rgba(184, 125, 0, 0.1);
}
@media (max-width: 480px) {
.btn-header-reboot {
padding: 4px 8px;
font-size: 0.72rem;
}
}
+5 -20
View File
@@ -18,8 +18,10 @@
flex-shrink: 0;
height: 100%;
overflow-y: auto;
border-right: 1px solid var(--border-color);
background-color: var(--surface-color);
border-right: 1px solid rgba(255, 255, 255, 0.06);
background-color: rgba(12, 14, 13, 0.65);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 20px 14px;
display: flex;
flex-direction: column;
@@ -46,7 +48,7 @@
.sidebar-support-btn:hover {
border-color: var(--accent-color);
border-style: solid;
background-color: #35354a;
background-color: #162320;
}
.sidebar-support-btn + .sidebar-support-btn {
@@ -82,23 +84,6 @@
margin: 16px 0;
}
/* ── Sidebar: Upgrade button (Node role) ────────────────────────── */
.sidebar-upgrade-btn {
border-color: var(--accent-color);
background-color: rgba(137, 180, 250, 0.06);
margin-top: 8px;
}
.sidebar-upgrade-btn:hover {
background-color: rgba(137, 180, 250, 0.14);
border-color: var(--accent-color);
}
.sidebar-upgrade-btn .sidebar-support-hint {
color: var(--accent-color);
}
/* ── Upgrade modal ──────────────────────────────────────────────── */
.upgrade-dialog {
+69 -21
View File
@@ -4,7 +4,9 @@
display: none;
position: fixed;
inset: 0;
background-color: rgba(0,0,0,0.65);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 200;
align-items: center;
justify-content: center;
@@ -15,15 +17,17 @@
}
.modal-dialog {
background-color: var(--surface-color);
border: 1px solid var(--border-color);
background-color: rgba(14, 16, 15, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
width: 90vw;
max-width: 900px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0,0,0,0.7);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
}
.modal-header {
@@ -71,7 +75,7 @@
font-size: 0.78rem;
line-height: 1.6;
color: var(--text-primary);
background-color: #12121c;
background-color: #0c0f0e;
white-space: pre-wrap;
word-break: break-all;
min-height: 200px;
@@ -89,18 +93,60 @@
/* Reboot = GREEN */
.modal-footer .btn-reboot,
button.btn-reboot {
background-color: #2ec27e;
background-color: #6DBF8B;
color: #fff;
}
.modal-footer .btn-reboot:hover:not(:disabled),
button.btn-reboot:hover:not(:disabled) {
background-color: #27ae6e;
background-color: #529E7E;
}
/* Restart = AMBER (manual restart action) */
.btn-restart-amber {
background-color: #b87d00;
color: #fff;
}
.btn-restart-amber:hover:not(:disabled) {
background-color: #9a6800;
}
/* Restart conflict warning box */
.restart-conflict-box {
background-color: rgba(180, 100, 0, 0.12);
border-left: 3px solid #c97a00;
border-radius: 6px;
padding: 12px 14px;
margin-bottom: 14px;
}
.restart-conflict-title {
font-size: 0.88rem;
font-weight: 700;
color: #e69000;
margin: 0 0 6px 0;
}
.restart-conflict-desc {
font-size: 0.83rem;
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
/* Reboot error card actions row */
.reboot-error-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-top: 20px;
}
.btn-save {
background-color: var(--yellow);
color: #1e1e2e;
color: #0A1A10;
}
.btn-save:hover:not(:disabled) {
@@ -113,21 +159,23 @@ button.btn-reboot:hover:not(:disabled) {
}
.btn-close-modal:hover:not(:disabled) {
background-color: #5a5c72;
background-color: #1c2a24;
}
/* ── Credentials info modal ──────────────────────────────────────── */
.creds-dialog {
background-color: var(--surface-color);
border: 1px solid var(--border-color);
background-color: rgba(14, 16, 15, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
width: 90vw;
max-width: 700px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0,0,0,0.7);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
animation: creds-fade-in 0.2s ease-out;
}
@@ -214,7 +262,7 @@ button.btn-reboot:hover:not(:disabled) {
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
font-size: 0.92rem;
color: var(--text-primary);
background-color: #12121c;
background-color: #0c0f0e;
padding: 12px 16px;
border-radius: 8px;
word-break: break-all;
@@ -239,7 +287,7 @@ button.btn-reboot:hover:not(:disabled) {
}
.creds-copy-btn:hover {
background-color: #5a5c72;
background-color: #1c2a24;
}
.creds-copy-btn.copied {
@@ -283,7 +331,7 @@ button.btn-reboot:hover:not(:disabled) {
.matrix-action-btn {
background-color: var(--accent-color);
color: #0f0f19;
color: #0A1A10;
font-size: 0.88rem;
font-weight: 700;
padding: 10px 18px;
@@ -295,7 +343,7 @@ button.btn-reboot:hover:not(:disabled) {
}
.matrix-action-btn:hover {
background-color: #a8c8ff;
background-color: #7CC4A0;
}
.matrix-form-group {
@@ -312,7 +360,7 @@ button.btn-reboot:hover:not(:disabled) {
.matrix-form-input {
width: 100%;
background-color: #12121c;
background-color: #0c0f0e;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
@@ -347,7 +395,7 @@ button.btn-reboot:hover:not(:disabled) {
.matrix-form-submit {
background-color: var(--accent-color);
color: #0f0f19;
color: #0A1A10;
font-size: 0.88rem;
font-weight: 700;
padding: 10px 20px;
@@ -358,7 +406,7 @@ button.btn-reboot:hover:not(:disabled) {
}
.matrix-form-submit:hover:not(:disabled) {
background-color: #a8c8ff;
background-color: #7CC4A0;
}
.matrix-form-submit:disabled {
@@ -378,7 +426,7 @@ button.btn-reboot:hover:not(:disabled) {
}
.matrix-form-back:hover {
background-color: #5a5c72;
background-color: #1c2a24;
}
.matrix-form-result {
@@ -391,7 +439,7 @@ button.btn-reboot:hover:not(:disabled) {
}
.matrix-form-result.success {
background-color: rgba(74, 222, 128, 0.12);
background-color: rgba(109, 191, 139, 0.12);
border: 1px solid var(--green);
color: var(--green);
display: block;
@@ -6,7 +6,9 @@
align-items: center;
justify-content: flex-start;
min-height: 100vh;
background-color: var(--bg-color);
background:
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
var(--bg-color);
padding: 24px 16px 48px;
overflow-y: auto;
}
@@ -68,13 +70,13 @@
.onboarding-step-dot.active {
background-color: var(--accent-color);
border-color: var(--accent-color);
color: #1e1e2e;
color: #0A1A10;
}
.onboarding-step-dot.completed {
background-color: var(--green);
border-color: var(--green);
color: #1e1e2e;
color: #0A1A10;
}
.onboarding-step-connector {
@@ -137,8 +139,10 @@
/* Cards */
.onboarding-card {
background-color: var(--surface-color);
border: 1px solid var(--border-color);
background-color: rgba(14, 16, 15, 0.65);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--radius-card);
padding: 24px 28px;
display: flex;
@@ -146,17 +150,6 @@
gap: 16px;
}
.onboarding-card--scroll {
max-height: 360px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.onboarding-card--ports {
overflow: visible;
}
/* Body text */
.onboarding-body-text {
@@ -191,10 +184,10 @@
font-size: 0.82rem;
font-weight: 700;
color: var(--accent-color);
background-color: rgba(137, 180, 250, 0.12);
background-color: rgba(94, 173, 138, 0.10);
padding: 3px 10px;
border-radius: 20px;
border: 1px solid rgba(137, 180, 250, 0.3);
border: 1px solid rgba(94, 173, 138, 0.25);
}
/* Step header */
@@ -228,7 +221,9 @@
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 4px;
padding-top: 24px;
padding-bottom: 24px;
margin-top: auto;
}
.onboarding-btn-next {
@@ -278,7 +273,7 @@
background-color: var(--card-color);
color: var(--text-primary);
font-size: 0.88rem;
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: border-color 0.15s;
}
@@ -356,7 +351,7 @@
.onboarding-port-table td {
padding: 8px 8px;
border-bottom: 1px solid rgba(69, 71, 90, 0.4);
border-bottom: 1px solid rgba(30, 45, 39, 0.5);
color: var(--text-secondary);
vertical-align: middle;
}
@@ -414,8 +409,8 @@
.onboarding-creds-notice {
padding: 12px 16px;
background-color: rgba(137, 180, 250, 0.08);
border: 1px solid rgba(137, 180, 250, 0.25);
background-color: rgba(94, 173, 138, 0.08);
border: 1px solid rgba(94, 173, 138, 0.20);
border-radius: 8px;
font-size: 0.85rem;
color: var(--text-secondary);
@@ -508,8 +503,8 @@
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 4px;
background-color: rgba(137, 180, 250, 0.1);
border: 1px solid rgba(137, 180, 250, 0.25);
background-color: rgba(94, 173, 138, 0.08);
border: 1px solid rgba(94, 173, 138, 0.20);
color: var(--accent-color);
cursor: pointer;
white-space: nowrap;
@@ -517,7 +512,7 @@
}
.onboarding-cred-reveal-btn:hover {
background-color: rgba(137, 180, 250, 0.2);
background-color: rgba(94, 173, 138, 0.15);
}
/* Completion checklist (Step 5) */
@@ -575,7 +570,62 @@
color: var(--text-secondary);
}
/* ── Password step (Step 2) ─────────────────────────────────────── */
/* ── Timezone / Locale step (Step 2) ────────────────────────────── */
.onboarding-tz-group {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 0;
}
.onboarding-tz-search {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
background-color: var(--card-color);
color: var(--text-primary);
font-size: 0.88rem;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: border-color 0.15s;
}
.onboarding-tz-search:focus {
outline: none;
border-color: var(--accent-color);
}
.onboarding-tz-select {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
background-color: var(--card-color);
color: var(--text-primary);
font-size: 0.88rem;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: border-color 0.15s;
overflow-y: auto;
}
.onboarding-tz-select:focus {
outline: none;
border-color: var(--accent-color);
}
.onboarding-tz-select option {
padding: 4px 8px;
background-color: var(--card-color);
color: var(--text-primary);
}
.onboarding-locale-select {
height: auto;
size: auto;
}
/* ── Password step (Step 3) ─────────────────────────────────────── */
.onboarding-password-group {
display: flex;
@@ -598,7 +648,7 @@
background-color: var(--card-color);
color: var(--text-primary);
font-size: 0.88rem;
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: border-color 0.15s;
}
@@ -621,7 +671,7 @@
}
.onboarding-password-toggle:hover {
background-color: rgba(137, 180, 250, 0.12);
background-color: rgba(94, 173, 138, 0.10);
border-color: var(--accent-color);
}
@@ -644,8 +694,8 @@
.onboarding-password-success {
padding: 12px 16px;
background-color: rgba(166, 227, 161, 0.1);
border: 1px solid rgba(166, 227, 161, 0.35);
background-color: rgba(94, 173, 138, 0.10);
border: 1px solid rgba(94, 173, 138, 0.30);
border-radius: 8px;
font-size: 0.92rem;
color: var(--green);
@@ -685,7 +735,9 @@
display: none;
position: fixed;
inset: 0;
background-color: rgba(15, 15, 25, 0.92);
background-color: rgba(6, 8, 7, 0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 999;
align-items: center;
justify-content: center;
@@ -696,8 +748,10 @@
}
.reboot-card {
background-color: var(--surface-color);
border: 1px solid var(--border-color);
background-color: rgba(14, 16, 15, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 48px 56px;
text-align: center;
@@ -1,3 +1,353 @@
/* ── Security Modal sections ──────────────────────────────────────── */
.security-section {
padding: 0 0 8px 0;
}
.security-section-title {
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 10px 0;
}
.security-section-desc {
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 10px;
}
.security-divider {
border: none;
border-top: 1px solid var(--border-color);
margin: 18px 0;
}
/* ── Security Reset warning box ──────────────────────────────────── */
.security-warning-box {
background-color: rgba(180, 40, 40, 0.10);
border-left: 3px solid #c94040;
border-radius: 6px;
padding: 12px 14px;
margin-bottom: 14px;
}
.security-warning-text {
font-size: 0.85rem;
color: var(--text-primary);
margin: 0 0 8px 0;
line-height: 1.5;
}
.security-warning-list {
margin: 6px 0 6px 20px;
padding: 0;
font-size: 0.82rem;
color: var(--text-secondary);
line-height: 1.7;
}
.security-erase-group {
margin-bottom: 14px;
}
.security-erase-label {
display: block;
font-size: 0.85rem;
color: var(--text-primary);
margin-bottom: 6px;
}
.security-erase-input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--input-bg, var(--card-color));
color: var(--text-primary);
font-size: 0.9rem;
box-sizing: border-box;
}
.security-reset-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn-danger {
background-color: #c94040;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 18px;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s;
}
.btn-danger:hover:not(:disabled) {
background-color: #a83030;
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.security-status-msg {
font-size: 0.83rem;
margin-top: 10px;
min-height: 1.2em;
}
.security-status-info { color: var(--text-secondary); }
.security-status-ok { color: #6DBF8B; }
.security-status-error { color: #e05252; }
/* ── Verify System Integrity ─────────────────────────────────────── */
.security-verify-list {
margin: 8px 0 10px 20px;
padding: 0;
font-size: 0.84rem;
color: var(--text-secondary);
line-height: 1.7;
}
.security-verify-loading {
font-size: 0.85rem;
color: var(--text-secondary);
}
.security-verify-result-card {
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.security-verify-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.security-verify-label {
font-size: 0.82rem;
color: var(--text-secondary);
min-width: 130px;
font-weight: 600;
}
.security-verify-value {
font-size: 0.82rem;
color: var(--text-primary);
}
.security-verify-mono {
font-family: monospace;
font-size: 0.78rem;
word-break: break-all;
}
.security-verify-link {
font-size: 0.78rem;
color: var(--accent-color, #6DBF8B);
text-decoration: none;
}
.security-verify-link:hover { text-decoration: underline; }
.security-verify-badge {
font-size: 0.82rem;
font-weight: 700;
padding: 2px 10px;
border-radius: 12px;
}
.security-verify-pass {
background-color: rgba(109, 191, 139, 0.15);
color: #6DBF8B;
}
.security-verify-fail {
background-color: rgba(224, 82, 82, 0.15);
color: #e05252;
}
.security-verify-errors {
font-size: 0.8rem;
color: var(--text-secondary);
}
.security-verify-pre {
white-space: pre-wrap;
word-break: break-all;
font-size: 0.75rem;
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 8px;
margin-top: 6px;
max-height: 150px;
overflow-y: auto;
}
.security-verify-path-row {
display: flex;
gap: 8px;
font-size: 0.78rem;
align-items: flex-start;
flex-wrap: wrap;
}
.security-verify-path-label {
font-weight: 600;
color: var(--text-secondary);
min-width: 70px;
}
/* ── Security Reset full-screen overlay ──────────────────────────── */
.security-reset-overlay {
display: none;
position: fixed;
inset: 0;
background-color: rgba(6, 8, 7, 0.94);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 1000;
align-items: center;
justify-content: center;
animation: security-reset-fade-in 0.35s ease-out;
}
.security-reset-overlay.visible {
display: flex;
}
@keyframes security-reset-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.security-reset-overlay-icon {
font-size: 3rem;
margin-bottom: 16px;
}
/* ── Phase 2: password display box ──────────────────────────────── */
.security-reset-password-label {
font-size: 0.88rem;
color: var(--text-secondary);
margin: 16px 0 8px 0;
}
.security-reset-password-box {
font-family: monospace;
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary);
background: rgba(109, 191, 139, 0.10);
border: 1.5px solid rgba(109, 191, 139, 0.35);
border-radius: 8px;
padding: 14px 24px;
letter-spacing: 0.04em;
text-align: center;
word-break: break-all;
margin-bottom: 16px;
min-width: 260px;
}
.security-reset-password-warning {
font-size: 0.84rem;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 20px;
text-align: center;
}
.security-reset-reboot-btn {
background-color: #6DBF8B;
color: #0a0c0b;
border: none;
border-radius: 7px;
padding: 11px 22px;
font-size: 0.88rem;
font-weight: 700;
cursor: pointer;
transition: background-color 0.15s, opacity 0.15s;
white-space: nowrap;
}
.security-reset-reboot-btn:hover:not(:disabled) {
background-color: #5aab78;
}
.security-reset-reboot-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── First-login security banner ─────────────────────────────────── */
.security-first-login-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 18px;
background-color: rgba(94, 173, 138, 0.08);
border-bottom: 2px solid rgba(94, 173, 138, 0.25);
color: var(--text-primary);
}
.security-banner-content {
display: flex;
align-items: flex-start;
gap: 10px;
flex: 1;
min-width: 0;
}
.security-banner-icon {
font-size: 1.2rem;
flex-shrink: 0;
margin-top: 1px;
}
.security-banner-text {
font-size: 0.85rem;
line-height: 1.5;
color: var(--text-primary);
}
.security-banner-dismiss {
background: none;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-secondary);
padding: 2px 7px;
flex-shrink: 0;
line-height: 1.4;
transition: background-color 0.15s;
}
.security-banner-dismiss:hover {
background-color: rgba(0,0,0,0.08);
}
/* ── Legacy security inline warning banner ───────────────────────── */
.security-inline-banner {
+10 -10
View File
@@ -104,7 +104,7 @@
}
.support-steps code {
background-color: rgba(137, 180, 250, 0.12);
background-color: rgba(94, 173, 138, 0.10);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.82rem;
@@ -116,7 +116,7 @@
padding: 12px;
border-radius: var(--radius-btn);
background-color: var(--accent-color);
color: #1e1e2e;
color: #0A1A10;
font-size: 0.95rem;
font-weight: 700;
margin-bottom: 10px;
@@ -146,7 +146,7 @@
padding: 12px;
border-radius: var(--radius-btn);
background-color: var(--accent-color);
color: #1e1e2e;
color: #0A1A10;
font-size: 0.95rem;
font-weight: 700;
margin-top: 16px;
@@ -168,7 +168,7 @@
}
.support-btn-auditlog:hover:not(:disabled) {
background-color: #5a5c72;
background-color: #1c2a24;
}
.support-fine-print {
@@ -219,8 +219,8 @@
}
.support-wallet-protected {
background-color: rgba(46, 194, 126, 0.06);
border-color: rgba(46, 194, 126, 0.3);
background-color: rgba(109, 191, 139, 0.06);
border-color: rgba(109, 191, 139, 0.3);
}
.support-wallet-unlocked {
@@ -290,7 +290,7 @@
padding: 8px 16px;
border-radius: var(--radius-btn);
background-color: var(--yellow);
color: #1e1e2e;
color: #0A1A10;
font-size: 0.82rem;
font-weight: 700;
}
@@ -310,7 +310,7 @@
}
.support-btn-wallet-lock:hover:not(:disabled) {
background-color: #27ae6e;
background-color: #529E7E;
}
/* ── Audit log ───────────────────────────────────────────────────── */
@@ -324,7 +324,7 @@
.support-audit-log {
max-height: 200px;
overflow-y: auto;
background-color: #12121c;
background-color: #0c0f0e;
border-radius: 8px;
padding: 10px 14px;
}
@@ -334,7 +334,7 @@
font-size: 0.72rem;
color: var(--text-secondary);
padding: 3px 0;
border-bottom: 1px solid rgba(69, 71, 90, 0.3);
border-bottom: 1px solid rgba(30, 45, 39, 0.4);
}
.support-audit-entry:last-child {
+116 -3
View File
@@ -5,6 +5,8 @@
min-height: 130px;
background-color: var(--card-color);
border: 1px solid var(--border-color);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
display: flex;
@@ -20,7 +22,7 @@
.service-tile:hover {
box-shadow: var(--shadow-hover);
border-color: #6c7086;
border-color: var(--accent-color);
}
.service-tile.disabled {
@@ -93,6 +95,7 @@
.status-dot.disabled { background-color: var(--grey); }
.status-dot.needs-attention { background-color: var(--yellow); }
.status-dot.syncing { background-color: #f5a623; animation: pulse-badge 1.5s infinite; }
.status-dot.checking-reachability { background-color: var(--accent-color); animation: pulse-badge 1s infinite; }
/* ── Bitcoin IBD sync progress bar ──────────────────────────────── */
@@ -152,6 +155,69 @@
white-space: nowrap;
}
/* ── BIP-110 status badge (tile + detail modal) ───────────────────── */
.tile-bip110-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 0.64rem;
font-weight: 600;
border-radius: 4px;
padding: 2px 6px;
margin-top: 4px;
white-space: nowrap;
letter-spacing: 0.02em;
}
.tile-bip110-badge--active {
background: rgba(109, 191, 139, 0.18);
color: var(--green);
border: 1px solid rgba(109, 191, 139, 0.3);
}
.tile-bip110-badge--locked_in {
background: rgba(94, 173, 138, 0.15);
color: var(--accent-color);
border: 1px solid rgba(94, 173, 138, 0.3);
}
.tile-bip110-badge--signaling {
background: rgba(94, 173, 138, 0.12);
color: var(--accent-color);
border: 1px solid rgba(94, 173, 138, 0.2);
}
.tile-bip110-badge--not_signaling {
background: rgba(229, 165, 10, 0.12);
color: var(--yellow);
border: 1px solid rgba(229, 165, 10, 0.25);
}
.tile-bip110-badge--unsupported {
background: rgba(94, 122, 106, 0.12);
color: var(--grey);
border: 1px solid rgba(94, 122, 106, 0.2);
}
.tile-bip110-badge--unknown {
background: transparent;
color: var(--text-dim);
border: 1px solid var(--border-color);
}
.bip110-status-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.bip110-source-label {
color: var(--text-dim);
font-size: 0.75rem;
}
/* ── Service detail modal sections ───────────────────────────────── */
.svc-detail-section {
@@ -235,7 +301,7 @@
.svc-detail-port-table td {
padding: 8px 10px;
border-bottom: 1px solid rgba(69, 71, 90, 0.4);
border-bottom: 1px solid rgba(30, 45, 39, 0.6);
color: var(--text-primary);
}
@@ -294,7 +360,7 @@
}
.svc-detail-troubleshoot code {
background-color: rgba(137, 180, 250, 0.12);
background-color: rgba(94, 173, 138, 0.10);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.82rem;
@@ -310,6 +376,12 @@
text-decoration: underline;
}
/* ── Service detail: Domain configure button ─────────────────────── */
.svc-detail-domain-btn {
margin-top: 12px;
}
/* ── Service detail: Addon feature toggle ────────────────────────── */
.svc-detail-addon-row {
@@ -344,6 +416,47 @@
font-weight: 600;
}
.btn-warning {
background-color: #d97706;
color: #fff;
}
.btn-warning:hover:not(:disabled) {
background-color: #b45309;
}
.svc-detail-restart-section {
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.svc-detail-restart-btn {
margin-top: 8px;
}
.svc-detail-restart-result {
margin-top: 12px;
padding: 12px 16px;
border-radius: 8px;
font-size: 0.88rem;
line-height: 1.5;
display: none;
}
.svc-detail-restart-result.success {
background-color: rgba(109, 191, 139, 0.12);
border: 1px solid var(--green);
color: var(--green);
display: block;
}
.svc-detail-restart-result.error {
background-color: rgba(239, 68, 68, 0.12);
border: 1px solid #ef4444;
color: #f87171;
display: block;
}
/* ── Desktop launch buttons ──────────────────────────────────────── */
@@ -6,6 +6,9 @@ const POLL_INTERVAL_SERVICES = 5000;
const POLL_INTERVAL_UPDATES = 1800000;
const UPDATE_POLL_INTERVAL = 2000;
const REBOOT_CHECK_INTERVAL = 5000;
const REBOOT_FETCH_TIMEOUT = 12000;
const REBOOT_REQUEST_TIMEOUT = 4000;
const REBOOT_INITIAL_DELAY = 25000;
const SUPPORT_TIMER_INTERVAL = 1000;
const CATEGORY_ORDER = [
+125 -3
View File
@@ -9,6 +9,12 @@ if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
// Logout button
if ($logoutBtn) $logoutBtn.addEventListener("click", function () {
fetch("/api/logout", { method: "POST", credentials: "same-origin" })
.finally(function () { window.location.replace("/login"); });
});
// Rebuild modal
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
@@ -38,6 +44,28 @@ if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeMod
if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal);
if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); });
// Restart confirm dialog
if ($restartConfirmCancel) $restartConfirmCancel.addEventListener("click", closeRestartConfirmDialog);
if ($restartConfirmModal) $restartConfirmModal.addEventListener("click", function(e) { if (e.target === $restartConfirmModal) closeRestartConfirmDialog(); });
if ($restartConfirmModal) $restartConfirmModal.addEventListener("keydown", function(e) { if (e.key === "Escape") closeRestartConfirmDialog(); });
// Header Reboot button
if ($headerRebootBtn) $headerRebootBtn.addEventListener("click", function() { openRestartConfirmDialog(); });
if ($restartConfirmOk) $restartConfirmOk.addEventListener("click", function() {
if ($restartConfirmOk.disabled) return;
$restartConfirmOk.disabled = true;
closeRestartConfirmDialog();
doReboot();
});
// Reboot error card buttons
var $rebootErrorCloseBtn = document.getElementById("reboot-error-close-btn");
var $rebootErrorRetryBtn = document.getElementById("reboot-error-retry-btn");
if ($rebootErrorCloseBtn) $rebootErrorCloseBtn.addEventListener("click", function() {
if ($rebootOverlay) $rebootOverlay.classList.remove("visible");
});
if ($rebootErrorRetryBtn) $rebootErrorRetryBtn.addEventListener("click", doReboot);
// ── Upgrade modal functions ───────────────────────────────────────
function openUpgradeModal() {
@@ -48,18 +76,63 @@ function closeUpgradeModal() {
if ($upgradeModal) $upgradeModal.classList.remove("open");
}
// ── Restart confirm dialog functions ─────────────────────────────
var _restartDialogOpener = null;
function openRestartConfirmDialog() {
if (!$restartConfirmModal) return;
_restartDialogOpener = document.activeElement;
// Detect conflicting operations
var isOperationInProgress = !!_updatePollTimer || !!_rebuildPollTimer;
if ($restartConflictBox) $restartConflictBox.style.display = isOperationInProgress ? "" : "none";
if ($restartConfirmOk) $restartConfirmOk.disabled = isOperationInProgress;
$restartConfirmModal.classList.add("open");
// Focus Cancel initially for safety
var cancelBtn = document.getElementById("restart-confirm-cancel-btn");
if (cancelBtn) setTimeout(function() { cancelBtn.focus(); }, 50);
}
function closeRestartConfirmDialog() {
if ($restartConfirmModal) $restartConfirmModal.classList.remove("open");
// Re-enable confirm button for next open
if ($restartConfirmOk) $restartConfirmOk.disabled = false;
// Return focus to the element that opened the dialog
if (_restartDialogOpener && _restartDialogOpener.focus) {
try { _restartDialogOpener.focus(); } catch (_) {}
_restartDialogOpener = null;
}
}
async function doUpgradeToServer() {
var confirmBtn = $upgradeConfirmBtn;
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; }
closeUpgradeModal();
// Reuse the rebuild modal to show progress
// Reuse the rebuild modal to show reboot progress
_rebuildFeatureName = "Server + Desktop";
_rebuildIsEnabling = true;
openRebuildModal();
try {
await apiFetch("/api/role/upgrade-to-server", { method: "POST" });
// Server is rebooting — show message and wait for it to come back
if ($rebuildStatus) $rebuildStatus.textContent = "Rebooting — the setup wizard will guide you through domain and port configuration…";
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
// Poll until server comes back, then redirect to onboarding
var pollInterval = setInterval(async function() {
try {
await apiFetch("/api/ping");
clearInterval(pollInterval);
window.location.href = "/onboarding";
} catch (_) {
// Server still down — keep polling
}
}, 3000);
} catch (err) {
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Upgrade failed: " + err.message;
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
@@ -70,6 +143,47 @@ async function doUpgradeToServer() {
if ($upgradeConfirmBtn) $upgradeConfirmBtn.addEventListener("click", doUpgradeToServer);
// ── First-login security banner ───────────────────────────────────
function showSecurityBanner() {
var existing = document.getElementById("security-first-login-banner");
if (existing) return;
var banner = document.createElement("div");
banner.id = "security-first-login-banner";
banner.className = "security-first-login-banner";
banner.innerHTML =
'<div class="security-banner-content">' +
'<span class="security-banner-icon">\uD83D\uDEE1</span>' +
'<span class="security-banner-text">' +
'<strong>Did someone else set up this machine?</strong> ' +
'If this computer was pre-configured by another person, go to ' +
'<strong>Menu \u2192 Security</strong> to reset all passwords and keys. ' +
'This ensures only you have access.' +
'</span>' +
'</div>' +
'<button class="security-banner-dismiss" id="security-banner-dismiss-btn" title="Dismiss">\u2715</button>';
var mainContent = document.querySelector(".main-content");
if (mainContent) {
mainContent.insertAdjacentElement("beforebegin", banner);
} else {
document.body.insertAdjacentElement("afterbegin", banner);
}
var dismissBtn = document.getElementById("security-banner-dismiss-btn");
if (dismissBtn) {
dismissBtn.addEventListener("click", async function() {
banner.remove();
try {
await apiFetch("/api/security/banner-dismiss", { method: "POST" });
} catch (_) {
// Non-fatal
}
});
}
}
// ── Init ──────────────────────────────────────────────────────────
async function init() {
@@ -84,8 +198,16 @@ async function init() {
// If we can't reach the endpoint, continue to normal dashboard
}
// Check for legacy machine security warning
await checkLegacySecurity();
// Show first-login security banner only for machines that went through onboarding
// (legacy machines without the onboarding flag will never see this)
try {
var bannerData = await apiFetch("/api/security/banner-status");
if (bannerData && bannerData.show) {
showSecurityBanner();
}
} catch (_) {
// Non-fatal — silently ignore
}
try {
var cfg = await apiFetch("/api/config");
+200 -145
View File
@@ -73,28 +73,131 @@ 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>';
}
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 =
'<div class="domain-setup-intro">' +
'<p><strong>Before continuing:</strong></p>' +
'<ol>' +
'<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>' +
'<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>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<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>' +
'<li>Njal.la will give you a curl command like:<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>' +
'<li>Enter the subdomain and paste that curl command below</li>' +
introHtml +
'<details style="margin-top:10px;">' +
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' +
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
'<li>In Njal.la, open a domain you own and click &quot;Add record&quot;.</li>' +
'<li>Set record type to <strong>Dynamic</strong>.</li>' +
'<li>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' +
'(Example only, your choice — for &quot;call.yourdomain.com&quot; you&apos;d type just: &nbsp;<code>call</code>)<br>' +
'&#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>' +
'</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 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>' +
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>';
document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal);
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
var npub = document.getElementById("domain-npub-input") ? (document.getElementById("domain-npub-input").value || "") : "";
subdomain = subdomain.trim();
ddnsUrl = ddnsUrl.trim();
npub = npub.trim();
if (!subdomain) { alert("Please enter a domain."); return; }
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
var saveBtn = document.getElementById("domain-setup-save-btn");
saveBtn.disabled = true;
saveBtn.textContent = "Saving…";
try {
await apiFetch("/api/domains/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
domain_name: feat.domain_name,
domain: subdomain,
ddns_url: ddnsUrl,
}),
});
closeDomainSetupModal();
onSaved(npub);
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = "Save & Enable";
alert("Failed to save domain. Please try again.");
}
});
$domainSetupModal.classList.add("open");
}
function openDomainReconfigureModal(feat, existingDomain, onSaved) {
if (!$domainSetupModal) return;
if ($domainSetupTitle) $domainSetupTitle.textContent = "🔄 Reconfigure Domain — " + feat.name;
var npubField = "";
if (feat.id === "haven") {
var currentNpub = "";
if (feat.extra_fields && feat.extra_fields.length > 0) {
for (var i = 0; i < feat.extra_fields.length; i++) {
if (feat.extra_fields[i].id === "nostr_npub") {
currentNpub = feat.extra_fields[i].current_value || "";
break;
}
}
}
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 currentDomain = existingDomain || "";
$domainSetupBody.innerHTML =
'<div class="domain-setup-intro">' +
'<p>Your domain <strong>' + escHtml(currentDomain || "this domain") + '</strong> is configured but isn\'t resolving correctly.</p>' +
'<p><strong>Troubleshooting steps:</strong></p>' +
'<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>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>' +
'<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 you changed the DDNS curl command, paste the updated one below</li>' +
'</ol>' +
'</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>' +
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>';
document.getElementById("domain-setup-cancel-btn").addEventListener("click", closeDomainSetupModal);
document.getElementById("domain-setup-save-btn").addEventListener("click", async function() {
var subdomain = (document.getElementById("domain-subdomain-input") || {}).value || "";
var ddnsUrl = (document.getElementById("domain-ddns-input") || {}).value || "";
@@ -124,7 +227,7 @@ function openDomainSetupModal(feat, onSaved) {
onSaved(npub);
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = "Save & Enable";
saveBtn.textContent = "Save & Update";
alert("Failed to save domain. Please try again.");
}
});
@@ -145,114 +248,59 @@ function openPortRequirementsModal(featureName, ports, onContinue) {
? '<button class="btn btn-primary" id="port-req-continue-btn">I Understand — Continue</button>'
: '';
// Show loading state while fetching port status
$portReqBody.innerHTML =
'<p class="port-req-intro">Checking port status for <strong>' + escHtml(featureName) + '</strong>…</p>' +
'<p class="port-req-hint">Detecting which ports are open on this machine…</p>';
function renderPortRequirements(internalIp) {
var rows = ports.map(function(p) {
return '<tr><td class="port-req-port">' + escHtml(p.port) + '</td>' +
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
}).join("");
var ipLine = internalIp
? '<p class="port-req-intro">Forward each port below <strong>to this machine\'s internal IP: <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code></strong></p>'
: "<p class=\"port-req-intro\">Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.</p>";
$portReqBody.innerHTML =
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + "</strong> to work with clients outside your local network, " +
"you must configure <strong>port forwarding</strong> in your router's admin panel.</p>" +
ipLine +
'<table class="port-req-table">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
"<p class=\"port-req-hint\"><strong>How to verify:</strong> Router-side forwarding cannot be checked from inside your network. " +
"To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " +
"or check your router's port forwarding page.</p>" +
'<p class="port-req-hint"> Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
'<div class="domain-field-actions">' +
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
continueBtn +
'</div>';
document.getElementById("port-req-dismiss-btn").onclick = function() {
closePortRequirementsModal();
};
if (onContinue) {
document.getElementById("port-req-continue-btn").onclick = function() {
closePortRequirementsModal();
onContinue();
};
}
}
$portReqModal.classList.add("open");
renderPortRequirements(null);
// Fetch live port status from local system commands (no external calls)
fetch("/api/ports/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ports: ports }),
})
fetch("/api/network")
.then(function(r) { return r.json(); })
.then(function(data) {
if (!$portReqModal.classList.contains("open")) return;
var internalIp = (data.internal_ip && data.internal_ip !== "unavailable")
? data.internal_ip : null;
var portStatuses = {};
(data.ports || []).forEach(function(p) {
portStatuses[p.port + "/" + p.protocol] = p.status;
});
var rows = ports.map(function(p) {
var key = p.port + "/" + p.protocol;
var status = portStatuses[key] || "unknown";
var statusHtml;
if (status === "listening") {
statusHtml = '<span class="port-status-listening" title="Service is running and firewall allows this port">🟢 Listening</span>';
} else if (status === "firewall_open") {
statusHtml = '<span class="port-status-open" title="Firewall allows this port but no service is bound yet">🟡 Open (idle)</span>';
} else if (status === "closed") {
statusHtml = '<span class="port-status-closed" title="Firewall blocks this port and/or nothing is listening">🔴 Closed</span>';
} else {
statusHtml = '<span class="port-status-unknown" title="Status could not be determined">⚪ Unknown</span>';
}
return '<tr>' +
'<td class="port-req-port">' + escHtml(p.port) + '</td>' +
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="port-req-desc">' + escHtml(p.description) + '</td>' +
'<td class="port-req-status">' + statusHtml + '</td>' +
'</tr>';
}).join("");
var ipLine = internalIp
? '<p class="port-req-intro">Forward each port below <strong>to this machine\'s internal IP: <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code></strong></p>'
: "<p class=\"port-req-intro\">Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.</p>";
$portReqBody.innerHTML =
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + "</strong> to work with clients outside your local network, " +
"you must configure <strong>port forwarding</strong> in your router's admin panel.</p>" +
ipLine +
'<table class="port-req-table">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th><th>Status</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
"<p class=\"port-req-hint\"><strong>How to verify:</strong> Router-side forwarding cannot be checked from inside your network. " +
"To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " +
"or check your router's port forwarding page.</p>" +
'<p class="port-req-hint"> Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
'<div class="domain-field-actions">' +
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
continueBtn +
'</div>';
document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
closePortRequirementsModal();
});
if (onContinue) {
document.getElementById("port-req-continue-btn").addEventListener("click", function() {
closePortRequirementsModal();
onContinue();
});
}
renderPortRequirements(internalIp);
})
.catch(function() {
// Fallback: show static table without status column if fetch fails
var rows = ports.map(function(p) {
return '<tr><td class="port-req-port">' + escHtml(p.port) + '</td>' +
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
}).join("");
$portReqBody.innerHTML =
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + '</strong> to work with clients outside your local network, ' +
'you must configure <strong>port forwarding</strong> in your router\'s admin panel and forward each port below to this machine\'s internal LAN IP.</p>' +
'<table class="port-req-table">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'<p class="port-req-hint"> Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
'<div class="domain-field-actions">' +
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
continueBtn +
'</div>';
document.getElementById("port-req-dismiss-btn").addEventListener("click", function() {
closePortRequirementsModal();
});
if (onContinue) {
document.getElementById("port-req-continue-btn").addEventListener("click", function() {
closePortRequirementsModal();
onContinue();
});
}
.catch(function(err) {
console.warn("Failed to fetch network info for port requirements modal:", err);
});
}
@@ -349,25 +397,52 @@ function handleFeatureToggle(feat, newEnabled) {
}
function proceedAfterConflictCheck() {
// Show port requirements notification if the feature has extra port needs
var ports = feat.port_requirements || [];
if (ports.length > 0) {
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
} else {
if (ports.length === 0) {
proceedAfterPortCheck();
return;
}
// Check which ports are actually closed before showing the modal
fetch("/api/ports/status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ports: ports }),
})
.then(function(r) {
if (!r.ok) throw new Error("Port status request failed: " + r.status);
return r.json();
})
.then(function(data) {
var portStatuses = {};
(data.ports || []).forEach(function(p) {
portStatuses[p.port + "/" + p.protocol] = p.status;
});
var closedPorts = ports.filter(function(p) {
var key = p.port + "/" + p.protocol;
var status = portStatuses[key] || "unknown";
return status !== "listening" && status !== "firewall_open";
});
if (closedPorts.length === 0) {
proceedAfterPortCheck();
} else {
openPortRequirementsModal(feat.name, closedPorts, proceedAfterPortCheck);
}
})
.catch(function(err) {
console.warn("Failed to fetch port status for feature enable flow:", err);
// Safe fallback if status check fails
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
});
}
if (conflictNames.length > 0) {
var confirmMsg;
if (feat.id === "bip110") {
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Continue?";
} else if (feat.id === "bitcoin-core") {
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Continue?";
} else {
confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?";
}
if (feat.id === "bitcoin-core") {
var confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will replace Bitcoin Knots + BIP110 as the active node. Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
} else if (conflictNames.length > 0) {
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
} else {
proceedAfterConflictCheck();
}
@@ -599,29 +674,9 @@ function renderAutolaunchToggle(enabled) {
var section = document.createElement("div");
section.className = "category-section autolaunch-section";
var securityBanner = "";
if (_securityIsLegacy) {
var msg = _securityWarningMessage || "Your system may have factory default passwords. Please change your passwords to secure your system.";
var linkText, linkAction;
if (_securityStatus === "unsealed") {
linkText = "Contact Support";
linkAction = "openSupportModal(); return false;";
} else {
linkText = "Change Passwords";
linkAction = "openServiceDetailModal('root-password-setup.service', 'System Passwords', 'passwords'); return false;";
}
securityBanner =
'<div class="security-inline-banner">' +
'<span class="security-inline-icon">⚠</span>' +
'<span class="security-inline-text">' + msg + '</span>' +
'<a class="security-inline-link" href="#" onclick="' + linkAction + '">' + linkText + '</a>' +
'</div>';
}
section.innerHTML =
'<div class="section-header">Preferences</div>' +
'<hr class="section-divider" />' +
securityBanner +
'<div class="feature-card">' +
'<div class="feature-card-top">' +
'<div class="feature-card-info">' +
@@ -14,6 +14,7 @@ function statusClass(health) {
if (health === "disabled") return "disabled";
if (health === "syncing") return "syncing";
if (STATUS_LOADING_STATES.has(health)) return "loading";
if (health === "checking_reachability") return "checking-reachability";
return "unknown";
}
@@ -27,6 +28,7 @@ function statusText(health, enabled) {
if (health === "syncing") return "Syncing\u2026";
if (!health || health === "unknown") return "Unknown";
if (STATUS_LOADING_STATES.has(health)) return health;
if (health === "checking_reachability") return "Checking\u2026";
return health;
}
@@ -58,3 +60,17 @@ async function apiFetch(path, options) {
}
return res.json();
}
// ── BIP-110 badge state config ────────────────────────────────────
// Shared lookup used by tiles.js and service-detail.js.
// Keys match the "state" values returned by /api/bitcoin/bip110.
var BIP110_BADGE_CONFIG = {
active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' },
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' },
signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' },
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' },
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' },
unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' }
};
+10 -3
View File
@@ -51,19 +51,26 @@ async function pollRebuildStatus() {
if (data.running) return;
_rebuildFinished = true;
stopRebuildPoll();
onRebuildDone(data.result === "success");
if (data.result === "reboot_required") {
onRebuildDone("reboot_required");
} else {
onRebuildDone(data.result === "success");
}
} catch (err) {
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
}
}
function onRebuildDone(success) {
function onRebuildDone(result) {
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
if ($rebuildClose) $rebuildClose.disabled = false;
if (success) {
if (result === true) {
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done";
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
setTimeout(function() { window.location.reload(); }, 1200);
} else if (result === "reboot_required") {
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — restart required";
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
} else {
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
+238 -10
View File
@@ -1,16 +1,244 @@
"use strict";
// ── Legacy security warning ───────────────────────────────────────
// ── Security Modal ────────────────────────────────────────────────
async function checkLegacySecurity() {
try {
var data = await apiFetch("/api/security/status");
if (data && (data.status === "legacy" || data.status === "unsealed")) {
_securityIsLegacy = true;
_securityStatus = data.status;
_securityWarningMessage = data.warning || "This machine may have a security issue. Please review your system security.";
function openSecurityModal() {
if ($supportModal) $supportModal.classList.add("open");
var title = document.getElementById("support-modal-title");
if (title) title.textContent = "\uD83D\uDEE1 Security";
if ($supportBody) {
$supportBody.innerHTML =
// ── Section A: Security Reset ──────────────────────────────
'<div class="security-section">' +
'<h3 class="security-section-title">Security Reset</h3>' +
'<p class="security-section-desc">' +
'Run this if you are using this physical computer for the first time <strong>AND</strong> ' +
'it was not set up by you. This will complete the security setup by resetting all passwords ' +
'and your Bitcoin Lightning Node\u2019s private keys.' +
'</p>' +
'<p class="security-section-desc">' +
'You can also run this if you wish to reset all your passwords and your Bitcoin Lightning ' +
'Node\u2019s private keys. If you have not transferred the Bitcoin out of this node and did ' +
'not back up the private keys, <strong>you will lose your Bitcoin.</strong>' +
'</p>' +
'<button class="btn btn-primary" id="security-reset-open-btn">Proceed with Security Reset</button>' +
'<div id="security-reset-confirm" style="display:none;margin-top:16px;">' +
'<div class="security-warning-box">' +
'<p class="security-warning-text">' +
'<strong>\u26A0\uFE0F This will permanently delete:</strong>' +
'</p>' +
'<ul class="security-warning-list">' +
'<li>All generated passwords and SSH keys</li>' +
'<li>LND wallet data (seed words, channels, macaroons)</li>' +
'<li>Application databases</li>' +
'<li>Vaultwarden data</li>' +
'</ul>' +
'<p class="security-warning-text">You will go through onboarding again. <strong>This cannot be undone.</strong></p>' +
'</div>' +
'<div class="security-erase-group">' +
'<label class="security-erase-label" for="security-erase-input">Type <strong>ERASE</strong> to confirm:</label>' +
'<input class="security-erase-input" type="text" id="security-erase-input" autocomplete="off" placeholder="ERASE" />' +
'</div>' +
'<div class="security-reset-actions">' +
'<button class="btn btn-close-modal" id="security-reset-cancel-btn">Cancel</button>' +
'<button class="btn btn-danger" id="security-reset-confirm-btn" disabled>Erase &amp; Reset</button>' +
'</div>' +
'<div id="security-reset-status" class="security-status-msg"></div>' +
'</div>' +
'</div>' +
'<hr class="security-divider" />' +
// ── Section B: Verify System Integrity ────────────────────
'<div class="security-section">' +
'<h3 class="security-section-title">Verify System Integrity</h3>' +
'<p class="security-section-desc">' +
'Your Sovran_SystemsOS is built with NixOS \u2014 a system designed for complete transparency ' +
'and reproducibility. Every piece of software on this machine is built from publicly auditable ' +
'source code and verified using cryptographic hashes.' +
'</p>' +
'<p class="security-section-desc">This verification confirms three things:</p>' +
'<ol class="security-verify-list">' +
'<li>' +
'<strong>Source Code Match</strong> \u2014 The system configuration on this machine matches ' +
'the exact commit published in the public repository. No hidden changes were added.' +
'</li>' +
'<li>' +
'<strong>Binary Integrity</strong> \u2014 Every installed package in the system store is ' +
'verified against its expected cryptographic hash. If any binary, library, or config file ' +
'was tampered with, it will be detected.' +
'</li>' +
'<li>' +
'<strong>Running System Match</strong> \u2014 The currently running system matches what the ' +
'configuration says it should be. No unauthorized modifications are active.' +
'</li>' +
'</ol>' +
'<p class="security-section-desc">' +
'In short: if this verification passes, you can be confident that the software running on ' +
'your machine is exactly what is published \u2014 nothing more, nothing less.' +
'</p>' +
'<button class="btn btn-primary" id="security-verify-btn">Verify Now</button>' +
'<div id="security-verify-results" style="display:none;margin-top:16px;"></div>' +
'</div>';
// ── Wire Security Reset flow
var resetOpenBtn = document.getElementById("security-reset-open-btn");
var resetConfirmDiv = document.getElementById("security-reset-confirm");
var eraseInput = document.getElementById("security-erase-input");
var resetConfirmBtn = document.getElementById("security-reset-confirm-btn");
var resetCancelBtn = document.getElementById("security-reset-cancel-btn");
var resetStatus = document.getElementById("security-reset-status");
if (resetOpenBtn) {
resetOpenBtn.addEventListener("click", function() {
resetOpenBtn.style.display = "none";
if (resetConfirmDiv) resetConfirmDiv.style.display = "";
if (eraseInput) eraseInput.focus();
});
}
if (eraseInput && resetConfirmBtn) {
eraseInput.addEventListener("input", function() {
resetConfirmBtn.disabled = eraseInput.value.trim() !== "ERASE";
});
}
if (resetCancelBtn) {
resetCancelBtn.addEventListener("click", function() {
if (resetConfirmDiv) resetConfirmDiv.style.display = "none";
if (resetOpenBtn) resetOpenBtn.style.display = "";
if (eraseInput) eraseInput.value = "";
if (resetConfirmBtn) resetConfirmBtn.disabled = true;
if (resetStatus) { resetStatus.textContent = ""; resetStatus.className = "security-status-msg"; }
});
}
if (resetConfirmBtn) {
resetConfirmBtn.addEventListener("click", async function() {
if (!eraseInput || eraseInput.value.trim() !== "ERASE") return;
resetConfirmBtn.disabled = true;
resetConfirmBtn.textContent = "Erasing\u2026";
// Show the full-screen blocking overlay immediately so the user knows
// the wipe is in progress even while the API call runs synchronously.
var $secResetOverlay = document.getElementById("security-reset-overlay");
var $secResetStep = document.getElementById("security-reset-overlay-step");
if ($secResetOverlay) $secResetOverlay.classList.add("visible");
// Close the support modal so its content doesn't bleed through the overlay
if ($supportModal) $supportModal.classList.remove("open");
if (resetStatus) { resetStatus.textContent = "Running security reset\u2026"; resetStatus.className = "security-status-msg security-status-info"; }
try {
var data = await apiFetch("/api/security/reset", { method: "POST" });
// Switch to Phase 2: show the new password and wait for user confirmation
var phase1 = document.getElementById("security-reset-phase1");
var phase2 = document.getElementById("security-reset-phase2");
var passwordBox = document.getElementById("security-reset-new-password");
var rebootBtn = document.getElementById("security-reset-reboot-btn");
if (phase1) phase1.style.display = "none";
if (phase2) phase2.style.display = "";
if (passwordBox && data.new_password) passwordBox.textContent = data.new_password;
if (rebootBtn) {
// Keep button disabled for 5 seconds to prevent accidental clicks
var countdown = 5;
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
var timer = setInterval(function() {
countdown--;
if (countdown <= 0) {
clearInterval(timer);
rebootBtn.disabled = false;
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System";
} else {
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
}
}, 1000);
rebootBtn.addEventListener("click", function() {
rebootBtn.disabled = true;
rebootBtn.textContent = "Restarting\u2026";
// Hide the security reset overlay so the shared reboot overlay is visible
var $secResetOverlay2 = document.getElementById("security-reset-overlay");
if ($secResetOverlay2) $secResetOverlay2.classList.remove("visible");
doReboot();
}, { once: true });
}
} catch (err) {
if ($secResetOverlay) $secResetOverlay.classList.remove("visible");
if (resetStatus) { resetStatus.textContent = "\u2717 Error: " + (err.message || "Reset failed."); resetStatus.className = "security-status-msg security-status-error"; }
resetConfirmBtn.disabled = false;
resetConfirmBtn.textContent = "Erase & Reset";
}
});
}
// ── Wire Verify System Integrity
var verifyBtn = document.getElementById("security-verify-btn");
var verifyResults = document.getElementById("security-verify-results");
if (verifyBtn && verifyResults) {
verifyBtn.addEventListener("click", async function() {
verifyBtn.disabled = true;
verifyBtn.textContent = "Verifying\u2026";
verifyResults.style.display = "";
verifyResults.innerHTML = '<p class="security-verify-loading">\u231B Running verification checks\u2026 This may take a few minutes.</p>';
try {
var data = await apiFetch("/api/security/verify-integrity", { method: "POST" });
var html = '<div class="security-verify-result-card">';
// Flake commit
html += '<div class="security-verify-row">';
html += '<span class="security-verify-label">Source Commit:</span>';
html += '<span class="security-verify-value security-verify-mono">' + escHtml(data.flake_commit || "unknown") + '</span>';
if (data.repo_url) {
html += '<a class="security-verify-link" href="' + escHtml(data.repo_url) + '" target="_blank" rel="noopener noreferrer">View on Gitea \u2197</a>';
}
html += '</div>';
// Store verification
var storeOk = data.store_verified === true;
html += '<div class="security-verify-row">';
html += '<span class="security-verify-label">Binary Integrity:</span>';
html += '<span class="security-verify-badge ' + (storeOk ? "security-verify-pass" : "security-verify-fail") + '">';
html += storeOk ? "\u2705 PASS" : "\u274C FAIL";
html += '</span>';
html += '</div>';
if (!storeOk && data.store_errors && data.store_errors.length > 0) {
html += '<details class="security-verify-errors"><summary>Show errors (' + data.store_errors.length + ')</summary>';
html += '<pre class="security-verify-pre">' + escHtml(data.store_errors.join("\n")) + '</pre>';
html += '</details>';
}
// System match
var sysOk = data.system_matches === true;
html += '<div class="security-verify-row">';
html += '<span class="security-verify-label">Running System Match:</span>';
html += '<span class="security-verify-badge ' + (sysOk ? "security-verify-pass" : "security-verify-fail") + '">';
html += sysOk ? "\u2705 PASS" : "\u274C FAIL";
html += '</span>';
html += '</div>';
if (!sysOk) {
html += '<div class="security-verify-path-row">';
html += '<span class="security-verify-path-label">Current:</span><code class="security-verify-mono">' + escHtml(data.current_system_path || "") + '</code>';
html += '</div>';
html += '<div class="security-verify-path-row">';
html += '<span class="security-verify-path-label">Expected:</span><code class="security-verify-mono">' + escHtml(data.expected_system_path || "") + '</code>';
html += '</div>';
}
html += '</div>';
verifyResults.innerHTML = html;
} catch (err) {
verifyResults.innerHTML = '<p class="security-status-msg security-status-error">\u274C Verification failed: ' + escHtml(err.message || "Unknown error") + '</p>';
}
verifyBtn.disabled = false;
verifyBtn.textContent = "Verify Now";
});
}
} catch (_) {
// Non-fatal — silently ignore if the endpoint is unreachable
}
}
@@ -7,11 +7,16 @@ function _renderCredsHtml(credentials, unit) {
for (var i = 0; i < credentials.length; i++) {
var cred = credentials[i];
var id = "cred-" + Math.random().toString(36).substring(2, 8);
var displayValue = linkify(cred.value);
var qrBlock = "";
if (cred.qrcode) {
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</div></div>';
}
// If qronly, render the label + QR block only — skip value and copy button
if (cred.qronly) {
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '</div>';
continue;
}
var displayValue = linkify(cred.value);
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
}
return html;
@@ -85,21 +90,6 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>';
}
// Section: Desktop Launch (only for services with desktop apps)
if (data.desktop_links && data.desktop_links.length > 0) {
var launchBtns = '';
data.desktop_links.forEach(function(link) {
launchBtns += '<button class="btn btn-primary svc-detail-launch-btn" data-desktop="' + escHtml(link.desktop_file) + '">' +
'🖥️ ' + escHtml(link.label) +
'</button>';
});
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Open on Desktop</div>' +
'<p class="svc-detail-desc" style="margin-bottom:12px">If you are accessing this machine locally on the GNOME desktop, click below to launch the app directly.</p>' +
'<div class="svc-detail-launch-row">' + launchBtns + '</div>' +
'</div>';
}
// Section B: Status
// When a feature override is present, use the feature's enabled state so the
// modal matches what the dashboard tile shows (feature toggle is authoritative).
@@ -117,156 +107,137 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>' +
'</div>';
// Section C: Ports (only if service has port_requirements)
if (data.port_statuses && data.port_statuses.length > 0) {
var anyPortClosed = data.port_statuses.some(function(p) { return p.status === "closed"; });
// Section B2: BIP-110 live status (bip110 tile only)
if (icon === 'bip110' && data.bip110) {
var bip110 = data.bip110;
var bip110State = bip110.state || 'unknown';
var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown;
var bip110Source = bip110.source ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
'<div class="bip110-status-row">' +
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
bip110Source +
'</div>' +
'</div>';
}
// Section C: Domain diagnostics (domain services)
if (data.needs_domain) {
var steps = data.domain_check_steps || [];
var stepsHtml = "";
steps.forEach(function(step) {
var iconLabel = "—";
if (step.status === "ok") iconLabel = "✅";
else if (step.status === "error") iconLabel = "❌";
else if (step.status === "warning") iconLabel = "⚠️";
else if (step.status === "skipped") iconLabel = "⏭️";
var detail = escHtml(step.detail || "").replace(/\n/g, "<br>");
stepsHtml += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px">' +
'<strong>' + iconLabel + ' Step ' + escHtml(String(step.step)) + ': ' + escHtml(step.label || "") + '</strong>' +
(detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') +
'</div>';
});
var domainActionHtml = "";
var ds = data.domain_status || {};
if (!data.domain && data.domain_name) {
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-config-domain-btn">🌐 Configure Domain</button>';
} else if (data.domain && (ds.status === "dns_mismatch" || ds.status === "unresolvable")) {
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-reconfig-domain-btn">🔄 Reconfigure Domain</button>';
}
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Domain Diagnostic Checklist</div>' +
stepsHtml +
domainActionHtml +
'</div>';
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 = "";
data.extra_ports.forEach(function(p) {
var statusIcon, statusClass2;
if (!effectiveEnabled) {
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";
} else if (p.status === "firewall_open") {
statusIcon = "✅ Ready";
statusClass2 = "port-status-open";
} else if (p.status === "closed") {
statusIcon = "❌ Not ready yet";
statusClass2 = "port-status-closed";
} else {
statusIcon = "— Could not check";
statusClass2 = "port-status-unknown";
}
extraRows += '<tr>' +
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="svc-detail-port-table-desc">' + escHtml(p.description || "") + '</td>' +
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
'</tr>';
});
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
'<div class="svc-detail-port-note">Forward these ports in your router to this Sovran_SystemsOS computer.</div>' +
'<div class="svc-detail-port-note"><strong>Router Forward-To IP:</strong> ' + internalIpHtml + '</div>' +
'<div class="svc-detail-port-note">' + routerIpHelp + '</div>' +
'<table class="svc-detail-port-table">' +
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' +
'<tbody>' + extraRows + '</tbody>' +
'</table>' +
'<div class="svc-detail-port-note">The Hub can check whether Sovran_SystemsOS is ready on this computer, but full public port verification requires an outside internet check.</div>' +
'<div class="svc-detail-port-note">' + routerNextStep + '</div>' +
'</div>';
}
} else if (data.port_statuses && data.port_statuses.length > 0) {
// Non-domain services (SSH) keep local single-port checks.
var portTableRows = "";
data.port_statuses.forEach(function(p) {
var statusIcon, statusClass2;
if (p.status === "listening") {
statusIcon = "✅ Open";
statusIcon = "✅ Ready";
statusClass2 = "port-status-listening";
} else if (p.status === "firewall_open") {
statusIcon = "🟡 Firewall open";
statusIcon = "✅ Ready";
statusClass2 = "port-status-open";
} else if (p.status === "closed") {
statusIcon = "🔴 Closed";
statusIcon = "❌ Not ready";
statusClass2 = "port-status-closed";
} else {
statusIcon = "— Unknown";
statusIcon = "— Could not check";
statusClass2 = "port-status-unknown";
}
var desc = p.description;
var portNum = parseInt(p.port, 10);
if (portNum === 80 || portNum === 443) {
desc += " (shared — all services)";
}
portTableRows += '<tr>' +
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
'<td class="svc-detail-port-table-desc">' + escHtml(desc) + '</td>' +
'<td class="svc-detail-port-table-desc">' + escHtml(p.description || "") + '</td>' +
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
'</tr>';
});
var troubleshootHtml = "";
if (anyPortClosed) {
var sharedPorts = [];
var specificPorts = [];
data.port_statuses.forEach(function(p) {
if (p.status === "closed") {
var portNum = parseInt(p.port, 10);
if (portNum === 80 || portNum === 443) {
sharedPorts.push(p);
} else {
specificPorts.push(p);
}
}
});
var troubleParts = [];
if (sharedPorts.length > 0) {
troubleParts.push(
'<strong>⚠️ Ports 80 and 443 need to be forwarded on your router.</strong>' +
'<p style="margin-top:8px">These are <strong>shared system ports</strong> — you only need to set them up once and they cover all your domain-based services ' +
'(BTCPayServer, Nextcloud, Matrix, WordPress, etc.).</p>' +
'<p style="margin-top:8px">If you already forwarded these ports during onboarding, you don\'t need to do it again. Otherwise:</p>' +
'<ol>' +
'<li>Log into your router\'s admin panel (usually <code>http://192.168.1.1</code>)</li>' +
'<li>Find the <strong>Port Forwarding</strong> section</li>' +
'<li>Forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong> to your machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
'<li>Save your router settings</li>' +
'</ol>' +
'<p style="margin-top:8px">💡 Once these two ports are forwarded, you won\'t see this warning on any service again.</p>'
);
}
if (specificPorts.length > 0) {
var portList = specificPorts.map(function(p) {
return '<strong>' + escHtml(p.port) + ' (' + escHtml(p.protocol) + ')</strong> — ' + escHtml(p.description);
}).join('<br>');
troubleParts.push(
'<strong>⚠️ This service requires additional ports to be forwarded:</strong>' +
'<p style="margin-top:8px">' + portList + '</p>' +
'<ol>' +
'<li>Log into your router\'s admin panel</li>' +
'<li>Forward each port listed above to your machine\'s internal IP: <code>' + escHtml(data.internal_ip || "—") + '</code></li>' +
'<li>Save your router settings</li>' +
'</ol>'
);
}
troubleshootHtml = '<div class="svc-detail-troubleshoot">' + troubleParts.join('<hr style="border:none;border-top:1px solid rgba(255,255,255,0.1);margin:16px 0">') + '</div>';
}
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">' +
'<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>' +
'</table>' +
troubleshootHtml +
'</div>';
}
// Section D: Domain (only if service needs_domain)
if (data.needs_domain) {
var domainStatusHtml = "";
var ds = data.domain_status || {};
var domainBadge = "";
if (data.domain) {
if (ds.status === "connected") {
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--ok">✓ ' + escHtml(data.domain) + '</span></span>';
} else if (ds.status === "dns_mismatch") {
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">⚠ ' + escHtml(data.domain) + ' (IP mismatch)</span></span>';
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
'<strong>⚠️ Your domain resolves to ' + escHtml(ds.resolved_ip || "unknown") + ' but your external IP is ' + escHtml(ds.expected_ip || "unknown") + '.</strong>' +
'<p style="margin-top:8px">This usually means the DNS record needs to be updated:</p>' +
'<ol>' +
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
'<li>Find your domain and check the Dynamic DNS record</li>' +
'<li>Make sure it points to your current external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
'<li>If you set up a DDNS curl command during onboarding, verify it\'s running correctly</li>' +
'</ol>' +
'</div>';
} else if (ds.status === "unresolvable") {
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--error">✗ ' + escHtml(data.domain) + ' (DNS error)</span></span>';
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
'<strong>⚠️ This domain cannot be resolved. DNS is not configured yet.</strong>' +
'<p style="margin-top:8px">Let\'s get it set up:</p>' +
'<ol>' +
'<li>Go to <a href="https://njal.la" target="_blank">njal.la</a> and log into your account</li>' +
'<li>Find the domain you purchased for this service</li>' +
'<li>Create a Dynamic DNS record pointing to your external IP: <code>' + escHtml(ds.expected_ip || "—") + '</code></li>' +
'<li>Copy the DDNS curl command from Njal.la\'s dashboard</li>' +
'<li>You can re-enter it in the Feature Manager to update your configuration</li>' +
'</ol>' +
'</div>';
} else {
domainBadge = '<span class="svc-detail-domain-value">' + escHtml(data.domain) + '</span>';
}
} else {
domainBadge = '<span class="svc-detail-domain-value"><span class="tile-domain-label--warn">Not configured</span></span>';
domainStatusHtml = '<div class="svc-detail-troubleshoot">' +
'<strong>⚠️ No domain has been configured for this service yet.</strong>' +
'<p style="margin-top:8px">To get this service working:</p>' +
'<ol>' +
'<li>Purchase a subdomain at <a href="https://njal.la" target="_blank">njal.la</a> (if you haven\'t already)</li>' +
'<li>Go to the <strong>Feature Manager</strong> in the sidebar</li>' +
'<li>Find this service and configure your domain through the setup wizard</li>' +
'</ol>' +
'</div>';
}
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Domain</div>' +
domainBadge +
domainStatusHtml +
'</div>';
}
@@ -282,7 +253,7 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>' : "") +
(unit === "root-password-setup.service" ?
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
'<button class="matrix-action-btn" id="sys-change-pw-btn">🔑 Change Password</button>' +
'<button class="matrix-action-btn" id="sys-change-pw-btn">🔑 Change Free Account Password</button>' +
'</div>' : "") +
'</div>';
} else if (!data.enabled && !data.feature) {
@@ -308,7 +279,7 @@ async function openServiceDetailModal(unit, name, icon) {
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core")
var addonSectionTitle = (feat.id === "bitcoin-core")
? "\u20BF Bitcoin Node Selection"
: "\uD83D\uDD27 Addon Feature";
@@ -341,28 +312,18 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>';
}
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
html += '<div class="svc-detail-section svc-detail-restart-section">' +
'<div class="svc-detail-section-title">Troubleshooting</div>' +
'<p class="svc-detail-desc">If you\'re experiencing issues with this service, try restarting it.</p>' +
'<button class="btn btn-warning svc-detail-restart-btn" id="svc-detail-restart-btn">🔄 Restart Service</button>' +
'<div class="svc-detail-restart-result" id="svc-detail-restart-result"></div>' +
'</div>';
}
$credsBody.innerHTML = html;
_attachCopyHandlers($credsBody);
// Desktop launch button handlers
$credsBody.querySelectorAll(".svc-detail-launch-btn").forEach(function(btn) {
btn.addEventListener("click", async function() {
var desktopFile = btn.dataset.desktop;
btn.disabled = true;
btn.textContent = "Launching…";
try {
await apiFetch("/api/desktop/launch/" + encodeURIComponent(desktopFile), {
method: "POST"
});
btn.textContent = "✓ Launched!";
setTimeout(function() { btn.textContent = "🖥️ " + btn.textContent; btn.disabled = false; }, 2000);
} catch (err) {
btn.textContent = "❌ Failed";
btn.disabled = false;
}
});
});
if (unit === "matrix-synapse.service") {
var addBtn = document.getElementById("matrix-add-user-btn");
var changePwBtn = document.getElementById("matrix-change-pw-btn");
@@ -385,6 +346,59 @@ async function openServiceDetailModal(unit, name, icon) {
});
}
}
var restartBtn = document.getElementById("svc-detail-restart-btn");
var restartResult = document.getElementById("svc-detail-restart-result");
if (restartBtn && restartResult) {
var RESTART_REFRESH_DELAY_MS = 3000;
restartBtn.addEventListener("click", async function() {
restartBtn.disabled = true;
restartBtn.textContent = "Restarting…";
restartResult.className = "svc-detail-restart-result";
restartResult.textContent = "";
try {
await apiFetch("/api/service/" + encodeURIComponent(unit) + "/restart", { method: "POST" });
restartResult.classList.add("success");
restartResult.textContent = "✅ Service restarted successfully.";
restartBtn.disabled = false;
restartBtn.textContent = "🔄 Restart Service";
setTimeout(function() {
openServiceDetailModal(unit, name, icon);
}, RESTART_REFRESH_DELAY_MS);
} catch (e) {
restartResult.classList.add("error");
restartResult.textContent = e && e.message ? e.message : "Failed to restart service. Please check service logs and try again.";
restartBtn.disabled = false;
restartBtn.textContent = "🔄 Restart Service";
}
});
}
// Configure / Reconfigure Domain buttons (for non-feature services that need a domain)
var configDomainBtn = document.getElementById("svc-detail-config-domain-btn");
var reconfigDomainBtn = document.getElementById("svc-detail-reconfig-domain-btn");
if ((configDomainBtn || reconfigDomainBtn) && data.needs_domain && data.domain_name) {
var pseudoFeat = {
id: data.domain_name,
name: name,
domain_name: data.domain_name,
needs_ddns: true,
extra_fields: []
};
if (configDomainBtn) configDomainBtn.addEventListener("click", function() {
closeCredsModal();
openDomainSetupModal(pseudoFeat, function() {
openServiceDetailModal(unit, name, icon);
});
});
if (reconfigDomainBtn) reconfigDomainBtn.addEventListener("click", function() {
closeCredsModal();
openDomainReconfigureModal(pseudoFeat, data.domain || "", function() {
openServiceDetailModal(unit, name, icon);
});
});
}
} catch (err) {
if ($credsBody) $credsBody.innerHTML = '<p class="creds-empty">Could not load service details.</p>';
}
@@ -548,8 +562,8 @@ function openSystemChangePasswordModal(unit, name, icon) {
if (!$credsBody) return;
$credsBody.innerHTML =
'<div class="sys-chpw-header">' +
'<div class="sys-chpw-title">🔑 Change \'free\' Account Password</div>' +
'<div class="sys-chpw-desc">This updates the system login password for the <strong>free</strong> user account on this device.</div>' +
'<div class="sys-chpw-title">🔑 Change Free Account &amp; Hub Login Password</div>' +
'<div class="sys-chpw-desc">This updates the password for the <strong>free</strong> user account. <strong>This is also your Sovran Hub login password</strong> — both will change.</div>' +
'</div>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-new">New Password</label>' +
'<div class="pw-input-wrap">' +
@@ -562,7 +576,7 @@ function openSystemChangePasswordModal(unit, name, icon) {
'<input class="matrix-form-input" type="password" id="sys-chpw-confirm" placeholder="Confirm new password" autocomplete="new-password">' +
'<button type="button" class="pw-toggle-btn" id="sys-chpw-confirm-toggle" aria-label="Toggle password visibility">👁</button>' +
'</div></div>' +
'<div class="pw-credentials-note">⚠ After changing, your updated password will appear in the System Passwords credentials tile. Make sure to remember it.</div>' +
'<div class="pw-credentials-note">⚠ This will change both your desktop login and Hub login password. After changing, your updated password will appear in the System Passwords credentials tile.</div>' +
'<div class="matrix-form-actions">' +
'<button class="matrix-form-back" id="sys-chpw-back-btn">← Back</button>' +
'<button class="matrix-form-submit" id="sys-chpw-submit-btn">Change Password</button>' +
@@ -623,18 +637,9 @@ function openSystemChangePasswordModal(unit, name, icon) {
body: JSON.stringify({ new_password: newPassword, confirm_password: confirmPassword })
});
resultEl.className = "matrix-form-result success";
resultEl.textContent = "✅ System password changed successfully.";
resultEl.textContent = "✅ Free account & Hub login password changed successfully.";
submitBtn.textContent = "Change Password";
submitBtn.disabled = false;
// Hide the legacy security banner if it's visible — but only for
// "legacy" status machines. For "unsealed" machines, changing passwords
// is not enough; the factory residue remains until a proper re-seal or re-install.
if (typeof _securityIsLegacy !== "undefined" && _securityIsLegacy &&
(typeof _securityStatus === "undefined" || _securityStatus !== "unsealed")) {
_securityIsLegacy = false;
var banner = document.querySelector(".security-inline-banner");
if (banner) banner.remove();
}
} catch (err) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "❌ " + (err.message || "Failed to change password.");
+13 -4
View File
@@ -49,6 +49,9 @@ const $btnSave = document.getElementById("btn-save-report");
const $btnCloseModal = document.getElementById("btn-close-modal");
const $rebootOverlay = document.getElementById("reboot-overlay");
const $rebootMainCard = document.getElementById("reboot-main-card");
const $rebootErrorCard = document.getElementById("reboot-error-card");
const $rebootSubmessage = document.getElementById("reboot-submessage");
const $credsModal = document.getElementById("creds-modal");
const $credsTitle = document.getElementById("creds-modal-title");
@@ -59,6 +62,8 @@ const $supportModal = document.getElementById("support-modal");
const $supportBody = document.getElementById("support-body");
const $supportCloseBtn = document.getElementById("support-close-btn");
const $logoutBtn = document.getElementById("btn-logout");
// Feature Manager — rebuild modal
const $rebuildModal = document.getElementById("rebuild-modal");
const $rebuildSpinner = document.getElementById("rebuild-spinner");
@@ -99,10 +104,14 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
// Legacy security warning state (populated by checkLegacySecurity in security.js)
var _securityIsLegacy = false;
var _securityStatus = "ok"; // "ok", "legacy", or "unsealed"
var _securityWarningMessage = "";
// Restart confirm dialog
const $restartConfirmModal = document.getElementById("restart-confirm-modal");
const $restartConfirmOk = document.getElementById("restart-confirm-ok-btn");
const $restartConfirmCancel = document.getElementById("restart-confirm-cancel-btn");
const $restartConflictBox = document.getElementById("restart-conflict-box");
// Header reboot button
const $headerRebootBtn = document.getElementById("btn-header-reboot");
// System status banner
// (removed — health is now shown per-tile via the composite health field)
+17 -8
View File
@@ -491,8 +491,9 @@ function renderBackupReady(drives) {
'<div class="support-steps-title">Requirements</div>',
'<ol class="support-backup-steps">',
'<li>USB hard drive plugged into one of the open USB ports on your Sovran Pro</li>',
'<li>At least 500 GB of free space on the drive</li>',
'<li>Drive must be formatted as <strong>exFAT</strong></li>',
'<li>Enough free space for your data (the backup checks this before starting)</li>',
'<li>Drive must be formatted as <strong>ext4</strong> (a Linux filesystem). Drives with exFAT, FAT32, or NTFS are not supported. To format a drive as ext4, use a Linux tool such as GParted or <code>mkfs.ext4</code> — note that formatting erases all data on the drive.</li>',
'<li>The drive is intended for Linux/Sovran recovery. It may not be directly readable by Windows or macOS without additional software.</li>',
'</ol>',
'</div>',
@@ -500,19 +501,26 @@ function renderBackupReady(drives) {
'<div class="support-steps-title">What gets backed up</div>',
'<ol class="support-backup-steps">',
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
'<li>Bitcoin &amp; Lightning wallet data (<code>/var/lib/lnd</code>)</li>',
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
'<li>Domain configurations (<code>/var/lib/domains</code>)</li>',
'<li>System service data (<code>/var/lib</code>) — excluding databases and blockchain data (see note below)</li>',
'<li>Home directory (<code>/home</code>)</li>',
'</ol>',
'</div>',
'<div class="support-wallet-box support-wallet-warning">',
'<div class="support-wallet-header">',
'<span class="support-wallet-icon">\u2139\ufe0f</span>',
'<span class="support-wallet-title">Database and Blockchain Data</span>',
'</div>',
'<p class="support-wallet-desc">Application databases stored in PostgreSQL or MariaDB/MySQL are <strong>not included</strong> in Manual Backup. Bitcoin blockchain and Electrs index data are also excluded (they are stored on the internal second drive). If you use Nextcloud, Matrix, or other database-backed applications, back up those databases separately with their native tools.</p>',
'</div>',
'<div class="support-wallet-box support-wallet-protected">',
'<div class="support-wallet-header">',
'<span class="support-wallet-icon">\u23f1\ufe0f</span>',
'<span class="support-wallet-title">Time Estimate</span>',
'</div>',
'<p class="support-wallet-desc">This backup can take <strong>up to 4 hours</strong> depending on the amount of data stored on your Sovran Pro and the speed of your external hard drive. Be patient\u2026</p>',
'<p class="support-wallet-desc">The first backup may take a while depending on how much data you have. Later backups are much faster because only changed or new files are copied. Files are stored directly on the drive and can be browsed without any special software.</p>',
'</div>',
driveSelector,
@@ -585,9 +593,10 @@ async function pollBackupStatus() {
logDiv.scrollTop = logDiv.scrollHeight;
}
_backupLogOffset = data.offset;
if (!data.running) {
const result = (data.result || "").toLowerCase();
if (result === "success" || result === "failed") {
stopBackupPoll();
renderBackupDone(data.result === "success");
renderBackupDone(result === "success");
}
} catch (_) {}
}
@@ -619,7 +628,7 @@ function renderBackupDone(success) {
'<div class="support-section">',
'<div class="support-icon-big">\u26a0\ufe0f</div>',
'<h3 class="support-heading">Backup Failed</h3>',
'<p class="support-desc">The backup did not complete successfully. Please check that the USB drive is still connected, has enough free space, and is formatted as exFAT. Then try again.</p>',
'<p class="support-desc">The backup did not complete successfully. Please check that the USB drive is still connected, has enough free space, and is formatted as ext4. Then try again.</p>',
'<div class="modal-log" id="backup-log-fail" style="text-align:left;"></div>',
'<button class="btn support-btn-done" id="btn-backup-close">Close</button>',
'</div>',
+49 -6
View File
@@ -4,6 +4,21 @@
// Keyed by tileId: { progress: float, timestamp: ms }
var _btcSyncPrev = {};
// ── BIP-110 badge helper ──────────────────────────────────────────
function _renderBip110Badge(bip110) {
if (!bip110) return '';
var state = bip110.state || 'unknown';
var cfg = BIP110_BADGE_CONFIG[state] || BIP110_BADGE_CONFIG.unknown;
return '<div class="tile-bip110-badge ' + cfg.cls + '" title="' + escHtml(cfg.title) + '">' + escHtml(cfg.label) + '</div>';
}
function _firstElementFromHtml(html) {
var tmp = document.createElement("div");
tmp.innerHTML = html;
return tmp.firstElementChild || null;
}
// ── Render: initial build ─────────────────────────────────────────
function buildTiles(services, categoryLabels) {
@@ -89,10 +104,22 @@ function renderSidebarSupport(supportServices) {
backupBtn.addEventListener("click", function() { openBackupModal(); });
$sidebarSupport.appendChild(backupBtn);
// ── Security button
var securityBtn = document.createElement("button");
securityBtn.className = "sidebar-support-btn";
securityBtn.innerHTML =
'<span class="sidebar-support-icon">\uD83D\uDEE1</span>' +
'<span class="sidebar-support-text">' +
'<span class="sidebar-support-title">Security</span>' +
'<span class="sidebar-support-hint">Reset &amp; verify system</span>' +
'</span>';
securityBtn.addEventListener("click", function() { openSecurityModal(); });
$sidebarSupport.appendChild(securityBtn);
// ── Upgrade button (Node role only)
if (_currentRole === "node") {
var upgradeBtn = document.createElement("button");
upgradeBtn.className = "sidebar-support-btn sidebar-upgrade-btn";
upgradeBtn.className = "sidebar-support-btn";
upgradeBtn.innerHTML =
'<span class="sidebar-support-icon">🚀</span>' +
'<span class="sidebar-support-text">' +
@@ -118,8 +145,6 @@ function buildTile(svc) {
tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : "");
tile.dataset.unit = svc.unit;
tile.dataset.tileId = tileId(svc);
if (dis) tile.title = svc.name + " is not enabled in custom.nix";
if (isSupport) {
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="support-status-label">Click for help</span></div>';
tile.style.cursor = "pointer";
@@ -127,7 +152,7 @@ function buildTile(svc) {
return tile;
}
if (svc.sync_ibd) {
if (svc.sync_ibd && svc.enabled) {
var pct = Math.round((svc.sync_progress || 0) * 100);
var id = tileId(svc);
var eta = _calcBtcEta(id, svc.sync_progress || 0);
@@ -155,7 +180,8 @@ function buildTile(svc) {
var ver = svc.version || svc.bitcoin_version || '';
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
var bip110Badge = (svc.icon === 'bip110') ? _renderBip110Badge(svc.bip110) : '';
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
tile.style.cursor = "pointer";
tile.addEventListener("click", function() {
@@ -193,7 +219,7 @@ function updateTiles(services) {
var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]');
if (!tile) continue;
if (svc.sync_ibd) {
if (svc.sync_ibd && svc.enabled) {
// If tile was previously normal, rebuild it with the sync layout
if (!tile.querySelector(".tile-sync-container")) {
var newTile = buildTile(svc);
@@ -255,6 +281,23 @@ function updateTiles(services) {
}
}
}
// Update BIP-110 badge for bip110 tiles
if (svc.icon === 'bip110') {
var badgeHtml = _renderBip110Badge(svc.bip110);
var badgeEl = tile.querySelector(".tile-bip110-badge");
if (badgeEl) {
// Replace existing badge in-place
var newBadge = _firstElementFromHtml(badgeHtml);
if (newBadge) { badgeEl.replaceWith(newBadge); } else { badgeEl.remove(); }
} else if (badgeHtml) {
// Insert badge after version label (or after tile-name if no version)
var anchorEl = tile.querySelector(".tile-version") || tile.querySelector(".tile-name");
if (anchorEl) {
var newBadgeEl = _firstElementFromHtml(badgeHtml);
if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl);
}
}
}
}
}
}
+145 -11
View File
@@ -3,6 +3,31 @@
// ── Update modal ──────────────────────────────────────────────────
function openUpdateModal() {
if (!$modal) return;
apiFetch("/api/updates/check")
.then(function(data) {
if (!data.available) {
stopUpdatePoll();
_updateLog = "";
_updateLogOffset = 0;
_updateFinished = true;
if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false;
$modal.classList.add("open");
return;
}
_doOpenUpdateModal();
})
.catch(function() {
_doOpenUpdateModal();
});
}
function _doOpenUpdateModal() {
if (!$modal) return;
_updateLog = "";
_updateLogOffset = 0;
@@ -37,6 +62,15 @@ function startUpdate() {
return response.json();
})
.then(function(data) {
if (data.status === "no_updates") {
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false;
_updateFinished = true;
return;
}
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
if ($modalStatus) $modalStatus.textContent = "Updating…";
startUpdatePoll();
@@ -60,25 +94,68 @@ async function pollUpdateStatus() {
if (_updateFinished) return;
try {
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
if (_serverWasDown) { _serverWasDown = false; appendLog("[Server reconnected]\n"); if ($modalStatus) $modalStatus.textContent = "Updating…"; }
if (_serverWasDown) {
_serverWasDown = false;
if (!data.running) {
// The update finished while the server was restarting. Reset to
// offset 0 and re-fetch so the complete log is shown from the top.
_updateLog = "";
_updateLogOffset = 0;
if ($modalLog) $modalLog.textContent = "";
try {
var fullData = await apiFetch("/api/updates/status?offset=0");
if (fullData.log) appendLog(fullData.log);
_updateLogOffset = fullData.offset;
} catch (e) {
// If the re-fetch fails, fall through with whatever we have.
if (data.log) appendLog(data.log);
_updateLogOffset = data.offset;
}
if (data.result === "reboot_required") {
appendLog("[Server restarted — update completed, reboot required.]\n");
} else if (data.result === "success") {
appendLog("[Server restarted — update completed successfully.]\n");
} else {
appendLog("[Server restarted — update encountered an error.]\n");
}
_updateFinished = true;
stopUpdatePoll();
if (data.result === "reboot_required") {
onUpdateDone("reboot_required");
} else {
onUpdateDone(data.result === "success");
}
return;
}
appendLog("[Server reconnected]\n");
if ($modalStatus) $modalStatus.textContent = "Updating…";
}
if (data.log) appendLog(data.log);
_updateLogOffset = data.offset;
if (data.running) return;
_updateFinished = true;
stopUpdatePoll();
if (data.result === "success") onUpdateDone(true);
else onUpdateDone(false);
if (data.result === "reboot_required") {
onUpdateDone("reboot_required");
} else if (data.result === "success") {
onUpdateDone(true);
} else {
onUpdateDone(false);
}
} catch (err) {
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
}
}
function onUpdateDone(success) {
function onUpdateDone(result) {
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnCloseModal) $btnCloseModal.disabled = false;
if (success) {
if (result === true) {
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else if (result === "reboot_required") {
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — restart required";
if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else {
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
if ($btnSave) $btnSave.style.display = "inline-flex";
@@ -100,21 +177,78 @@ function saveErrorReport() {
// ── Reboot ────────────────────────────────────────────────────────
var _rebootStartTime = 0;
var _serverWentDown = false;
var _rebootFailed = false;
function _setRebootStatus(msg) {
if ($rebootSubmessage) $rebootSubmessage.textContent = msg;
}
function doReboot() {
if ($modal) $modal.classList.remove("open");
if ($rebuildModal) $rebuildModal.classList.remove("open");
stopUpdatePoll();
stopRebuildPoll();
// Reset overlay to main card
if ($rebootMainCard) $rebootMainCard.style.display = "";
if ($rebootErrorCard) $rebootErrorCard.style.display = "none";
_setRebootStatus("Sending restart request\u2026");
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
fetch("/api/reboot", { method: "POST" }).catch(function() {});
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
_rebootStartTime = Date.now();
_serverWentDown = false;
_rebootFailed = false;
var rebootCtrl = new AbortController();
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal })
.then(function(res) {
if (!res.ok) {
// Definitive HTTP error — server rejected the request before going down
_rebootFailed = true;
if ($rebootMainCard) $rebootMainCard.style.display = "none";
if ($rebootErrorCard) $rebootErrorCard.style.display = "";
// Leave overlay visible so the error card is shown
}
// HTTP 2xx: request accepted, proceed with polling
})
.catch(function() {
// Connection dropped or request aborted — the server is likely already going
// down as part of the restart. Treat as success and continue polling.
});
// Wait before the first check — NixOS shutdown after an update can take 20-40s
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
}
function waitForServerReboot() {
fetch("/api/config", { cache: "no-store" })
if (_rebootFailed) return;
// Update status on first check (server hasn't gone down yet)
if (!_serverWentDown) _setRebootStatus("Waiting for the computer to shut down\u2026");
var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, REBOOT_FETCH_TIMEOUT);
fetch("/api/ping", { cache: "no-store", signal: controller.signal, headers: { "Connection": "close" } })
.then(function(res) {
if (res.ok) window.location.reload();
else setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
clearTimeout(timeoutId);
if (_serverWentDown) {
// Server is responding after having been down — reboot is complete.
// Any response (even 401/500) means the server process is back.
_setRebootStatus("System is back online. Reconnecting\u2026");
window.location.reload();
} else if ((Date.now() - _rebootStartTime) < 90000) {
// Server still responding but hasn't gone down yet — keep waiting
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
} else {
// Been over 90 seconds and server is responding — just reload
_setRebootStatus("System is back online. Reconnecting\u2026");
window.location.reload();
}
})
.catch(function() { setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL); });
.catch(function() {
clearTimeout(timeoutId);
if (!_serverWentDown) {
_serverWentDown = true;
_setRebootStatus("The computer is restarting\u2026");
}
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
});
}
+307 -128
View File
@@ -7,7 +7,7 @@
const TOTAL_STEPS = 5;
// Steps to skip per role (steps 3 and 4 involve domain/port setup)
// Step 2 (password) is NEVER skipped — all roles need it.
// Step 2 (timezone/locale) is NEVER skipped — all roles need it.
const ROLE_SKIP_STEPS = {
"desktop": [3, 4],
"node": [3, 4],
@@ -17,9 +17,6 @@ const ROLE_SKIP_STEPS = {
var _onboardingRole = "server_plus_desktop";
// Password default state (loaded at step 2)
var _passwordIsDefault = true;
// Domains that may need configuration, with service unit mapping for enabled check
const DOMAIN_DEFS = [
{ name: "matrix", label: "Matrix (Synapse)", unit: "matrix-synapse.service", needsDdns: true },
@@ -36,6 +33,7 @@ const DOMAIN_DEFS = [
var _currentStep = 1;
var _servicesData = null;
var _domainsData = null;
var _migrationOccurred = false;
// ── Helpers ───────────────────────────────────────────────────────
@@ -68,6 +66,48 @@ function setStatus(elId, msg, type) {
el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : "");
}
function updateStep5Checklist() {
var checklist = document.getElementById("onboarding-checklist");
if (!checklist) return;
var existing = document.getElementById("onboarding-migration-check");
if (_migrationOccurred) {
if (!existing) {
var li = document.createElement("li");
li.id = "onboarding-migration-check";
li.textContent = "✅ Migration password noted";
checklist.appendChild(li);
}
return;
}
if (existing) existing.remove();
}
function showMigrationStep(password) {
for (var i = 1; i <= TOTAL_STEPS; i++) {
var panel = document.getElementById("step-" + i);
if (panel) panel.style.display = "none";
}
var migrationPanel = document.getElementById("step-migration");
if (migrationPanel) migrationPanel.style.display = "";
var pw = document.getElementById("migration-password-value");
if (pw) pw.textContent = password || "";
var progressBar = document.getElementById("onboarding-progress-bar");
if (progressBar) progressBar.style.display = "none";
var nav = document.getElementById("onboarding-steps-nav");
if (nav) nav.style.display = "none";
}
function showStep1FromMigration() {
var migrationPanel = document.getElementById("step-migration");
if (migrationPanel) migrationPanel.style.display = "none";
var progressBar = document.getElementById("onboarding-progress-bar");
if (progressBar) progressBar.style.display = "";
var nav = document.getElementById("onboarding-steps-nav");
if (nav) nav.style.display = "";
showStep(1);
loadStep1();
}
// ── Progress / step navigation ────────────────────────────────────
function updateProgress(step) {
@@ -125,126 +165,150 @@ async function loadStep1() {
} catch (_) {}
}
// ── Step 2: Create Your Password ─────────────────────────────────
// ── Step 2: Timezone & Locale ─────────────────────────────────────
async function loadStep2() {
var body = document.getElementById("step-2-body");
if (!body) return;
if (!body || body._tzLoaded) return;
body._tzLoaded = true;
var nextBtn = document.getElementById("step-2-next");
body.innerHTML = '<p class="onboarding-loading">Loading timezone data…</p>';
var timezones = [];
var currentTz = null;
var locales = [];
var currentLocale = null;
try {
var result = await apiFetch("/api/security/password-is-default");
_passwordIsDefault = result.is_default !== false;
} catch (_) {
_passwordIsDefault = true;
var results = await Promise.all([
apiFetch("/api/system/timezones"),
apiFetch("/api/system/locales"),
]);
timezones = results[0].timezones || [];
currentTz = results[0].current || null;
locales = results[1].locales || [];
currentLocale = results[1].current || null;
} catch (err) {
body.innerHTML = '<p class="onboarding-error">⚠ Could not load timezone data: ' + escHtml(err.message) + '</p>';
return;
}
if (_passwordIsDefault) {
// Factory-sealed scenario: password must be set before continuing
if (nextBtn) nextBtn.textContent = "Set Password & Continue \u2192";
// Try to auto-detect timezone from browser
var browserTz = null;
try { browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (_) {}
var selectedTz = currentTz || browserTz || "";
body.innerHTML =
'<div class="onboarding-password-group">' +
'<label class="onboarding-domain-label" for="pw-new">New Password</label>' +
'<div class="onboarding-password-input-wrap">' +
'<input class="onboarding-password-input" type="password" id="pw-new" autocomplete="new-password" placeholder="At least 8 characters" />' +
'<button type="button" class="onboarding-password-toggle" data-target="pw-new" aria-label="Show password">👁</button>' +
'</div>' +
'<p class="onboarding-password-hint">Minimum 8 characters</p>' +
'</div>' +
'<div class="onboarding-password-group">' +
'<label class="onboarding-domain-label" for="pw-confirm">Confirm Password</label>' +
'<div class="onboarding-password-input-wrap">' +
'<input class="onboarding-password-input" type="password" id="pw-confirm" autocomplete="new-password" placeholder="Re-enter your password" />' +
'<button type="button" class="onboarding-password-toggle" data-target="pw-confirm" aria-label="Show password">👁</button>' +
'</div>' +
'</div>' +
'<div class="onboarding-password-warning">⚠️ Write this password down — it cannot be recovered.</div>';
var html = '';
// Wire show/hide toggles
body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) {
btn.addEventListener("click", function() {
var inp = document.getElementById(btn.dataset.target);
if (inp) inp.type = (inp.type === "password") ? "text" : "password";
});
// Timezone section
html += '<div class="onboarding-tz-group">';
html += '<label class="onboarding-domain-label" for="tz-search">🕐 Timezone</label>';
html += '<input class="onboarding-tz-search" type="text" id="tz-search" placeholder="Search timezones…" autocomplete="off" value="' + escHtml(selectedTz) + '" />';
html += '<select class="onboarding-tz-select" id="tz-select" size="5">';
timezones.forEach(function(tz) {
var sel = tz === selectedTz ? ' selected' : '';
html += '<option value="' + escHtml(tz) + '"' + sel + '>' + escHtml(tz) + '</option>';
});
html += '</select>';
html += '<p class="onboarding-hint">Current: <span id="tz-current-display">' + escHtml(selectedTz || 'Not set') + '</span></p>';
html += '</div>';
// Locale section
html += '<div class="onboarding-tz-group">';
html += '<label class="onboarding-domain-label" for="locale-select">🌐 Language / Locale</label>';
html += '<select class="onboarding-tz-select onboarding-locale-select" id="locale-select">';
locales.forEach(function(loc) {
var sel = loc === (currentLocale || 'en_US.UTF-8') ? ' selected' : '';
html += '<option value="' + escHtml(loc) + '"' + sel + '>' + escHtml(loc) + '</option>';
});
html += '</select>';
html += '</div>';
body.innerHTML = html;
// Wire timezone search filter
var tzSearch = document.getElementById('tz-search');
var tzSelect = document.getElementById('tz-select');
var tzCurrentDisplay = document.getElementById('tz-current-display');
if (tzSearch && tzSelect) {
// When typing in search box, filter the dropdown options
tzSearch.addEventListener('input', function() {
var q = tzSearch.value.toLowerCase();
var opts = tzSelect.options;
var firstVisible = null;
for (var i = 0; i < opts.length; i++) {
var match = opts[i].value.toLowerCase().indexOf(q) !== -1;
opts[i].style.display = match ? '' : 'none';
if (match && firstVisible === null) firstVisible = i;
}
if (firstVisible !== null) {
tzSelect.selectedIndex = firstVisible;
if (tzCurrentDisplay) tzCurrentDisplay.textContent = tzSelect.options[firstVisible].value;
}
});
} else {
// DIY install scenario: password already set by installer
if (nextBtn) nextBtn.textContent = "Continue \u2192";
body.innerHTML =
'<div class="onboarding-password-success">✅ Your password was already set during installation.</div>' +
'<details class="onboarding-password-optional">' +
'<summary>Change it anyway</summary>' +
'<div style="margin-top:14px;">' +
'<div class="onboarding-password-group">' +
'<label class="onboarding-domain-label" for="pw-new">New Password</label>' +
'<div class="onboarding-password-input-wrap">' +
'<input class="onboarding-password-input" type="password" id="pw-new" autocomplete="new-password" placeholder="At least 8 characters" />' +
'<button type="button" class="onboarding-password-toggle" data-target="pw-new" aria-label="Show password">👁</button>' +
'</div>' +
'<p class="onboarding-password-hint">Minimum 8 characters</p>' +
'</div>' +
'<div class="onboarding-password-group">' +
'<label class="onboarding-domain-label" for="pw-confirm">Confirm Password</label>' +
'<div class="onboarding-password-input-wrap">' +
'<input class="onboarding-password-input" type="password" id="pw-confirm" autocomplete="new-password" placeholder="Re-enter your password" />' +
'<button type="button" class="onboarding-password-toggle" data-target="pw-confirm" aria-label="Show password">👁</button>' +
'</div>' +
'</div>' +
'<div class="onboarding-password-warning">⚠️ Write this password down — it cannot be recovered.</div>' +
'</div>' +
'</details>';
// Wire show/hide toggles
body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) {
btn.addEventListener("click", function() {
var inp = document.getElementById(btn.dataset.target);
if (inp) inp.type = (inp.type === "password") ? "text" : "password";
});
// When selecting from dropdown, update search box
tzSelect.addEventListener('change', function() {
if (tzSelect.value) {
tzSearch.value = tzSelect.value;
if (tzCurrentDisplay) tzCurrentDisplay.textContent = tzSelect.value;
}
});
// Scroll selected option into view
if (tzSelect.selectedIndex >= 0) {
tzSelect.options[tzSelect.selectedIndex].scrollIntoView({ block: 'nearest' });
}
}
}
async function saveStep2() {
var newPw = document.getElementById("pw-new");
var confirmPw = document.getElementById("pw-confirm");
var tzSelect = document.getElementById('tz-select');
var tzSearch = document.getElementById('tz-search');
var localeSelect = document.getElementById('locale-select');
// If no fields visible or both empty and password already set → skip
if (!newPw || !newPw.value.trim()) {
if (!_passwordIsDefault) return true; // already set, no change requested
setStatus("step-2-status", "⚠ Please enter a password.", "error");
// Determine selected timezone: prefer dropdown selection, fall back to search text
var tz = (tzSelect && tzSelect.value) ? tzSelect.value : (tzSearch ? tzSearch.value.trim() : '');
var locale = localeSelect ? localeSelect.value : '';
if (!tz) {
setStatus('step-2-status', '⚠ Please select a timezone.', 'error');
return false;
}
var pw = newPw.value;
var cpw = confirmPw ? confirmPw.value : "";
setStatus('step-2-status', 'Saving…', 'info');
if (pw.length < 8) {
setStatus("step-2-status", "⚠ Password must be at least 8 characters.", "error");
return false;
}
if (pw !== cpw) {
setStatus("step-2-status", "⚠ Passwords do not match.", "error");
return false;
}
var errors = [];
setStatus("step-2-status", "Saving password…", "info");
try {
await apiFetch("/api/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: pw, confirm_password: cpw }),
await apiFetch('/api/system/timezone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timezone: tz }),
});
} catch (err) {
setStatus("step-2-status", "⚠ " + err.message, "error");
errors.push('Timezone: ' + err.message);
}
if (locale) {
try {
await apiFetch('/api/system/locale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale: locale }),
});
} catch (err) {
errors.push('Locale: ' + err.message);
}
}
if (errors.length > 0) {
setStatus('step-2-status', '⚠ ' + errors.join('; '), 'error');
return false;
}
setStatus("step-2-status", "✓ Password saved", "ok");
_passwordIsDefault = false;
setStatus('step-2-status', '✓ Timezone & locale saved', 'ok');
return true;
}
@@ -269,8 +333,6 @@ async function loadStep3() {
return;
}
var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)";
// Build set of enabled service units
var enabledUnits = new Set();
(_servicesData || []).forEach(function(svc) {
@@ -288,26 +350,34 @@ async function loadStep3() {
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
} else {
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;">'
+ '<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>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>'
+ '<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>'
+ '<li>Njal.la will give you a curl command like:<br>'
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&amp;k=abc123&amp;auto"</code></li>'
+ '<li>Enter the subdomain and paste that curl command below for each service</li>'
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a>.</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>For each service, add a <strong>Dynamic</strong> record in Njal.la:'
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
+ '<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>'
+ '(Example only, your choice — for &quot;call.yourdomain.com&quot; you&apos;d type just: <code>call</code>.)<br>'
+ '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>'
+ '</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) {
var currentVal = (_domainsData && _domainsData[d.name]) || "";
html += '<div class="onboarding-domain-group">';
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />';
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 += '<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 += '</div>';
});
}
@@ -318,9 +388,78 @@ async function loadStep3() {
html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>';
html += '<p class="onboarding-hint onboarding-hint--inline">Let\'s Encrypt uses this for certificate expiry notifications.</p>';
html += '<input class="onboarding-domain-input domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" value="' + escHtml(emailVal) + '" />';
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-email="true" 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-email" style="font-size:0.82rem;min-height:1.2em;"></span>';
html += '</div>';
body.innerHTML = html;
// Wire per-field save buttons for domains
body.querySelectorAll('[data-save-domain]').forEach(function(btn) {
btn.addEventListener('click', async function() {
var domainName = btn.dataset.saveDomain;
var domainInput = document.getElementById('domain-input-' + domainName);
var ddnsInput = document.getElementById('ddns-input-' + domainName);
var statusEl = document.getElementById('domain-save-status-' + domainName);
var domainVal = domainInput ? domainInput.value.trim() : '';
var ddnsVal = ddnsInput ? ddnsInput.value.trim() : '';
if (!domainVal) {
if (statusEl) { statusEl.textContent = '⚠ Enter a domain first'; statusEl.style.color = 'var(--red)'; }
return;
}
btn.disabled = true;
btn.textContent = 'Saving…';
if (statusEl) { statusEl.textContent = ''; }
try {
await apiFetch('/api/domains/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain_name: domainName, domain: domainVal, ddns_url: ddnsVal }),
});
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--green)'; }
} catch (err) {
if (statusEl) { statusEl.textContent = '⚠ ' + err.message; statusEl.style.color = 'var(--red)'; }
}
btn.disabled = false;
btn.textContent = 'Save';
});
});
// Wire save button for SSL email
body.querySelectorAll('[data-save-email]').forEach(function(btn) {
btn.addEventListener('click', async function() {
var emailInput = document.getElementById('ssl-email-input');
var statusEl = document.getElementById('domain-save-status-email');
var emailVal = emailInput ? emailInput.value.trim() : '';
if (!emailVal) {
if (statusEl) { statusEl.textContent = '⚠ Enter an email first'; statusEl.style.color = 'var(--red)'; }
return;
}
btn.disabled = true;
btn.textContent = 'Saving…';
if (statusEl) { statusEl.textContent = ''; }
try {
await apiFetch('/api/domains/set-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailVal }),
});
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--green)'; }
} catch (err) {
if (statusEl) { statusEl.textContent = '⚠ ' + err.message; statusEl.style.color = 'var(--red)'; }
}
btn.disabled = false;
btn.textContent = 'Save';
});
});
}
async function saveStep3() {
@@ -377,7 +516,7 @@ async function saveStep3() {
async function loadStep4() {
var body = document.getElementById("step-4-body");
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;
@@ -388,51 +527,59 @@ async function loadStep4() {
return;
}
var internalIp = (networkData && networkData.internal_ip) || "unknown";
var ip = escHtml(internalIp);
var trimmedInternalIp = (networkData && networkData.internal_ip) ? String(networkData.internal_ip).trim() : "";
var internalIp = trimmedInternalIp || "";
var hasInternalIp = !!internalIp;
var ip = escHtml(internalIp || "Could not detect");
var routerIpHelp = hasInternalIp
? "Use this IP address as the destination/internal IP when creating each router forwarding rule."
: "Use this 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;">'
+ '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
+ '</p>';
html += '<div class="onboarding-port-ip">';
html += ' <span class="onboarding-port-ip-label">Forward 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 += '</div>';
html += '<div class="onboarding-port-note" style="margin:8px 0 16px;">' + routerIpHelp + '</div>';
// Required ports table
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required 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 += '<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 += '<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">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">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 += '<tr><td class="port-req-port">22</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Remote SSH access</td></tr>';
html += '</tbody></table>';
html += '</div>';
// Optional ports table
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Optional — Only needed if you enable Element Calling:</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 class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Element Call Router Rules</div>';
html += '<div style="font-size:0.88em;margin-bottom:8px;color:var(--color-text-muted,#888);">Only add these if you enable Element Call. These ports help video and audio calls connect reliably.</div>';
html += '<table class="onboarding-port-table">';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&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 += '<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">78827894</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media streams</td></tr>';
html += '<tr><td class="port-req-port">7882</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media (UDP mux)</td></tr>';
html += '<tr><td class="port-req-port">5349</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN over TLS</td></tr>';
html += '<tr><td class="port-req-port">3478</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN (STUN/relay)</td></tr>';
html += '<tr><td class="port-req-port">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 += '<div style="font-size:0.85em;margin-top:6px;color:var(--color-text-muted,#888);"> The <strong>30000-40000</strong> range is a single forwarding rule — just set its protocol to <strong>both TCP and UDP</strong> (often shown as "Both" or "TCP/UDP" on your router).</div>';
html += '</div>';
// Totals
html += '<div class="onboarding-port-totals">';
html += '<strong>Total port openings: 4</strong> (without Element Calling)<br>';
html += '<strong>Total port openings: 9</strong> (with Element Calling4 required + 5 optional)';
html += '<strong>Total port openings: 3</strong> (without Element Call)<br>';
html += '<strong>Total port openings: 8</strong> (with Element Call — 3 required + 5 optional)';
html += '</div>';
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
@@ -447,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>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>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>Save and apply changes</li>'
+ '</ol>'
+ '</details>';
html += '<div class="onboarding-port-note" style="margin-top:12px;">'
+ '<strong>Important:</strong> The Hub can show which ports Sovran_SystemsOS needs, but it cannot fully confirm router forwarding from inside your home network. Full public port verification requires an outside internet check.'
+ '</div>';
body.innerHTML = html;
}
@@ -474,11 +625,28 @@ async function completeOnboarding() {
// ── Event wiring ──────────────────────────────────────────────────
function wireNavButtons() {
var migrationContinue = document.getElementById("migration-password-continue");
if (migrationContinue) migrationContinue.addEventListener("click", async function() {
migrationContinue.disabled = true;
migrationContinue.textContent = "Continuing…";
setStatus("migration-password-status", "Saving acknowledgement…", "info");
try {
await apiFetch("/api/migration/password-acknowledge", { method: "POST" });
_migrationOccurred = true;
updateStep5Checklist();
showStep1FromMigration();
} catch (err) {
setStatus("migration-password-status", "⚠ " + err.message, "error");
migrationContinue.disabled = false;
migrationContinue.textContent = "I've written it down — Continue →";
}
});
// Step 1 → next
var s1next = document.getElementById("step-1-next");
if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); });
// Step 2 → 3 (save password first)
// Step 2 → 3 (save timezone/locale first)
var s2next = document.getElementById("step-2-next");
if (s2next) s2next.addEventListener("click", async function() {
s2next.disabled = true;
@@ -501,7 +669,7 @@ function wireNavButtons() {
showStep(nextStep(3));
});
// Step 4 → 5 (Complete)
// Step 4 → 5 (port forwarding — no save needed)
var s4next = document.getElementById("step-4-next");
if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); });
@@ -535,6 +703,17 @@ document.addEventListener("DOMContentLoaded", async function() {
} catch (_) {}
wireNavButtons();
updateProgress(1);
try {
var migration = await apiFetch("/api/migration/password-status");
if (migration && migration.pending) {
updateStep5Checklist();
showMigrationStep(migration.password || "");
return;
}
} catch (_) {}
updateStep5Checklist();
showStep(1);
loadStep1();
});
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+106 -35
View File
@@ -4,26 +4,28 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran_SystemsOS Hub</title>
<link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/buttons.css" />
<link rel="stylesheet" href="/static/css/header.css" />
<link rel="stylesheet" href="/static/css/layout.css" />
<link rel="stylesheet" href="/static/css/tiles.css" />
<link rel="stylesheet" href="/static/css/modals.css" />
<link rel="stylesheet" href="/static/css/features.css" />
<link rel="stylesheet" href="/static/css/onboarding.css" />
<link rel="stylesheet" href="/static/css/support.css" />
<link rel="stylesheet" href="/static/css/domain-setup.css" />
<link rel="stylesheet" href="/static/css/security.css" />
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/layout.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/tiles.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/modals.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/features.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/onboarding.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/support.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/domain-setup.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/security.css?v={{ asset_version }}" />
</head>
<body>
<!-- Header bar -->
<header class="header-bar">
<img src="/static/logo-light.svg" alt="Sovran Systems" class="header-logo" />
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
<span class="title">Sovran_SystemsOS Hub</span>
<div class="header-buttons">
<span class="role-badge" id="role-badge">Loading…</span>
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
</div>
</header>
@@ -60,7 +62,7 @@
<div class="modal-log" id="modal-log" aria-live="polite"></div>
<div class="modal-footer">
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Reboot</button>
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
</div>
</div>
@@ -163,7 +165,7 @@
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
<div class="modal-footer">
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Reboot</button>
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
</div>
</div>
@@ -184,7 +186,7 @@
<div class="upgrade-info-box">
<p class="upgrade-info-title">⚠ What you should know:</p>
<ul class="upgrade-info-list">
<li>You will need to purchase domains for your services (we recommend <a href="https://njal.la" target="_blank" rel="noopener noreferrer">njal.la</a>)</li>
<li>You will need to purchase domains for your services <a href="https://njal.la" target="_blank" rel="noopener noreferrer">Njal.la</a> is the only supported domain provider</li>
<li>Some services require ports to be opened on your router</li>
</ul>
</div>
@@ -210,34 +212,103 @@
</div>
</div>
<!-- Reboot overlay -->
<div class="reboot-overlay" id="reboot-overlay">
<div class="reboot-card">
<div class="reboot-icon"></div>
<h2 class="reboot-title">System Rebooting</h2>
<!-- Security Reset overlay -->
<div class="security-reset-overlay" id="security-reset-overlay">
<!-- Phase 1: wiping in progress -->
<div class="reboot-card" id="security-reset-phase1">
<div class="security-reset-overlay-icon">🛡</div>
<h2 class="reboot-title">Security Reset In Progress</h2>
<p class="reboot-message">
Sovran_SystemsOS is now restarting.<br />
This page will automatically reconnect once the system is back online.
⚠️ Wiping all data and credentials.<br />
<strong>Do not power off your computer.</strong><br />
This may take several minutes.
</p>
<div class="reboot-dots">
<span class="reboot-dot"></span>
<span class="reboot-dot"></span>
<span class="reboot-dot"></span>
</div>
<p class="reboot-submessage">Stay tuned</p>
<p class="reboot-submessage" id="security-reset-overlay-step">Erasing data and resetting credentials</p>
</div>
<!-- Phase 2: password display -->
<div class="reboot-card" id="security-reset-phase2" style="display:none;">
<div class="security-reset-overlay-icon">🔑</div>
<h2 class="reboot-title">Security Reset Complete</h2>
<p class="security-reset-password-label">Your new login password is:</p>
<div class="security-reset-password-box" id="security-reset-new-password">&nbsp;</div>
<p class="security-reset-password-warning">
✍️ <strong>Write this down now.</strong><br />
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>.
</p>
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
I have written down my new password — Restart Entire System
</button>
</div>
</div>
<script src="/static/js/constants.js"></script>
<script src="/static/js/state.js"></script>
<script src="/static/js/helpers.js"></script>
<script src="/static/js/tiles.js"></script>
<script src="/static/js/service-detail.js"></script>
<script src="/static/js/support.js"></script>
<script src="/static/js/update.js"></script>
<script src="/static/js/rebuild.js"></script>
<script src="/static/js/features.js"></script>
<script src="/static/js/security.js"></script>
<script src="/static/js/events.js"></script>
<!-- Reboot overlay -->
<div class="reboot-overlay" id="reboot-overlay">
<!-- Normal restarting card -->
<div class="reboot-card" id="reboot-main-card">
<div class="reboot-icon" aria-hidden="true"></div>
<h2 class="reboot-title">Restarting Entire System</h2>
<p class="reboot-message">
The entire computer is restarting, including the desktop and all hosted services.<br />
This page will reconnect automatically when Sovran_SystemsOS is back online.
</p>
<div class="reboot-dots" aria-hidden="true">
<span class="reboot-dot"></span>
<span class="reboot-dot"></span>
<span class="reboot-dot"></span>
</div>
<p class="reboot-submessage" id="reboot-submessage" aria-live="polite">Sending restart request…</p>
</div>
<!-- Error card (shown if restart request fails definitively) -->
<div class="reboot-card" id="reboot-error-card" style="display:none">
<div class="reboot-icon" aria-hidden="true"></div>
<h2 class="reboot-title">Restart could not be started</h2>
<p class="reboot-message">
The computer did not begin restarting. No services were intentionally stopped. Please try again.
</p>
<div class="reboot-error-actions">
<button class="btn btn-close-modal" id="reboot-error-close-btn">Close</button>
<button class="btn btn-restart-amber" id="reboot-error-retry-btn">Try Again</button>
</div>
</div>
</div>
<!-- Restart Confirm Dialog -->
<div class="modal-overlay" id="restart-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="restart-confirm-title">
<div class="creds-dialog domain-narrow-dialog">
<div class="creds-header">
<span class="creds-title" id="restart-confirm-title">Restart the entire computer?</span>
</div>
<div class="creds-body">
<div id="restart-conflict-box" class="restart-conflict-box" style="display:none">
<p class="restart-conflict-title">The system cannot restart right now.</p>
<p class="restart-conflict-desc">A system update, rebuild, backup, restore, or security operation is currently running. Wait for it to finish, then try again.</p>
</div>
<p class="support-desc"><strong>This will reboot the physical machine running Sovran_SystemsOS — not just the Hub.</strong></p>
<p class="support-desc">The desktop and all hosted services will stop temporarily and restart with the computer. Anyone currently using these services will be disconnected.</p>
<p class="support-desc">The system usually returns within 13 minutes. This page will reconnect automatically.</p>
<div class="domain-field-actions">
<button class="btn btn-close-modal" id="restart-confirm-cancel-btn">Cancel</button>
<button class="btn btn-restart-amber" id="restart-confirm-ok-btn">Restart Entire System</button>
</div>
</div>
</div>
</div>
<script src="/static/js/constants.js?v={{ asset_version }}"></script>
<script src="/static/js/state.js?v={{ asset_version }}"></script>
<script src="/static/js/helpers.js?v={{ asset_version }}"></script>
<script src="/static/js/tiles.js?v={{ asset_version }}"></script>
<script src="/static/js/service-detail.js?v={{ asset_version }}"></script>
<script src="/static/js/support.js?v={{ asset_version }}"></script>
<script src="/static/js/update.js?v={{ asset_version }}"></script>
<script src="/static/js/rebuild.js?v={{ asset_version }}"></script>
<script src="/static/js/features.js?v={{ asset_version }}"></script>
<script src="/static/js/security.js?v={{ asset_version }}"></script>
<script src="/static/js/events.js?v={{ asset_version }}"></script>
</body>
</html>
</html>
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran Hub — Login</title>
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
</head>
<body>
<div class="login-wrapper">
<div class="login-card">
<div class="login-header">
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="login-logo" />
<div class="login-title">Sovran Hub</div>
</div>
<form class="login-form" id="login-form" onsubmit="return false;">
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
autocomplete="current-password"
autofocus
placeholder="Enter your Hub password"
/>
</div>
<div class="login-error" id="login-error">Incorrect password. Please try again.</div>
<button type="submit" class="btn btn-login" id="btn-login">Sign In</button>
</form>
</div>
</div>
<script>
(function () {
var form = document.getElementById('login-form');
var input = document.getElementById('password');
var errEl = document.getElementById('login-error');
var btnEl = document.getElementById('btn-login');
form.addEventListener('submit', function () {
var password = input.value;
if (!password) return;
btnEl.disabled = true;
btnEl.textContent = 'Signing in…';
errEl.classList.remove('visible');
fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: password }),
credentials: 'same-origin',
})
.then(function (res) {
if (res.ok) {
window.location.replace('/');
} else {
return res.json().then(function (data) {
errEl.textContent = (data && data.detail) ? data.detail : 'Incorrect password. Please try again.';
errEl.classList.add('visible');
input.value = '';
input.focus();
btnEl.disabled = false;
btnEl.textContent = 'Sign In';
});
}
})
.catch(function () {
errEl.textContent = 'Network error. Please try again.';
errEl.classList.add('visible');
btnEl.disabled = false;
btnEl.textContent = 'Sign In';
});
});
})();
</script>
</body>
</html>
@@ -21,7 +21,7 @@
<div class="onboarding-shell">
<!-- Progress bar -->
<div class="onboarding-progress-bar">
<div class="onboarding-progress-bar" id="onboarding-progress-bar">
<div class="onboarding-progress-fill" id="onboarding-progress-fill"></div>
</div>
@@ -41,6 +41,33 @@
<!-- Step panels -->
<div class="onboarding-panel-wrap">
<!-- ── Migration Password Gate (pre-step) ── -->
<div class="onboarding-panel" id="step-migration" style="display:none">
<div class="onboarding-hero">
<div class="onboarding-logo">🔐</div>
<h1 class="onboarding-title">Your system has been migrated to Sovran_SystemsOS</h1>
<p class="onboarding-subtitle">Important password update required</p>
</div>
<div class="onboarding-card">
<p class="onboarding-body-text" style="text-align:center; margin-bottom:4px;">
Your new login password is:
</p>
<div id="migration-password-value" style="font-family:monospace; font-size:1.35rem; font-weight:700; color:var(--text-primary); background:rgba(109, 191, 139, 0.10); border:1.5px solid rgba(109, 191, 139, 0.35); border-radius:8px; padding:14px 24px; letter-spacing:0.04em; text-align:center; word-break:break-all; margin-bottom:8px;">
&nbsp;
</div>
<div style="padding:10px 14px; background-color:rgba(229, 165, 10, 0.1); border:1px solid rgba(229, 165, 10, 0.35); border-radius:8px; font-size:0.92rem; color:var(--yellow); line-height:1.55;">
⚠ Write this password down! You will need it to log in next time. This is also your Sovran Hub login password.
</div>
<div id="migration-password-status" class="onboarding-save-status" style="margin-top:8px;"></div>
</div>
<div class="onboarding-footer">
<div></div>
<button class="btn btn-primary" id="migration-password-continue">
I've written it down — Continue →
</button>
</div>
</div>
<!-- ── Step 1: Welcome ── -->
<div class="onboarding-panel" id="step-1">
<div class="onboarding-hero">
@@ -72,23 +99,24 @@
</div>
</div>
<!-- ── Step 2: Create Your Password ── -->
<!-- ── Step 2: Timezone &amp; Locale ── -->
<div class="onboarding-panel" id="step-2" style="display:none">
<div class="onboarding-step-header">
<span class="onboarding-step-icon">🔒</span>
<h2 class="onboarding-step-title">Create Your Password</h2>
<span class="onboarding-step-icon">🌍</span>
<h2 class="onboarding-step-title">Timezone &amp; Locale</h2>
<p class="onboarding-step-desc">
Choose a strong password for your <strong>'free'</strong> user account. This will be your login password for the desktop, SSH, and the Hub.
Select your timezone and preferred language so your system clock, logs,
and services display the correct time and format.
</p>
</div>
<div class="onboarding-card" id="step-2-body">
<p class="onboarding-loading">Checking password status</p>
<p class="onboarding-loading">Loading timezone data</p>
</div>
<div id="step-2-status" class="onboarding-save-status"></div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="1">← Back</button>
<button class="btn btn-primary onboarding-btn-next" id="step-2-next">
Set Password &amp; Continue →
Save &amp; Continue →
</button>
</div>
</div>
@@ -100,12 +128,11 @@
<h2 class="onboarding-step-title">Domain Configuration</h2>
<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.
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.
Then, in the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine's external IP address (shown below).
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
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.
Paste the curl command from your Njal.la dashboard for each service.
</p>
</div>
<div class="onboarding-card onboarding-card--scroll" id="step-3-body">
<div class="onboarding-card" id="step-3-body">
<p class="onboarding-loading">Loading service information…</p>
</div>
<div id="step-3-status" class="onboarding-save-status"></div>
@@ -121,14 +148,14 @@
<div class="onboarding-panel" id="step-4" style="display:none">
<div class="onboarding-step-header">
<span class="onboarding-step-icon">🔌</span>
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
<h2 class="onboarding-step-title">Router Setup</h2>
<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.
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
Forward these ports in your router to this Sovran_SystemsOS computer. These rules let people reach your services from outside your home network.
<strong>Ports 80 and 443 are required for HTTPS and SSL certificates.</strong>
</p>
</div>
<div class="onboarding-card onboarding-card--ports" id="step-4-body">
<p class="onboarding-loading">Checking ports</p>
<div class="onboarding-card" id="step-4-body">
<p class="onboarding-loading">Loading router setup</p>
</div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
@@ -151,7 +178,7 @@
monitor your services, manage credentials, and make changes at any time.
</p>
<ul class="onboarding-checklist" id="onboarding-checklist">
<li>Password configured</li>
<li>Timezone &amp; locale configured</li>
<li>✅ Domain configuration saved</li>
<li>✅ Port forwarding reviewed</li>
</ul>
@@ -169,4 +196,4 @@
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
</body>
</html>
</html>
+166
View File
@@ -0,0 +1,166 @@
import unittest
from unittest.mock import patch
from pathlib import Path
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 Bip110StatusTests(unittest.TestCase):
def _status(self, deploy_info, net_info):
with patch.object(server, "_get_bitcoin_deployment_info", return_value=deploy_info), patch.object(
server, "_get_bitcoin_version_info", return_value=net_info
):
return server._get_bip110_status()
def test_started_reduced_data_reports_signaling(self):
deploy_info = {
"deployments": {
"reduced_data": {
"type": "bip9",
"active": False,
"bip9": {
"bit": 4,
"status": "started",
"statistics": {"elapsed": 833, "count": 4, "threshold": 1109},
"signalling": "--#--",
},
}
}
}
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
self.assertEqual(
result,
{"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"},
)
def test_active_reduced_data_reports_active(self):
deploy_info = {
"deployments": {"reduced_data": {"active": True, "bip9": {"bit": 4, "status": "active"}}}
}
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
self.assertEqual(result["state"], "active")
self.assertTrue(result["supported"])
self.assertTrue(result["signaling"])
self.assertEqual(result["source"], "getdeploymentinfo")
def test_locked_in_reduced_data_reports_locked_in(self):
deploy_info = {
"deployments": {"reduced_data": {"active": False, "bip9": {"bit": 4, "status": "locked_in"}}}
}
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
self.assertEqual(result["state"], "locked_in")
self.assertTrue(result["supported"])
self.assertTrue(result["signaling"])
self.assertEqual(result["source"], "getdeploymentinfo")
def test_no_bip110_deployment_and_plain_subversion_reports_unsupported(self):
deploy_info = {
"deployments": {
"taproot": {"type": "bip9", "active": True, "bip9": {"bit": 2, "status": "active"}},
}
}
result = self._status(deploy_info, {"subversion": "/Satoshi:27.0.0/"})
self.assertEqual(
result,
{"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"},
)
def test_node_unreachable_reports_unknown(self):
result = self._status(None, None)
self.assertEqual(result, {"supported": False, "signaling": False, "state": "unknown", "source": "none"})
if __name__ == "__main__":
unittest.main()
+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()
+193
View File
@@ -0,0 +1,193 @@
"""Structural regression tests for modules/core/local-domain-loopback.nix.
Verifies that the sovran-hosts-update helper:
- is built as a pkgs.writeShellApplication with explicit runtimeInputs
(gawk, gnugrep, coreutils);
- uses ``lib.getExe hostsUpdateScript`` for both the systemd ExecStart and
the activation script so that both contexts share the same Nix-store
executable;
- does NOT point ExecStart at the raw /etc path;
- includes element-calling in the supported domain list;
- uses ``awk -v`` for safe marker-variable passing rather than interpolating
marker text directly into the awk program;
- retains the domain validation regex (idempotency / injection prevention);
- exposes /etc/sovran-hosts-update.sh as a symlink via ``source =`` (not a
second raw ``text =`` body);
- does NOT rely on environment.systemPackages for the helper's dependencies.
"""
import re
import shutil
import subprocess
import unittest
from pathlib import Path
NIX_STRING_INDENT = 6
REPO_ROOT = Path(__file__).resolve().parents[2]
FLAKE_SOURCE = (REPO_ROOT / "flake.nix").read_text()
PRIMARY_NIXOS_CONFIGURATION_MATCH = re.search(
r"nixosConfigurations\.([A-Za-z0-9_-]+)\s*=",
FLAKE_SOURCE,
)
if PRIMARY_NIXOS_CONFIGURATION_MATCH is None:
raise RuntimeError("Could not determine the primary nixosConfigurations entry from flake.nix")
PRIMARY_NIXOS_CONFIGURATION = PRIMARY_NIXOS_CONFIGURATION_MATCH.group(1)
HELPER_BUILD_ATTR = (
f'.#nixosConfigurations.{PRIMARY_NIXOS_CONFIGURATION}.config.environment.etc.'
'"sovran-hosts-update.sh".source'
)
NIX_FILE = (
REPO_ROOT
/ "modules"
/ "core"
/ "local-domain-loopback.nix"
)
class LocalDomainLoopbackNixStructureTests(unittest.TestCase):
def setUp(self):
self.source = NIX_FILE.read_text()
def _helper_script(self) -> str:
start = self.source.index("text = ''") + len("text = ''")
end = self.source.index(" '';", start)
return "\n".join(
line[NIX_STRING_INDENT:]
if line.startswith(" " * NIX_STRING_INDENT)
else line
for line in self.source[start:end].splitlines()
).lstrip("\n")
# ── writeShellApplication and explicit runtimeInputs ────────────────────
def test_uses_write_shell_application(self):
self.assertIn("pkgs.writeShellApplication", self.source)
def test_runtime_inputs_includes_coreutils(self):
self.assertIn("pkgs.coreutils", self.source)
def test_runtime_inputs_includes_gawk(self):
self.assertIn("pkgs.gawk", self.source)
def test_runtime_inputs_includes_gnugrep(self):
self.assertIn("pkgs.gnugrep", self.source)
def test_runtime_inputs_block_present(self):
self.assertIn("runtimeInputs", self.source)
# ── Both execution paths use lib.getExe ─────────────────────────────────
def test_exec_start_uses_lib_get_exe(self):
"""systemd ExecStart must reference the Nix-store executable."""
self.assertIn("ExecStart = lib.getExe hostsUpdateScript", self.source)
def test_activation_script_uses_lib_get_exe(self):
"""Activation text must call the same Nix-store executable."""
self.assertIn("${lib.getExe hostsUpdateScript}", self.source)
def test_exec_start_does_not_point_to_etc_path(self):
"""ExecStart must NOT use the raw /etc path (which lacks a deterministic PATH)."""
self.assertNotIn('ExecStart = "/etc/sovran-hosts-update.sh"', self.source)
# ── /etc symlink uses source =, not a second text = body ────────────────
def test_etc_entry_uses_source_not_text(self):
"""The /etc/sovran-hosts-update.sh entry must be a symlink (source =),
not a second raw script body (text =)."""
self.assertIn(
'environment.etc."sovran-hosts-update.sh".source', self.source
)
def test_etc_source_points_to_get_exe(self):
self.assertIn(
'environment.etc."sovran-hosts-update.sh".source = lib.getExe hostsUpdateScript',
self.source,
)
# ── element-calling domain is supported ─────────────────────────────────
def test_element_calling_domain_key_present(self):
self.assertIn("element-calling", self.source)
# ── Robust awk -v variable passing ──────────────────────────────────────
def test_awk_uses_dash_v_for_begin_marker(self):
"""awk must receive the begin marker via -v, not by shell interpolation."""
self.assertIn('awk -v begin=', self.source)
def test_awk_uses_dash_v_for_end_marker(self):
self.assertIn('-v end=', self.source)
def test_awk_does_not_interpolate_marker_into_program(self):
"""The old pattern interpolated $BEGIN_MARKER directly into the awk source."""
self.assertNotIn('/$BEGIN_MARKER', self.source)
self.assertNotIn('/$END_MARKER', self.source)
# ── Domain validation ────────────────────────────────────────────────────
def test_domain_validation_regex_present(self):
"""The hostname validation regex must still be present for injection prevention."""
self.assertIn("grep -qE", self.source)
self.assertIn("[a-zA-Z0-9]", self.source)
def test_invalid_domain_warning_present(self):
self.assertIn("skipping invalid domain value", self.source)
# ── No environment.systemPackages reliance ───────────────────────────────
def test_no_environment_system_packages_for_helper(self):
"""The helper's tools are declared via runtimeInputs; the module must
not add them to environment.systemPackages."""
self.assertNotIn("environment.systemPackages", self.source)
# ── Idempotency: existing Sovran block is removed before rewriting ───────
def test_existing_block_removal_logic_present(self):
"""awk strip of the managed block must be present for idempotency."""
self.assertIn("skip=1", self.source)
self.assertIn("skip=0", self.source)
def test_managed_block_uses_grouped_append_redirect(self):
self.assertIn('} >> "$TMP"', self.source)
self.assertEqual(self.source.count('>> "$TMP"'), 1)
def test_helper_script_passes_shellcheck(self):
shellcheck = shutil.which("shellcheck")
if shellcheck is None:
self.skipTest("shellcheck is not installed")
proc = subprocess.run(
[shellcheck, "-s", "bash", "-"],
input=self._helper_script(),
text=True,
capture_output=True,
check=False,
)
output = proc.stdout + proc.stderr
self.assertEqual(proc.returncode, 0, output)
def test_helper_derivation_builds_when_nix_available(self):
nix = shutil.which("nix")
if nix is None:
self.skipTest("nix is not installed")
proc = subprocess.run(
[
nix,
"build",
HELPER_BUILD_ATTR,
"--no-link",
],
cwd=REPO_ROOT,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
# ── Activation script warns on failure rather than silently swallowing ───
def test_activation_script_emits_warning_on_failure(self):
self.assertIn("warning: sovran-hosts-update", self.source)
if __name__ == "__main__":
unittest.main()
+399
View File
@@ -0,0 +1,399 @@
"""Tests for server-local loopback diagnostics and domain validation.
Covers:
- Domain value validation and injection prevention.
- Loopback address detection (IPv4 and IPv6).
- _resolve_all_addresses returning multiple addresses.
- _check_domain_health_fast with loopback resolution.
- _evaluate_domain_checklist with loopback override — no false dns_mismatch.
- _evaluate_domain_checklist with genuine DNS mismatch — still reports error.
- api_services health stays "healthy" when domain resolves to loopback.
- api_services health stays "needs_attention" when DNS is genuinely wrong.
- api_domains_check returns "local_override" for loopback-resolved domains.
"""
import unittest
from pathlib import Path
from unittest.mock import MagicMock, mock_open, patch
import sys
import types
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# ---------------------------------------------------------------------------
# Minimal stubs so server.py can be imported without the full FastAPI stack.
# ---------------------------------------------------------------------------
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 # noqa: E402
# ===========================================================================
# Domain value validation
# ===========================================================================
class TestValidateDomainValue(unittest.TestCase):
"""_validate_domain_value must reject anything that could corrupt /etc/hosts."""
def _v(self, value: str) -> bool:
return server._validate_domain_value(value)
# -- Valid values --------------------------------------------------------
def test_simple_domain_valid(self):
self.assertTrue(self._v("cloud.example.com"))
def test_subdomain_valid(self):
self.assertTrue(self._v("matrix.home.example.org"))
def test_single_label_with_tld_valid(self):
self.assertTrue(self._v("example.com"))
def test_hyphen_in_domain_valid(self):
self.assertTrue(self._v("my-nextcloud.example.com"))
# -- Injection / malformed values ----------------------------------------
def test_empty_string_invalid(self):
self.assertFalse(self._v(""))
def test_newline_injection_invalid(self):
self.assertFalse(self._v("evil.com\n127.0.0.1 other.host"))
def test_carriage_return_injection_invalid(self):
self.assertFalse(self._v("evil.com\r127.0.0.1 other.host"))
def test_space_injection_invalid(self):
self.assertFalse(self._v("evil.com 127.0.0.1"))
def test_hash_comment_injection_invalid(self):
self.assertFalse(self._v("evil.com# comment"))
def test_bare_hostname_no_dot_invalid(self):
self.assertFalse(self._v("localhost"))
def test_bare_ip_invalid(self):
self.assertFalse(self._v("192.168.1.1"))
def test_too_long_invalid(self):
self.assertFalse(self._v("a" * 254 + ".com"))
def test_leading_dot_invalid(self):
self.assertFalse(self._v(".example.com"))
def test_trailing_dot_invalid(self):
self.assertFalse(self._v("example.com."))
# ===========================================================================
# Loopback address detection
# ===========================================================================
class TestIsLoopbackAddress(unittest.TestCase):
def test_ipv4_loopback(self):
self.assertTrue(server._is_loopback_address("127.0.0.1"))
def test_ipv4_loopback_other(self):
self.assertTrue(server._is_loopback_address("127.0.0.2"))
def test_ipv4_loopback_high(self):
self.assertTrue(server._is_loopback_address("127.255.255.255"))
def test_ipv6_loopback(self):
self.assertTrue(server._is_loopback_address("::1"))
def test_public_ipv4_not_loopback(self):
self.assertFalse(server._is_loopback_address("203.0.113.10"))
def test_private_ipv4_not_loopback(self):
self.assertFalse(server._is_loopback_address("192.168.1.50"))
def test_ipv6_public_not_loopback(self):
self.assertFalse(server._is_loopback_address("2001:db8::1"))
def test_invalid_string_not_loopback(self):
self.assertFalse(server._is_loopback_address("not-an-ip"))
# ===========================================================================
# _check_domain_health_fast
# ===========================================================================
class TestCheckDomainHealthFast(unittest.TestCase):
"""_check_domain_health_fast returns True when there is an issue,
False when everything looks fine."""
def _fast(self, domain, external_ip, resolved_addrs):
with patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs):
return server._check_domain_health_fast(domain, external_ip)
def test_no_domain_no_issue(self):
# None/empty domain: the fast check reports True (handled by checklist).
result = server._check_domain_health_fast(None, "203.0.113.10")
self.assertTrue(result)
def test_empty_domain_no_issue(self):
result = server._check_domain_health_fast("", "203.0.113.10")
self.assertTrue(result)
def test_loopback_ipv4_no_issue(self):
"""Loopback override must not be flagged as a DNS mismatch."""
result = self._fast("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
self.assertFalse(result)
def test_loopback_ipv6_no_issue(self):
result = self._fast("cloud.example.com", "203.0.113.10", ["::1"])
self.assertFalse(result)
def test_matches_external_ip_no_issue(self):
result = self._fast("cloud.example.com", "203.0.113.10", ["203.0.113.10"])
self.assertFalse(result)
def test_mismatch_is_an_issue(self):
result = self._fast("cloud.example.com", "203.0.113.10", ["198.51.100.1"])
self.assertTrue(result)
def test_unavailable_external_ip_no_issue(self):
result = self._fast("cloud.example.com", "unavailable", ["198.51.100.1"])
self.assertFalse(result)
def test_multiple_addresses_one_matches_no_issue(self):
"""If any resolved address matches external_ip the check should pass."""
result = self._fast(
"cloud.example.com", "203.0.113.10",
["198.51.100.1", "203.0.113.10"],
)
self.assertFalse(result)
# ===========================================================================
# _evaluate_domain_checklist — loopback override path
# ===========================================================================
class TestEvaluateDomainChecklistLoopback(unittest.TestCase):
def _eval(self, domain, external_ip, resolved_addrs, reachable_result=None):
with (
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
patch.object(server, "_check_domain_reachable",
return_value=reachable_result or {"reachable": True, "status_code": 200}),
):
return server._evaluate_domain_checklist(domain, external_ip)
def test_loopback_dns_step_is_ok_not_error(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
dns_step = next(s for s in result["domain_check_steps"] if s["step"] == 2)
self.assertEqual(dns_step["status"], "ok")
self.assertNotIn("mismatch", dns_step.get("detail", "").lower())
def test_loopback_domain_status_is_local_override(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["127.0.0.1"])
self.assertEqual(result["domain_status"]["status"], "local_override")
def test_loopback_has_no_issues_when_reachable(self):
result = self._eval(
"cloud.example.com", "203.0.113.10", ["127.0.0.1"],
reachable_result={"reachable": True, "status_code": 200},
)
self.assertFalse(result["has_issues"])
def test_loopback_has_issues_when_caddy_unreachable(self):
"""A loopback override with Caddy down should still report an issue."""
result = self._eval(
"cloud.example.com", "203.0.113.10", ["127.0.0.1"],
reachable_result={"reachable": False, "error": "connection refused"},
)
self.assertTrue(result["has_issues"])
def test_ipv6_loopback_no_issue(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["::1"])
self.assertEqual(result["domain_status"]["status"], "local_override")
self.assertFalse(result["has_issues"])
def test_genuine_mismatch_still_reports_error(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["198.51.100.1"])
self.assertEqual(result["domain_status"]["status"], "dns_mismatch")
self.assertTrue(result["has_issues"])
def test_correct_public_dns_still_reports_ok(self):
result = self._eval("cloud.example.com", "203.0.113.10", ["203.0.113.10"])
self.assertEqual(result["domain_status"]["status"], "connected")
self.assertFalse(result["has_issues"])
def test_no_domain_has_issues(self):
result = self._eval(None, "203.0.113.10", [])
self.assertTrue(result["has_issues"])
# ===========================================================================
# api_services — composite health with loopback
# ===========================================================================
class TestApiServicesLoopbackHealth(unittest.IsolatedAsyncioTestCase):
async def _get_health(self, resolved_addrs, cached_reachable):
"""Return the health value for a single domain-requiring service."""
service_cfg = {
"services": [
{"unit": "caddy.service", "icon": "nextcloud", "enabled": True, "type": "system"}
]
}
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, {"caddy.service": "nextcloud"}, clear=False),
patch("builtins.open", mock_open(read_data="cloud.example.com\n")),
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
patch.object(server, "_is_domain_reachable_cached", return_value=cached_reachable),
patch.object(server, "_get_listening_ports",
return_value={"tcp": {80, 443}, "udp": set()}),
patch.object(server, "_get_firewall_allowed_ports",
return_value={"tcp": set(), "udp": set()}),
patch.object(server, "_cached_external_ip", "203.0.113.10"),
):
results = await server.api_services()
return results[0]["health"]
async def test_loopback_and_reachable_is_healthy(self):
"""Loopback override + Caddy reachable → healthy, not needs_attention."""
health = await self._get_health(["127.0.0.1"], cached_reachable=True)
self.assertEqual(health, "healthy")
async def test_loopback_and_caddy_down_is_needs_attention(self):
"""Loopback override + Caddy unreachable → needs_attention (genuine issue)."""
health = await self._get_health(["127.0.0.1"], cached_reachable=False)
self.assertEqual(health, "needs_attention")
async def test_correct_dns_and_reachable_is_healthy(self):
health = await self._get_health(["203.0.113.10"], cached_reachable=True)
self.assertEqual(health, "healthy")
async def test_dns_mismatch_is_needs_attention(self):
health = await self._get_health(["198.51.100.1"], cached_reachable=True)
self.assertEqual(health, "needs_attention")
# ===========================================================================
# api_domains_check — loopback detection
# ===========================================================================
class TestApiDomainsCheckLoopback(unittest.IsolatedAsyncioTestCase):
async def _check(self, resolved_addrs, external_ip="203.0.113.10"):
with (
patch.object(server, "_resolve_all_addresses", return_value=resolved_addrs),
patch.object(server, "_cached_external_ip", external_ip),
):
result = await server.api_domains_check(
MagicMock(domains=["cloud.example.com"])
)
return result["domains"][0]
async def test_loopback_ipv4_returns_local_override(self):
result = await self._check(["127.0.0.1"])
self.assertEqual(result["status"], "local_override")
async def test_loopback_ipv6_returns_local_override(self):
result = await self._check(["::1"])
self.assertEqual(result["status"], "local_override")
async def test_correct_dns_returns_connected(self):
result = await self._check(["203.0.113.10"])
self.assertEqual(result["status"], "connected")
async def test_mismatch_returns_dns_mismatch(self):
result = await self._check(["198.51.100.1"])
self.assertEqual(result["status"], "dns_mismatch")
async def test_no_resolution_returns_unresolvable(self):
result = await self._check([])
self.assertEqual(result["status"], "unresolvable")
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+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

+52
View File
@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#153126"/>
<stop offset="55%" stop-color="#0F241B"/>
<stop offset="100%" stop-color="#091C14"/>
</linearGradient>
<linearGradient id="outerArc" x1="70" y1="40" x2="190" y2="210" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#42F39A"/>
<stop offset="45%" stop-color="#28D978"/>
<stop offset="100%" stop-color="#1AA45D"/>
</linearGradient>
<linearGradient id="innerArc" x1="90" y1="60" x2="180" y2="190" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#27C86F"/>
<stop offset="100%" stop-color="#157E49"/>
</linearGradient>
<filter id="innerShade" x="-10%" y="-10%" width="120%" height="120%">
<feOffset dx="0" dy="2"/>
<feGaussianBlur stdDeviation="5" result="blur"/>
<feComposite in="blur" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 .18 0"/>
</filter>
</defs>
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
<rect x="1.5" y="1.5" width="253" height="253" rx="46.5" ry="46.5"
fill="none" stroke="rgba(255,255,255,0.08)"/>
<rect x="6" y="6" width="244" height="244" rx="42" ry="42"
fill="none" filter="url(#innerShade)"/>
<path d="M128 32 A96 96 0 1 1 58 196"
fill="none"
stroke="url(#outerArc)"
stroke-width="12"
stroke-linecap="round"/>
<path d="M128 56 A72 72 0 1 1 76 178"
fill="none"
stroke="url(#innerArc)"
stroke-width="10"
stroke-linecap="round"/>
<circle cx="128" cy="128" r="8" fill="#F2FFF7"/>
<circle cx="128" cy="128" r="18" fill="none" stroke="#7BFFC0" stroke-opacity="0.14" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,300 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 3440 1440"
width="3440"
height="1440"
version="1.1"
id="svg21"
sodipodi:docname="sovran-wallpaper-12-ultrawide-3440x1440.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xml:space="preserve"
inkscape:export-filename="sovran-wallpaper-12-ultrawide-3440x1440.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview21"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.657762"
inkscape:cx="1721.7474"
inkscape:cy="718.34493"
inkscape:window-width="3440"
inkscape:window-height="1363"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg21" /><defs
id="defs14"><linearGradient
id="bg"
x1="0"
y1="0"
x2="1"
y2="1"><stop
offset="0%"
stop-color="#040706"
id="stop1" /><stop
offset="50%"
stop-color="#06100c"
id="stop2" /><stop
offset="100%"
stop-color="#050706"
id="stop3" /></linearGradient><radialGradient
id="softGlow"
cx="0"
cy="0"
r="210"
fx="0"
fy="0"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(210)"><stop
offset="0%"
stop-color="#28d978"
stop-opacity="0.04"
id="stop4" /><stop
offset="100%"
stop-color="#28d978"
stop-opacity="0"
id="stop5" /></radialGradient><linearGradient
id="tileBg"
x1="0"
y1="0"
x2="0"
y2="340"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(210)"><stop
offset="0%"
stop-color="#153126"
id="stop6" /><stop
offset="55%"
stop-color="#0F241B"
id="stop7" /><stop
offset="100%"
stop-color="#091C14"
id="stop8" /></linearGradient><linearGradient
id="outerArc"
x1="75.634857"
y1="47.268153"
x2="326.94922"
y2="298.58251"
gradientTransform="matrix(0.95194204,0,0,1.0504841,210,0)"
gradientUnits="userSpaceOnUse"><stop
offset="0%"
stop-color="#42F39A"
id="stop9" /><stop
offset="45%"
stop-color="#28D978"
id="stop10" /><stop
offset="100%"
stop-color="#1AA45D"
id="stop11" /></linearGradient><linearGradient
id="innerArc"
x1="130.64136"
y1="106.15404"
x2="258.09194"
y2="251.81184"
gradientTransform="matrix(0.95325178,0,0,1.0490408,210,0)"
gradientUnits="userSpaceOnUse"><stop
offset="0%"
stop-color="#27C86F"
id="stop12" /><stop
offset="100%"
stop-color="#157E49"
id="stop13" /></linearGradient><filter
id="tileShadow"
x="-0.12705882"
y="-0.12705882"
width="1.2541176"
height="1.2952941"><feOffset
dy="14"
id="feOffset13" /><feGaussianBlur
stdDeviation="18"
result="blur"
id="feGaussianBlur13" /><feColorMatrix
type="matrix"
values=" 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .24 0"
id="feColorMatrix13" /><feMerge
id="feMerge14"><feMergeNode
in="blur"
id="feMergeNode13" /><feMergeNode
in="SourceGraphic"
id="feMergeNode14" /></feMerge></filter><linearGradient
id="bg-7"
x1="0"
y1="0"
x2="0"
y2="256"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.79478947,0,0,0.82005964,452.62858,4.2254746)"><stop
offset="0%"
stop-color="#153126"
id="stop1-0" /><stop
offset="55%"
stop-color="#0F241B"
id="stop2-9" /><stop
offset="100%"
stop-color="#091C14"
id="stop3-3" /></linearGradient><linearGradient
id="outerArc-6"
x1="70"
y1="40"
x2="190"
y2="210"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.79478947,0,0,0.82005964,452.62858,4.2254746)"><stop
offset="0%"
stop-color="#42F39A"
id="stop4-0" /><stop
offset="45%"
stop-color="#28D978"
id="stop5-6" /><stop
offset="100%"
stop-color="#1AA45D"
id="stop6-2" /></linearGradient><linearGradient
id="innerArc-6"
x1="90"
y1="60"
x2="180"
y2="190"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.79478947,0,0,0.82005964,452.62858,4.2254746)"><stop
offset="0%"
stop-color="#27C86F"
id="stop7-1" /><stop
offset="100%"
stop-color="#157E49"
id="stop8-8" /></linearGradient><filter
id="innerShade"
x="-0.049180328"
y="-0.049180328"
width="1.0983607"
height="1.1065574"><feOffset
dx="0"
dy="2"
id="feOffset8" /><feGaussianBlur
stdDeviation="5"
result="blur"
id="feGaussianBlur8" /><feComposite
in="blur"
in2="SourceAlpha"
operator="arithmetic"
k2="-1"
k3="1"
id="feComposite8" /><feColorMatrix
type="matrix"
values=" 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .18 0"
id="feColorMatrix8" /></filter></defs><rect
width="3440"
height="1440"
fill="url(#bg)"
id="rect14" /><!-- centered for ultrawide balance --><g
id="g3"
transform="translate(18.243681,41.048282)"><g
id="g2"
transform="translate(-161.15251,-21.284294)"><g
transform="translate(1330,720)"
id="g20"><circle
cx="0"
cy="0"
r="310"
fill="none"
stroke="rgba(242,255,247,0.045)"
stroke-width="1"
id="circle15" /><circle
cx="0"
cy="0"
r="390"
fill="none"
stroke="rgba(66,243,154,0.055)"
stroke-width="2"
stroke-dasharray="3, 22"
id="circle16" /></g><g
transform="translate(1565,702)"
id="g21"><text
x="0"
y="0"
fill="#c3cbc6"
font-family="Inter, ui-sans-serif, system-ui, '-apple-system', BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="42px"
font-weight="500"
letter-spacing="8"
id="text20">PRIVACY. SOVEREIGNTY. BITCOIN.</text><rect
x="0"
y="56"
width="240"
height="2"
rx="1"
fill="#42f39a"
id="rect21" /></g><g
id="g1"
transform="matrix(1.2581949,0,0,1.2194235,1167.4138,564.08337)"><rect
width="256"
height="256"
rx="48"
ry="48"
fill="url(#bg)"
id="rect8"
style="fill:url(#bg-7)"
x="0"
y="0" /><rect
x="1.5"
y="1.5"
width="253"
height="253"
rx="46.5"
ry="46.5"
fill="none"
stroke="rgba(255,255,255,0.08)"
id="rect9" /><rect
x="6"
y="6"
width="244"
height="244"
rx="42"
ry="42"
fill="none"
filter="url(#innerShade)"
id="rect10" /><path
d="M 128,32 A 96,96 0 1 1 58,196"
fill="none"
stroke="url(#outerArc)"
stroke-width="12"
stroke-linecap="round"
id="path10"
style="stroke:url(#outerArc-6)" /><path
d="M 128,56 A 72,72 0 1 1 76,178"
fill="none"
stroke="url(#innerArc)"
stroke-width="10"
stroke-linecap="round"
id="path11"
style="stroke:url(#innerArc-6)" /><circle
cx="128"
cy="128"
r="8"
fill="#f2fff7"
id="circle11" /><circle
cx="128"
cy="128"
r="18"
fill="none"
stroke="#7bffc0"
stroke-opacity="0.14"
stroke-width="4"
id="circle12" /></g><rect
x="0"
y="56"
width="560"
height="2"
rx="1"
fill="rgba(242,255,247,0.08)"
id="rect20"
style="fill:#b3b3b3"
transform="translate(1565,702)" /></g></g></svg>

After

Width:  |  Height:  |  Size: 8.8 KiB

+55 -21
View File
@@ -3,7 +3,6 @@
{
imports = [
./modules/modules.nix
./iso/branding.nix
];
# ── Boot ────────────────────────────────────────────────────
@@ -11,6 +10,8 @@
boot.loader.efi.canTouchEfiVariables = true;
boot.loader.efi.efiSysMountPoint = "/boot/efi";
boot.kernelPackages = pkgs.linuxPackages_latest;
boot.kernelParams = [ "quiet" "loglevel=3" "rd.systemd.show_status=false" "udev.log_level=3" ];
boot.blacklistedKernelModules = [ "rxrpc" ];
# ── Filesystems ─────────────────────────────────────────────
fileSystems."/run/media/Second_Drive" = {
@@ -25,21 +26,22 @@
nix.settings = {
experimental-features = [ "nix-command" "flakes" ];
download-buffer-size = 524288000;
# Network resilience for cache.nixos.org (Fastly) flakiness.
connect-timeout = 10; # fail-fast on dead TCP connects (default: 0 = unlimited)
stalled-download-timeout = 90; # default 300s; retry sooner on stalled transfers
download-attempts = 7; # default 5
http-connections = 25; # cap concurrency (helps MTU/middlebox paths)
fallback = true; # build locally if a substitute can't be fetched
};
# ── Networking ──────────────────────────────────────────────
# NOTE: hostName must remain "nixos" to match the nixosConfigurations key in
# flake.nix. Changing it breaks flake-based remote upgrades with:
# error: does not provide attribute 'nixosConfigurations."<hostname>"'
networking.hostName = "nixos";
networking.networkmanager.enable = true;
networking.firewall.enable = true;
networking.firewall.allowedTCPPorts = [ 80 443 8448 3051 ];
networking.firewall.allowedUDPPorts = [ 80 443 8448 3051 5353 ];
networking.firewall.allowedUDPPorts = [ 5353 ];
# ── Avahi (mDNS) ───────────────────────────────────────────
# Advertise as sovransystemsos.local on the LAN without changing the system
# hostname (which must remain "nixos" for flake compatibility — see above).
services.avahi = {
enable = true;
hostName = "sovransystemsos";
@@ -48,17 +50,42 @@
};
# ── Locale / Time ──────────────────────────────────────────
time.timeZone = "America/Los_Angeles";
i18n.defaultLocale = "en_US.UTF-8";
time.timeZone = null;
i18n.defaultLocale = lib.mkDefault "en_US.UTF-8";
i18n.supportedLocales = [
"en_US.UTF-8/UTF-8"
"en_GB.UTF-8/UTF-8"
"es_ES.UTF-8/UTF-8"
"fr_FR.UTF-8/UTF-8"
"de_DE.UTF-8/UTF-8"
"pt_BR.UTF-8/UTF-8"
"ja_JP.UTF-8/UTF-8"
"zh_CN.UTF-8/UTF-8"
"ko_KR.UTF-8/UTF-8"
"ru_RU.UTF-8/UTF-8"
"ar_SA.UTF-8/UTF-8"
"hi_IN/UTF-8"
];
# ── Desktop ────────────────────────────────────────────────
services.displayManager.gdm.enable = true;
services.displayManager.gdm.autoSuspend = false;
services.displayManager.gdm.wayland = true;
services.desktopManager.gnome.enable = true;
services.printing.enable = true;
systemd.enableEmergencyMode = false;
environment.gnome.excludePackages = [ pkgs.gnome-tour ];
security.pam.services.gdm-password.enableGnomeKeyring = true;
security.pam.services.gdm-autologin.enableGnomeKeyring = true;
# Declaratively guarantee the GNOME Keyring default pointer exists.
# Defining the full path ensures root doesn't accidentally lock the user out of .local
systemd.tmpfiles.rules = [
"d /home/free/.local 0700 free users -"
"d /home/free/.local/share 0700 free users -"
"d /home/free/.local/share/keyrings 0700 free users -"
"f /home/free/.local/share/keyrings/default 0600 free users - login\n"
];
# ── Audio ──────────────────────────────────────────────────
services.pulseaudio.enable = false;
@@ -77,16 +104,25 @@
extraGroups = [ "networkmanager" ];
};
services.displayManager.autoLogin.enable = true;
services.displayManager.autoLogin.user = "free";
services.displayManager.autoLogin.enable = false;
# ── Flatpak ────────────────────────────────────────────────
services.flatpak.enable = true;
systemd.services.flatpak-repo = {
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" "nss-lookup.target" ];
wants = [ "network-online.target" "nss-lookup.target" ];
path = [ pkgs.flatpak ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = "15s";
};
unitConfig = {
StartLimitIntervalSec = 120;
StartLimitBurst = 5;
};
script = ''
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
'';
@@ -94,7 +130,6 @@
# ── Packages ───────────────────────────────────────────────
nixpkgs.config.allowUnfree = true;
nixpkgs.config.permittedInsecurePackages = [ "jitsi-meet-1.0.8043" ];
environment.systemPackages = with pkgs; [
nftables
@@ -110,12 +145,12 @@
ranger fastfetch gedit openssl pwgen
aspell aspellDicts.en lm_sensors
hunspell hunspellDicts.en_US
synadm brave dua bitwarden-desktop
synadm brave dua
gparted pv unzip parted screen zenity
libargon2 gnome-terminal libreoffice-fresh
dig firefox element-desktop wp-cli axel
lk-jwt-service livekit-libwebrtc livekit-cli livekit
matrix-synapse age
dig firefox wp-cli axel
lk-jwt-service livekit-libwebrtc livekit
matrix-synapse age onlyoffice-desktopeditors
];
# ── Shell ──────────────────────────────────────────────────
@@ -161,7 +196,6 @@ backup /etc/nix-bitcoin-secrets/ localhost/
enable = true;
systemCronJobs = [
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh"
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/external_ip/external_ip.sh"
];
};
-93
View File
@@ -1,93 +0,0 @@
# Sovran Hub — Manual Backup
The manual backup service copies critical system data from your Sovran Pro to an external USB drive, providing a third copy of your data (your Sovran Pro already maintains an automatic internal backup on its second drive).
Backups are written to:
```
<USB drive>/Sovran_SystemsOS_Backup/<timestamp>/
```
where `<timestamp>` is formatted as `YYYYMMDD_HHMMSS`.
---
## Backup Stages
The script always attempts all four stages, but skips stages that are irrelevant to the system's configured role (see [Per-Role Breakdown](#per-role-breakdown) below).
| Stage | Directory | Contents |
|-------|-----------|----------|
| **1/4 — NixOS config** | `/etc/nixos/` | Full NixOS system configuration: `role-state.nix`, `custom.nix`, flake files, and any other config managed by the Hub |
| **2/4 — Secrets** | `/etc/nix-bitcoin-secrets`, `/var/lib/domains`, `/var/lib/secrets` | Bitcoin/LND secrets, domain configurations for all web services, and Hub state files |
| **3/4 — Home directory** | `/home/` | All user home directories (`.cache/` and Trash are excluded) |
| **4/4 — LND wallet data** | `/var/lib/lnd/` | Lightning Network node wallet and channel data (log files excluded) |
---
## Per-Role Breakdown
The script detects the system role at runtime by reading `/var/lib/sovran-hub/config.json` (falling back to `/etc/nixos/role-state.nix`) and adjusts its behaviour accordingly.
### Server + Desktop (default)
All services are enabled: Bitcoin, Matrix Synapse, Vaultwarden, WordPress, Nextcloud.
| Stage | Status | Notes |
|-------|--------|-------|
| Stage 1 — NixOS config | ✅ Backed up | Full server configuration |
| Stage 2 — Secrets | ✅ Backed up | Bitcoin secrets, domain configs, and Hub state |
| Stage 3 — Home directory | ✅ Backed up | Desktop user data |
| Stage 4 — LND wallet | ✅ Backed up | Lightning wallet and channel data |
This produces the largest backup. All four stages generate meaningful data.
### Desktop Only
All server services are disabled (`bitcoin = false`, `synapse = false`, `vaultwarden = false`, `wordpress = false`, `nextcloud = false`). Only GNOME desktop is active.
| Stage | Status | Notes |
|-------|--------|-------|
| Stage 1 — NixOS config | ✅ Backed up | Simpler config (no server services) |
| Stage 2 — Secrets | ⚠️ Partial | `/etc/nix-bitcoin-secrets` is **skipped** (not applicable for Desktop Only role). `/var/lib/domains` and `/var/lib/secrets` (Hub state) are still backed up if present |
| Stage 3 — Home directory | ✅ Backed up | **The most important data for this role** |
| Stage 4 — LND wallet | ⏭️ Skipped | Explicitly skipped — not applicable for Desktop Only role |
This produces the smallest and fastest backup. Stages 1 and 3 are the primary sources of meaningful data.
### Node (Bitcoin-only)
Only the Bitcoin ecosystem is active: `bitcoind`, `electrs`, `lnd`, `rtl`, `btcpay`, `mempool`, and `bip110`. All other server services are disabled.
| Stage | Status | Notes |
|-------|--------|-------|
| Stage 1 — NixOS config | ✅ Backed up | Node-specific configuration |
| Stage 2 — Secrets | ✅ Backed up | Bitcoin secrets and Hub state. `/var/lib/domains` may be minimal (BTCPay runs but is not exposed via Caddy) |
| Stage 3 — Home directory | ✅ Backed up | User data |
| Stage 4 — LND wallet | ✅ Backed up | **Critical** — Lightning wallet and channel data |
All four stages run, matching Server + Desktop behaviour. The `/var/lib/domains` directory may be sparsely populated since non-Bitcoin web services are not configured.
---
## Backup Manifest
After all stages complete, the script writes a `BACKUP_MANIFEST.txt` file inside the timestamped backup directory. This file records the date, hostname, detected role, target drive, and a directory listing of everything that was backed up.
---
## Running the Backup
The backup is triggered from the Sovran Hub web UI. You can also run it directly:
```bash
# Auto-detect the first external USB drive
sudo bash /path/to/sovran-hub-backup.sh
# Specify a target drive explicitly
sudo BACKUP_TARGET=/run/media/<user>/<drive> bash /path/to/sovran-hub-backup.sh
```
The script requires at least **10 GB** of free space on the target drive and will refuse to write to internal system drives.
Logs are written to `/var/log/sovran-hub-backup.log` and the current status (`RUNNING`, `SUCCESS`, or `FAILED`) is tracked in `/var/log/sovran-hub-backup.status`.
-259
View File
@@ -1,259 +0,0 @@
# Tech Support: Security Design, User Flow, and Incident Response
## Overview
The Sovran Hub includes a **Tech Support** feature that lets Sovran Systems
staff remotely diagnose and fix issues on a user's machine via SSH — without
ever having access to private keys or wallet funds.
Wallet protection is the default. The user must make an active, time-limited
choice to grant support staff access to wallet files, and can revoke that
access at any time.
---
## Implementation Details
### Restricted User Instead of Root
When a user enables support access the Hub:
1. Ensures the `sovran-support` system user exists (declared declaratively in
`modules/core/tech-support.nix`; the Hub also provisions it on demand as a
fallback on non-NixOS systems).
2. Writes the Sovran Systems public SSH key **only** to
`/var/lib/sovran-support/.ssh/authorized_keys`, not to root's
`authorized_keys`.
3. Applies POSIX ACLs (`setfacl -R -m u:sovran-support:---`) to every wallet
directory that exists on disk, denying all access by the support user.
4. Records a timestamped `SUPPORT_ENABLED` event in the audit log at
`/var/log/sovran-support-audit.log`.
When the session ends (or if the Hub cannot create the restricted user), the
key is removed and all ACLs are revoked immediately.
### Protected Wallet Paths
The following directories are locked by default when a support session starts:
| Path | Contents |
|------|----------|
| `/etc/nix-bitcoin-secrets` | nix-bitcoin generated secrets |
| `/var/lib/bitcoind` | Bitcoin Core chainstate and wallet |
| `/var/lib/lnd` | LND wallet and channel database |
| `/home` | User home directories |
Paths are only locked if they exist on disk at the time the session starts.
### POSIX ACL Mechanics
POSIX ACLs on Linux handle access checks in this order:
1. If the process UID matches the file owner UID → use owner permissions
2. **If there is a matching named-user ACL entry → use that entry's
permissions** (clamped by the mask entry)
3. If any group matches → use group permissions
4. Otherwise → use "other" permissions
Setting `u:sovran-support:---` creates a named-user ACL entry with no
permissions. Because the named-user entry is checked before the group/other
entries, the support user cannot access those directories regardless of the
"other" permission bits.
`setfacl` and `getfacl` are provided by the `acl` package, which is added to
`environment.systemPackages` by `modules/core/tech-support.nix`.
### Fallback to Root (When Restricted User Cannot Be Created)
If the `sovran-support` user does not exist and cannot be created (e.g.,
`users.mutableUsers = false` and the declarative module has not been deployed
yet), the Hub falls back to adding the support key to root's
`authorized_keys`. The modal prominently warns the user when this has happened
so they can decide whether to end the session.
### Audit Log
Every session event is appended to `/var/log/sovran-support-audit.log`:
```
[2025-01-15 14:32:01 UTC] SUPPORT_ENABLED: restricted_user=True acl_applied=True protected_paths=4
[2025-01-15 14:45:00 UTC] WALLET_UNLOCKED: duration=3600s expires=2025-01-15 15:45:00 UTC
[2025-01-15 15:45:00 UTC] WALLET_RELOCKED: auto-expired
[2025-01-15 16:01:22 UTC] SUPPORT_DISABLED
```
The last 100 lines of this log are accessible from the Hub UI while a session
is active (or after it ends, until the page is refreshed).
---
## Security Tradeoffs
### What This Protects Against
- **Accidental wallet exposure** — support staff cannot read wallet files
during a normal session; they must ask the user to explicitly grant access.
- **Credential theft** — private keys in the wallet directories are not
visible to the `sovran-support` user by default.
- **Scope creep** — the restricted user account limits the blast radius of an
SSH session compared to direct root access.
### Known Limitations
| Limitation | Mitigation |
|------------|------------|
| Support user still has system-wide bash access | Restrict with `ForceCommand` or AppArmor in the NixOS config if a narrower scope is required |
| ACLs apply only to directories that exist at session start | If new wallet directories are created during a session, they are not auto-protected. Re-lock and re-enable support to pick up new paths |
| Root fallback grants full access | The Hub UI warns the user prominently; users should end the session if they are uncomfortable |
| `setfacl` / ACL filesystem support required | The `acl` package is declared in `tech-support.nix`; most Linux filesystems (ext4, btrfs, xfs) support ACLs by default |
| Wallet access grant is time-limited but lazy-expired | Expiry is checked on the next `/api/support/status` poll (every 10 seconds in the UI); there is a small window after expiry |
### Defense-in-Depth Recommendations
For environments that require stronger isolation, consider layering one or
more additional controls:
- **`ForceCommand`** in `sshd_config` (or `~/.ssh/authorized_keys` command
prefix) to restrict the support user to a specific diagnostic script.
- **`ChrootDirectory`** in the `sshd_config` `Match User sovran-support` block
to confine the session to a prepared directory tree.
- **AppArmor or SELinux** profiles that deny the support process read access
to wallet paths at the kernel level.
- **Namespace/bind-mount overlays** (e.g., via a wrapper systemd unit) to
present a sanitized filesystem view.
---
## User Flow
```
User opens Hub → Clicks "Tech Support" in sidebar
Modal: "Need help from Sovran Systems?"
• Explains what will happen
• Shows Wallet Protection notice
• User clicks "Enable Support Access"
Hub: 1. Creates / verifies sovran-support user
2. Writes SSH key to that user's authorized_keys
3. Applies POSIX ACL deny on all existing wallet paths
4. Saves session metadata + writes SUPPORT_ENABLED to audit log
Modal: "Support Access is Active"
• Live session duration timer
• Wallet Files: Protected panel
Optional: "Grant Wallet Access" (time-limited, user-chosen)
• "End Support Session" button
• "View Audit Log" button
(User grants wallet access)
Hub: • Removes ACL deny entries
• Records WALLET_UNLOCKED event with expiry time
• Starts countdown timer in UI
(Timer expires or user clicks "Re-lock Wallet Now")
Hub: • Re-applies ACL deny entries
• Removes WALLET_UNLOCK_FILE
• Records WALLET_RELOCKED event
(User clicks "End Support Session")
Hub: 1. Removes SSH key from sovran-support authorized_keys
2. Removes SSH key from root authorized_keys (legacy cleanup)
3. Revokes any wallet unlock, re-applies ACL deny
4. Verifies key is gone
5. Records SUPPORT_DISABLED event
Modal: "Support Session Ended — SSH key removed"
• Shows verified removal status
```
---
## Incident Response
### Scenario 1 — You accidentally granted wallet access and are unsure what was copied
**Immediate steps:**
1. Click **"Re-lock Wallet Now"** in the Hub modal, or click
**"End Support Session"** to simultaneously revoke SSH access and wallet
access.
2. Open the **Audit Log** from the Hub modal and note the timestamps of
`WALLET_UNLOCKED` and `WALLET_RELOCKED` events.
3. Check `/var/log/auth.log` (or `journalctl -u sshd`) for SSH login events
by `sovran-support` during the unlocked window.
**Assessment:**
- If no SSH login occurred during the wallet-unlocked window, your keys are
safe.
- If an SSH login did occur, treat private keys as potentially compromised.
**Recovery if keys may be compromised:**
| Wallet | Recovery action |
|--------|----------------|
| LND | Move all funds out using `lncli sendcoins` to a freshly generated on-chain address; close channels; recreate wallet |
| Sparrow | Sweep funds to a new wallet generated on an air-gapped device |
| Bisq | Withdraw all BSQ and BTC to external wallets; delete the Bisq data directory and recreate |
| nix-bitcoin secrets | Rotate all secrets with `nix-bitcoin-secrets generate` and redeploy |
**Report the incident:**
Contact Sovran Systems immediately at support@sovransystems.com with:
- The audit log output (`/var/log/sovran-support-audit.log`)
- The SSH auth log for the affected time window
- A description of what you were troubleshooting
---
### Scenario 2 — Support session cannot be ended (button fails or server is unresponsive)
**Manual key removal (run as root on the device):**
```bash
# Remove from support user's authorized_keys
rm -f /var/lib/sovran-support/.ssh/authorized_keys
# Remove from root's authorized_keys (fallback / legacy)
sed -i '/sovransystemsos-support/d' /root/.ssh/authorized_keys
# Remove wallet unlock state
rm -f /var/lib/secrets/support-wallet-unlock
# Re-apply wallet ACL protections
setfacl -R -m u:sovran-support:--- /etc/nix-bitcoin-secrets \
/var/lib/bitcoind /var/lib/lnd /home 2>/dev/null || true
# Restart sshd to drop any active connections
systemctl restart sshd
```
---
### Scenario 3 — You see an unexpected SUPPORT_ENABLED in the audit log
This should never happen without physical or remote access to the Hub web
interface. If you see an unexpected entry:
1. Immediately run the manual key removal commands above.
2. Change the Sovran Hub web interface password.
3. Check `/var/log/nginx/access.log` (or Caddy access logs) for unexpected
requests to `/api/support/enable`.
4. Consider rebooting the device to clear any in-memory state.
5. Report the incident to Sovran Systems.
---
*This document is part of the Sovran_SystemsOS repository. For the
authoritative and up-to-date version, see the repository.*
@@ -1,70 +0,0 @@
#!/usr/bin/env bash
cd /home/free/Downloads
#### SCRIPT 1 ####
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/sovran-pro-flake-update.sh"
/run/current-system/sw/bin/bash /home/free/Downloads/sovran-pro-flake-update.sh
rm -rf /home/free/Downloads/sovran-pro-flake-update.sh
#### SCRIPT 2 ####
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/add-custom-nix.sh"
/run/current-system/sw/bin/bash /home/free/Downloads/add-custom-nix.sh
rm -rf /home/free/Downloads/add-custom-nix.sh
#### SCRIPT 3 ####
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/sovran-pro-flake-update2.sh"
/run/current-system/sw/bin/bash /home/free/Downloads/sovran-pro-flake-update2.sh
rm -rf /home/free/Downloads/sovran-pro-flake-update2.sh
#### SCRIPT 4 ####
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/nextcloud_maintenance_window_fix.sh"
/run/current-system/sw/bin/bash /home/free/Downloads/nextcloud_maintenance_window_fix.sh
rm -rf /home/free/Downloads/nextcloud_maintenance_window_fix.sh
#### SCRIPT 5 ####
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/add_external_backup_app.sh"
/run/current-system/sw/bin/bash /home/free/Downloads/add_external_backup_app.sh
rm -rf /home/free/Downloads/add_external_backup_app.sh
#### SCRIPT 6 ####
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/update-agenix.sh"
/run/current-system/sw/bin/bash /home/free/Downloads/update-agenix.sh
rm -rf /home/free/Downloads/update-agenix.sh
#### SCRIPT 7 ####
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/element-calling_haven"
/run/current-system/sw/bin/bash /home/free/Downloads/element-calling_haven.sh
rm -rf /home/free/Downloads/element-calling_haven.sh
#### REMOVAL OF MAIN SCRIPT ####
rm -rf /home/free/Downloads/Sovran_SystemsOS_File_Fixes_And_New_Services.sh
@@ -1,86 +0,0 @@
#!/usr/bin/env bash
function log_console () {
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
echo $1
}
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
FILE=/var/lib/beacons/file_fixes_and_new_services/add-custom-nix/completed
if [ -e $FILE ]; then
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
exit 1
fi
#### CREATE INITIAL TAG ####
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/add-custom-nix ; touch /var/lib/beacons/file_fixes_and_new_services/add-custom-nix/started
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
exit 1
fi
#### MAIN SCRIPT ####
touch /etc/nixos/custom.nix
/run/current-system/sw/bin/cat > /etc/nixos/custom.nix <<- "EOF"
{ config, lib, ... }:
{
###########################################################
# #
# Sovran_SystemsOS — custom.nix #
# #
# Services, features, and roles are managed by the #
# Sovran Hub. Any changes you make through the Hub #
# will appear in the "Hub Managed" section below. #
# #
# If you want to add your own NixOS modules or #
# configuration, place them here — outside of the #
# Hub Managed section. #
# #
###########################################################
# ─── Add your custom NixOS configuration below ───────────
}
EOF
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Run add-custom-nix"
exit 1
fi
#### CREATE COMPELETE TAG ####
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/add-custom-nix/completed
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
exit 1
fi
exit 0
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
function log_console () {
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
echo $1
}
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
FILE=/var/lib/beacons/file_fixes_and_new_services/add_external_backup_app/completed
if [ -e $FILE ]; then
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
exit 1
fi
#### CREATE INITIAL TAG ####
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/add_external_backup_app ; touch /var/lib/beacons/file_fixes_and_new_services/add_external_backup_app/started
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
exit 1
fi
#### MAIN SCRIPT ####
cd /home/free/Downloads
/run/current-system/sw/bin/wget "https://git.sovransystems.com/Sovran_Systems/Software/raw/branch/main/Sovran_SystemsOS_External_Backup/sovran_systemsOS_external_backup_local_installer/sovran_systemsOS_external_backup_install.sh"
/run/current-system/sw/bin/bash "sovran_systemsOS_external_backup_install.sh"
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Run add_external_backup_app"
exit 1
fi
#### CREATE COMPELETE TAG ####
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/add_external_backup_app/completed
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
exit 1
fi
exit 0
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
function log_console () {
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
echo $1
}
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
FILE=/var/lib/beacons/file_fixes_and_new_services/element-calling_haven/completed
if [ -e $FILE ]; then
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
exit 1
fi
#### CREATE INITIAL TAG ####
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/element-calling_haven ; touch /var/lib/beacons/file_fixes_and_new_services/element-calling_haven/started
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
exit 1
fi
#### MAIN SCRIPT ####
touch /var/lib/domains/haven
touch /var/lib/domains/element-calling
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Run element-calling_haven"
exit 1
fi
#### CREATE COMPELETE TAG ####
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/element-calling_haven/completed
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
exit 1
fi
exit 0
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
function log_console () {
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
echo $1
}
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
FILE=/var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix/completed
if [ -e $FILE ]; then
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
exit 1
fi
#### CREATE INITIAL TAG ####
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix ; touch /var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix/started
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
exit 1
fi
#### MAIN SCRIPT ####
/run/wrappers/bin/sudo -u caddy /run/current-system/sw/bin/php /var/lib/www/nextcloud/occ config:system:set maintenance_window_start --type=integer --value=1
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Run add-custom-nix"
exit 1
fi
#### CREATE COMPELETE TAG ####
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/nextcloud_maintenance_window_fix/completed
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
exit 1
fi
exit 0
@@ -1,96 +0,0 @@
#!/usr/bin/env bash
function log_console () {
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
echo $1
}
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
FILE=/var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update/completed
if [ -e $FILE ]; then
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
exit 1
fi
#### CREATE INITIAL TAG ####
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update ; touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update/started
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
exit 1
fi
#### MAIN SCRIPT ####
/run/current-system/sw/bin/rm /etc/nixos/flake.nix
/run/current-system/sw/bin/cat > /etc/nixos/flake.nix <<- "EOF"
{
description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems";
inputs = {
Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS";
};
outputs = { self, Sovran_Systems, ... }@inputs: {
nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./hardware-configuration.nix
Sovran_Systems.nixosModules.Sovran_SystemsOS
];
};
};
}
EOF
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Run sovran-pro-flake-update"
exit 1
fi
#### CREATE COMPELETE TAG ####
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update/completed
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
exit 1
fi
exit 0
@@ -1,98 +0,0 @@
#!/usr/bin/env bash
function log_console () {
echo "`date` :: $1" >> /var/lib/beacons/awesome.log
echo $1
}
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
FILE=/var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2/completed
if [ -e $FILE ]; then
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
exit 1
fi
#### CREATE INITIAL TAG ####
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2 ; touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2/started
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
exit 1
fi
#### MAIN SCRIPT ####
/run/current-system/sw/bin/rm /etc/nixos/flake.nix
/run/current-system/sw/bin/cat > /etc/nixos/flake.nix <<- "EOF"
{
description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems";
inputs = {
Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS";
};
outputs = { self, Sovran_Systems, ... }@inputs: {
nixosConfigurations."nixos" = Sovran_Systems.inputs.nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./custom.nix
./hardware-configuration.nix
Sovran_Systems.nixosModules.Sovran_SystemsOS
];
};
};
}
EOF
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Run sovran-pro-flake-update2"
exit 1
fi
#### CREATE COMPELETE TAG ####
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/sovran-pro-flake-update2/completed
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
exit 1
fi
exit 0
@@ -1,83 +0,0 @@
#!/usr/bin/env bash
#### CHECK TO SEE IF IT HAS BEEN RUN BEFORE ####
FILE=/var/lib/beacons/file_fixes_and_new_services/update-agenix/completed
if [ -e $FILE ]; then
/run/current-system/sw/bin/echo "File Found :), No Need to Run ... Exiting"
exit 1
fi
#### CREATE INITIAL TAG ####
/run/current-system/sw/bin/mkdir -p /var/lib/beacons/file_fixes_and_new_services/update-agenix ; touch /var/lib/beacons/file_fixes_and_new_services/update-agenix/started
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Initial Tag"
exit 1
fi
#### MAIN SCRIPT ####
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/nextclouddb.age
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/wordpressdb.age
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/turn.age
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/matrixdb.age
/run/current-system/sw/bin/rm -rf /var/lib/agenix-secrets/matrix_reg_secret.age
pushd /var/lib/agenix-secrets/
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/wordpressdb) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e wordpressdb.age -i /root/.ssh/agenix/agenix-secret-keys
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/nextclouddb) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e nextclouddb.age -i /root/.ssh/agenix/agenix-secret-keys
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/matrixdb) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e matrixdb.age -i /root/.ssh/agenix/agenix-secret-keys
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/turn) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e turn.age -i /root/.ssh/agenix/agenix-secret-keys
/run/current-system/sw/bin/echo -n $(/run/current-system/sw/bin/cat /var/lib/secrets/matrix_reg_secret) | EDITOR='/run/current-system/sw/bin/cp /dev/stdin' /run/current-system/sw/bin/nix run github:ryantm/agenix -- -e matrix_reg_secret.age -i /root/.ssh/agenix/agenix-secret-keys
popd
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Run update-agenix"
exit 1
fi
#### CREATE COMPELETE TAG ####
/run/current-system/sw/bin/touch /var/lib/beacons/file_fixes_and_new_services/update-agenix/completed
if [[ $? != 0 ]]; then
/run/current-system/sw/bin/echo "Could Not Create Completed Tag"
exit 1
fi
exit 0
Generated
+44 -95
View File
@@ -1,34 +1,15 @@
{
"nodes": {
"bip110": {
"btc-clients": {
"inputs": {
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1775155316,
"narHash": "sha256-4H8aEChZ6rra9jd8OcVHgHs3IuzKzpDt4PPtsPJrkyM=",
"owner": "emmanuelrosa",
"repo": "bitcoin-knots-bip-110-nix",
"rev": "663ea34f6f846f48c385a73d4581ba599bb5bbc0",
"type": "github"
},
"original": {
"owner": "emmanuelrosa",
"repo": "bitcoin-knots-bip-110-nix",
"type": "github"
}
},
"btc-clients": {
"inputs": {
"nixpkgs": "nixpkgs_2",
"oldNixpkgs": "oldNixpkgs"
},
"locked": {
"lastModified": 1775155241,
"narHash": "sha256-J8dGfHLXlpJgDSgbBuQtll9chM9Hsv5hhVWglSRLgIE=",
"lastModified": 1784932759,
"narHash": "sha256-44/iCx+wiYukHGhvPm65ppJZ3FZZbp6f9JE5isz8TsA=",
"owner": "emmanuelrosa",
"repo": "btc-clients-nix",
"rev": "e76e32fef6d0b8a8e9a32318835ba09915232a5c",
"rev": "8aab86c245ab9a2bea0d72175d6fd663a892af9f",
"type": "github"
},
"original": {
@@ -71,11 +52,11 @@
]
},
"locked": {
"lastModified": 1769996383,
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
@@ -106,16 +87,16 @@
"inputs": {
"extra-container": "extra-container",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_3",
"nixpkgs": "nixpkgs_2",
"nixpkgs-25_05": "nixpkgs-25_05",
"nixpkgs-unstable": "nixpkgs-unstable"
},
"locked": {
"lastModified": 1767721199,
"narHash": "sha256-UzRxDiJlopBGPTjyhCdMP+QdTwXK+l+y45urXCyH69A=",
"lastModified": 1779253922,
"narHash": "sha256-k5DpYVfyy27ELuEiV+51EfVg7B6vKUW63NWeA6eKGd0=",
"owner": "fort-nix",
"repo": "nix-bitcoin",
"rev": "5b532698ce9e8bd79b07d77ab4fc60e1a8408f73",
"rev": "1496f842477976c085cd96f1837ea12444014088",
"type": "github"
},
"original": {
@@ -127,27 +108,26 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1775054576,
"narHash": "sha256-iiIr1hlTMu2LLARsUYtiqlE90tqocqIMVLK2fIzB/UY=",
"lastModified": 1782911660,
"narHash": "sha256-PbR+tJ5E/Ux+01UtdFKqblccVA4/FgWbkym4ev3VHHQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "fc4b9b74d4b0bdbf3c97fef4bd34c05225172912",
"rev": "cf720c15e108d432d29041cc5a185630809acefb",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "master",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-25_05": {
"locked": {
"lastModified": 1767051569,
"narHash": "sha256-0MnuWoN+n1UYaGBIpqpPs9I9ZHW4kynits4mrnh1Pk4=",
"lastModified": 1767313136,
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "40ee5e1944bebdd128f9fbada44faefddfde29bd",
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
"type": "github"
},
"original": {
@@ -159,27 +139,27 @@
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1751274312,
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
"lastModified": 1784856561,
"narHash": "sha256-J+Bx1Z6Oeoj2FgnBhRMKyUhhtDoOpTgXYaVLZpDjW4A=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
"rev": "597283ad8aa0b331c788e97c4c262d58877074ef",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-24.11",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"lastModified": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
"type": "github"
},
"original": {
@@ -191,26 +171,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1775054576,
"narHash": "sha256-iiIr1hlTMu2LLARsUYtiqlE90tqocqIMVLK2fIzB/UY=",
"lastModified": 1778737229,
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "fc4b9b74d4b0bdbf3c97fef4bd34c05225172912",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1767480499,
"narHash": "sha256-8IQQUorUGiSmFaPnLSo2+T+rjHtiNWc+OAzeHck7N48=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "30a3c519afcf3f99e2c6df3b359aec5692054d92",
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
"type": "github"
},
"original": {
@@ -220,13 +185,13 @@
"type": "github"
}
},
"nixpkgs_4": {
"nixpkgs_3": {
"locked": {
"lastModified": 1775423009,
"narHash": "sha256-vPKLpjhIVWdDrfiUM8atW6YkIggCEKdSAlJPzzhkQlw=",
"lastModified": 1784796856,
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "68d8aa3d661f0e6bd5862291b5bb263b2a6595c9",
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
"type": "github"
},
"original": {
@@ -236,13 +201,13 @@
"type": "github"
}
},
"nixpkgs_5": {
"nixpkgs_4": {
"locked": {
"lastModified": 1770380644,
"narHash": "sha256-P7dWMHRUWG5m4G+06jDyThXO7kwSk46C1kgjEWcybkE=",
"lastModified": 1784555310,
"narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ae67888ff7ef9dff69b3cf0cc0fbfbcd3a722abe",
"rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493",
"type": "github"
},
"original": {
@@ -255,15 +220,15 @@
"nixvim": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs_5",
"nixpkgs": "nixpkgs_4",
"systems": "systems_2"
},
"locked": {
"lastModified": 1775307257,
"narHash": "sha256-y9hEecHH4ennFwIcw1n480YCGh73DkEmizmQnyXuvgg=",
"lastModified": 1784814601,
"narHash": "sha256-T32JXjZ7kIbhBn8/Har171yGg6IBdl97cxAWameqZDE=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "2e008bb941f72379d5b935d5bfe70ed8b7c793ff",
"rev": "f316e949e0ed9df0e1e0bf645c6dce721d4e230e",
"type": "github"
},
"original": {
@@ -272,28 +237,11 @@
"type": "github"
}
},
"oldNixpkgs": {
"locked": {
"lastModified": 1727619874,
"narHash": "sha256-a4Jcd+vjQAzF675/7B1LN3U2ay22jfDAVA8pOml5J/0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "6710d0dd013f55809648dfb1265b8f85447d30a6",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "6710d0dd013f55809648dfb1265b8f85447d30a6",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"bip110": "bip110",
"btc-clients": "btc-clients",
"nix-bitcoin": "nix-bitcoin",
"nixpkgs": "nixpkgs_4",
"nixpkgs": "nixpkgs_3",
"nixpkgs-stable": "nixpkgs-stable",
"nixvim": "nixvim"
}
@@ -315,15 +263,16 @@
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"lastModified": 1774449309,
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
"type": "github"
},
"original": {
"owner": "nix-systems",
"ref": "future-26.11",
"repo": "default",
"type": "github"
}
+2 -4
View File
@@ -6,11 +6,10 @@
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
nixvim.url = "github:nix-community/nixvim";
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11";
bip110.url = "github:emmanuelrosa/bitcoin-knots-bip-110-nix";
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
};
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, bip110, ... }:
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }:
let
overlay-stable = final: prev: {
@@ -56,7 +55,6 @@
btc-clients.packages.${pkgs.system}.bisq2
btc-clients.packages.${pkgs.system}.sparrow
];
sovran_systemsOS.packages.bip110 = bip110.packages.${pkgs.system}.bitcoind-knots-bip-110;
};
};
};
+52
View File
@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#153126"/>
<stop offset="55%" stop-color="#0F241B"/>
<stop offset="100%" stop-color="#091C14"/>
</linearGradient>
<linearGradient id="outerArc" x1="70" y1="40" x2="190" y2="210" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#42F39A"/>
<stop offset="45%" stop-color="#28D978"/>
<stop offset="100%" stop-color="#1AA45D"/>
</linearGradient>
<linearGradient id="innerArc" x1="90" y1="60" x2="180" y2="190" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#27C86F"/>
<stop offset="100%" stop-color="#157E49"/>
</linearGradient>
<filter id="innerShade" x="-10%" y="-10%" width="120%" height="120%">
<feOffset dx="0" dy="2"/>
<feGaussianBlur stdDeviation="5" result="blur"/>
<feComposite in="blur" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 .18 0"/>
</filter>
</defs>
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
<rect x="1.5" y="1.5" width="253" height="253" rx="46.5" ry="46.5"
fill="none" stroke="rgba(255,255,255,0.08)"/>
<rect x="6" y="6" width="244" height="244" rx="42" ry="42"
fill="none" filter="url(#innerShade)"/>
<path d="M128 32 A96 96 0 1 1 58 196"
fill="none"
stroke="url(#outerArc)"
stroke-width="12"
stroke-linecap="round"/>
<path d="M128 56 A72 72 0 1 1 76 178"
fill="none"
stroke="url(#innerArc)"
stroke-width="10"
stroke-linecap="round"/>
<circle cx="128" cy="128" r="8" fill="#F2FFF7"/>
<circle cx="128" cy="128" r="18" fill="none" stroke="#7BFFC0" stroke-opacity="0.14" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

-12
View File
@@ -1,12 +0,0 @@
{ config, pkgs, lib, ... }:
let
theme = pkgs.callPackage ./plymouth-theme.nix {};
in
{
boot.plymouth.enable = true;
boot.plymouth.theme = "sovran";
boot.plymouth.themePackages = [ theme ];
boot.kernelParams = [ "quiet" "splash" ];
boot.initrd.systemd.enable = true;
}
+106 -1
View File
@@ -16,7 +16,6 @@ in
{
imports = [
"${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix"
./branding.nix
];
image.baseName = lib.mkForce "Sovran_SystemsOS";
@@ -62,12 +61,118 @@ in
nixos-install-tools
git
curl
openssh
tailscale
jq
xxd
];
# Remote install support — SSH on the live ISO
services.openssh = {
enable = true;
listenAddresses = [{ addr = "0.0.0.0"; port = 22; }];
settings = {
PasswordAuthentication = true;
PermitRootLogin = "yes";
};
};
users.users.root.initialPassword = lib.mkForce "sovran-remote";
users.users.root.initialHashedPassword = lib.mkForce null;
# mDNS so the machine is discoverable as sovran-installer.local
services.avahi = {
enable = true;
hostName = "sovran-installer";
nssmdns4 = true;
publish = { enable = true; addresses = true; };
};
environment.etc."sovran/logo.png".source = ./assets/splash-logo.png;
environment.etc."sovran/flake".source = sovranSource;
environment.etc."sovran/installer.py".source = ./installer.py;
# These files are gitignored — set at build time by placing them in iso/secrets/
environment.etc."sovran/enroll-token" = lib.mkIf (builtins.pathExists ./secrets/enroll-token) {
text = builtins.readFile ./secrets/enroll-token;
mode = "0600";
};
environment.etc."sovran/provisioner-url" = lib.mkIf (builtins.pathExists ./secrets/provisioner-url) {
text = builtins.readFile ./secrets/provisioner-url;
mode = "0644";
};
# Tailscale client for mesh VPN
services.tailscale.enable = true;
# Auto-provision service — registers with provisioning server and joins Tailnet
systemd.services.sovran-auto-provision = {
description = "Auto-register with Sovran provisioning server and join Tailnet";
after = [ "network-online.target" "tailscaled.service" ];
wants = [ "network-online.target" "tailscaled.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.tailscale pkgs.curl pkgs.jq pkgs.coreutils pkgs.iproute2 pkgs.xxd ];
script = ''
TOKEN_FILE="/etc/sovran/enroll-token"
URL_FILE="/etc/sovran/provisioner-url"
[ -f "$TOKEN_FILE" ] || { echo "No enroll token found, skipping auto-provision"; exit 0; }
[ -f "$URL_FILE" ] || { echo "No provisioner URL found, skipping auto-provision"; exit 0; }
TOKEN=$(cat "$TOKEN_FILE")
PROV_URL=$(cat "$URL_FILE")
[ -n "$TOKEN" ] || exit 0
[ -n "$PROV_URL" ] || exit 0
# Wait for network + tailscaled
sleep 10
# Collect machine info
HOSTNAME="sovran-deploy-$(head -c 8 /dev/urandom | xxd -p)"
MAC=$(ip link show | grep ether | head -1 | awk '{print $2}' || echo "unknown")
echo "Registering with provisioning server at $PROV_URL..."
# Retry up to 6 times (covers slow DHCP)
RESPONSE=""
for i in $(seq 1 6); do
RESPONSE=$(curl -sf --max-time 15 -X POST \
"$PROV_URL/register" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"hostname\": \"$HOSTNAME\", \"mac\": \"$MAC\"}" 2>/dev/null) && break
echo "Attempt $i failed, retrying in 10s..."
sleep 10
done
if [ -z "$RESPONSE" ]; then
echo "ERROR: Failed to register with provisioning server after 6 attempts"
exit 1
fi
HS_KEY=$(echo "$RESPONSE" | jq -r '.headscale_key')
LOGIN_SERVER=$(echo "$RESPONSE" | jq -r '.login_server')
if [ -z "$HS_KEY" ] || [ "$HS_KEY" = "null" ]; then
echo "ERROR: No Headscale key in response: $RESPONSE"
exit 1
fi
echo "Joining Tailnet via $LOGIN_SERVER as $HOSTNAME..."
tailscale up \
--login-server="$LOGIN_SERVER" \
--authkey="$HS_KEY" \
--hostname="$HOSTNAME"
TAILSCALE_IP=$(tailscale ip -4)
echo "Successfully joined Tailnet as $HOSTNAME ($TAILSCALE_IP)"
'';
};
environment.etc."xdg/autostart/sovran-installer.desktop".text = ''
[Desktop Entry]
Type=Application
+210 -168
View File
@@ -5,8 +5,10 @@ gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
import atexit
import os
import secrets
import subprocess
import sys
import tempfile
import threading
import time
@@ -19,7 +21,7 @@ DEPLOYED_FLAKE = """\
description = "Sovran_SystemsOS for the Sovran Pro from Sovran Systems";
inputs = {
Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS";
Sovran_Systems.url = "git+https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS?ref=stable";
};
outputs = { self, Sovran_Systems, ... }@inputs: {
@@ -36,6 +38,26 @@ DEPLOYED_FLAKE = """\
}
"""
DICEWARE_WORDS = [
"apple", "barn", "brook", "cabin", "cedar", "cloud", "coral", "crane",
"delta", "eagle", "ember", "fern", "field", "flame", "flora", "flint",
"frost", "grove", "haven", "hedge", "holly", "heron", "jade", "juniper",
"kelp", "larch", "lemon", "lilac", "linden", "loch", "lotus", "maple",
"marsh", "meadow", "mist", "mossy", "mount", "oak", "ocean", "olive",
"petal", "pine", "pixel", "plum", "pond", "prism", "quartz", "raven",
"ridge", "river", "robin", "rocky", "rose", "rowan", "sage", "sand",
"sierra", "silver", "slate", "snow", "solar", "spark", "spruce", "stone",
"storm", "summit", "swift", "thorn", "tide", "timber", "torch", "trout",
"vale", "vault", "vine", "walnut", "wave", "willow", "wren", "amber",
"aspen", "birch", "blaze", "bloom", "bluff", "coast", "copper", "crest",
"dune", "elder", "fjord", "forge", "glade", "glen", "glow", "gulf",
]
def generate_diceware_password():
words = [secrets.choice(DICEWARE_WORDS) for _ in range(3)]
digit = secrets.randbelow(10)
return "-".join(words) + f"-{digit}"
try:
logfile = open(LOG, "a")
atexit.register(logfile.close)
@@ -109,7 +131,7 @@ def symbolic_icon(name):
return icon
# ── Application ────────────────────────────────────────────────────────────────
# ── Application ──────────────────────────────────────────────────────────
class InstallerApp(Adw.Application):
def __init__(self):
@@ -121,7 +143,7 @@ class InstallerApp(Adw.Application):
self.win.present()
# ── Main Window ────────────────────────────────────────────────────────────────
# ── Main Window ──────────────────────────────────────────────────────────
class InstallerWindow(Adw.ApplicationWindow):
def __init__(self, **kwargs):
@@ -135,6 +157,8 @@ class InstallerWindow(Adw.ApplicationWindow):
self.boot_size = None
self.data_disk = None
self.data_size = None
self.data_drive_has_timechain = False
self.free_password = None
# Root navigation view
self.nav = Adw.NavigationView()
@@ -168,7 +192,7 @@ class InstallerWindow(Adw.ApplicationWindow):
break
self.push_page(title, child)
# ── Shared widgets ────────────────────────────────────────────────────
# ── Shared widgets ────────────────────────────────────────────────────
def make_scrolled_log(self):
sw = Gtk.ScrolledWindow()
@@ -238,7 +262,7 @@ class InstallerWindow(Adw.ApplicationWindow):
pass
title = Gtk.Label()
title.set_markup("<span size='xx-large' weight='heavy'>Welcome to Sovran SystemsOS</span>")
title.set_markup("<span size='xx-large' weight='heavy'>Welcome to Sovran_SystemsOS</span>")
title.set_margin_top(8)
hero.append(title)
@@ -259,7 +283,7 @@ class InstallerWindow(Adw.ApplicationWindow):
notice.set_markup(
"<span size='medium'>"
"Before installation begins, please ensure you have an <b>active internet connection</b>.\n"
"Sovran SystemsOS downloads packages during installation and requires internet access\n"
"Sovran_SystemsOS downloads packages during installation and requires internet access\n"
"to complete the process. Connect via <b>Ethernet cable</b> or configure <b>Wi-Fi</b> now."
"</span>"
)
@@ -325,7 +349,7 @@ class InstallerWindow(Adw.ApplicationWindow):
pass
title = Gtk.Label()
title.set_markup("<span size='xx-large' weight='heavy'>Sovran Systems</span>")
title.set_markup("<span size='xx-large' weight='heavy'>Sovran_SystemsOS</span>")
hero.append(title)
sub = Gtk.Label()
@@ -339,6 +363,40 @@ class InstallerWindow(Adw.ApplicationWindow):
sep.set_margin_end(40)
outer.append(sep)
notice_frame = Gtk.Frame()
notice_frame.add_css_class("card")
notice_frame.set_margin_start(40)
notice_frame.set_margin_end(40)
notice_frame.set_margin_top(20)
notice_frame.set_margin_bottom(4)
notice_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
notice_box.set_margin_top(12)
notice_box.set_margin_bottom(12)
notice_box.set_margin_start(16)
notice_box.set_margin_end(16)
notice_icon = symbolic_icon("dialog-information-symbolic")
notice_icon.set_valign(Gtk.Align.START)
notice_box.append(notice_icon)
notice_lbl = Gtk.Label()
notice_lbl.set_use_markup(True)
notice_lbl.set_wrap(True)
notice_lbl.set_xalign(0)
notice_lbl.set_halign(Gtk.Align.FILL)
notice_lbl.set_markup(
"<span weight='bold'>Heads up — Server + Desktop prerequisites</span>\n"
"• A domain or subdomain from <span weight='bold'>https://njal.la</span>\n"
"• The ability to open / forward ports on your router\n\n"
"Don't worry — after install, the onboarding wizard walks you through every step.\n"
"<span size='small'>Desktop Only and Node Only do not require a domain or port forwarding.</span>"
)
notice_box.append(notice_lbl)
notice_frame.set_child(notice_box)
outer.append(notice_frame)
# Role label
role_lbl = Gtk.Label()
role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>")
@@ -645,11 +703,19 @@ class InstallerWindow(Adw.ApplicationWindow):
def push_disk_confirm(self):
"""Show the selected drives and ask the user to type ERASE to confirm."""
self.data_drive_has_timechain = False
if self.data_disk:
data_path = f"/dev/{self.data_disk}"
self.data_drive_has_timechain = self.detect_existing_timechain_data(data_path)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
# Disk info group
disk_group = Adw.PreferencesGroup()
disk_group.set_title("Drives to be erased")
if self.data_disk and self.data_drive_has_timechain:
disk_group.set_title("OS drive to be erased (data drive preserved)")
else:
disk_group.set_title("Drives to be erased")
disk_group.set_margin_top(24)
disk_group.set_margin_start(40)
disk_group.set_margin_end(40)
@@ -666,12 +732,21 @@ class InstallerWindow(Adw.ApplicationWindow):
data_row.set_subtitle(f"/dev/{self.data_disk}{human_size(self.data_size)}")
data_row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
disk_group.add(data_row)
if self.data_drive_has_timechain:
note_row = Adw.ActionRow()
note_row.set_title(f"Existing Bitcoin timechain detected on /dev/{self.data_disk}")
note_row.set_subtitle("Data will be preserved and mounted as-is.")
note_row.add_prefix(symbolic_icon("emblem-ok-symbolic"))
disk_group.add(note_row)
outer.append(disk_group)
# Warning banner
banner = Adw.Banner()
banner.set_title("⚠ All data on the above disk(s) will be permanently destroyed.")
if self.data_disk and self.data_drive_has_timechain:
banner.set_title("⚠ All data on the OS disk will be permanently destroyed. Existing Bitcoin data disk will be preserved.")
else:
banner.set_title("⚠ All data on the above disk(s) will be permanently destroyed.")
banner.set_revealed(True)
banner.set_margin_top(16)
banner.set_margin_start(40)
@@ -753,9 +828,61 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── Worker: partition ─────────────────────────────────────────────────
def partition_path(self, dev_path, num):
return f"{dev_path}p{num}" if "nvme" in dev_path else f"{dev_path}{num}"
def detect_existing_timechain_data(self, data_path, buf=None):
data_p1 = self.partition_path(data_path, 1)
if not os.path.exists(data_p1):
return False
label = ""
for cmd in (
["sudo", "lsblk", "-no", "LABEL", data_p1],
["sudo", "blkid", "-o", "value", "-s", "LABEL", data_p1],
):
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode == 0:
stdout = proc.stdout.strip()
label = stdout.splitlines()[0] if stdout else ""
if label:
break
if label != "BTCEcoandBackup":
return False
check_mount = tempfile.mkdtemp(prefix="sovran-installer-data-check-")
mounted = False
try:
run(["sudo", "mount", "-o", "ro", data_p1, check_mount])
mounted = True
has_bitcoin = os.path.isdir(f"{check_mount}/BTCEcoandBackup/Bitcoin_Node")
has_electrs = os.path.isdir(f"{check_mount}/BTCEcoandBackup/Electrs_Data")
if has_bitcoin and has_electrs:
if buf is not None:
GLib.idle_add(
append_text,
buf,
"=== Existing Bitcoin timechain detected on data drive — preserving data ===\n",
)
return True
return False
except Exception as e:
log(f"Timechain detection failed for {data_p1} at mount/check step ({check_mount}): {e}")
return False
finally:
if mounted:
subprocess.run(["sudo", "umount", check_mount], capture_output=True, text=True)
subprocess.run(["sudo", "rmdir", check_mount], capture_output=True, text=True)
def do_partition(self, buf):
boot_path = f"/dev/{self.boot_disk}"
data_path = f"/dev/{self.data_disk}" if self.data_disk else None
self.data_drive_has_timechain = False
if data_path:
self.data_drive_has_timechain = self.detect_existing_timechain_data(data_path, buf)
# ── Wipe disk(s) ──
GLib.idle_add(append_text, buf, "=== Wiping disk(s) ===\n")
@@ -763,12 +890,12 @@ class InstallerWindow(Adw.ApplicationWindow):
run_stream(["sudo", "sgdisk", "--zap-all", boot_path], buf)
run_stream(["sudo", "wipefs", "--all", "--force", boot_path], buf)
if data_path:
if data_path and not self.data_drive_has_timechain:
run_stream(["sudo", "sgdisk", "--zap-all", data_path], buf)
run_stream(["sudo", "wipefs", "--all", "--force", data_path], buf)
run_stream(["sudo", "partprobe", boot_path], buf)
if data_path:
if data_path and not self.data_drive_has_timechain:
run_stream(["sudo", "partprobe", data_path], buf)
time.sleep(2)
@@ -784,7 +911,7 @@ class InstallerWindow(Adw.ApplicationWindow):
time.sleep(2)
# ── Partition data disk (if selected) ──
if data_path:
if data_path and not self.data_drive_has_timechain:
GLib.idle_add(append_text, buf, "\n=== Partitioning data disk ===\n")
run_stream(["sudo", "sgdisk",
"-n", "1:1M:0", "-t", "1:8300", "-c", "1:primary",
@@ -795,14 +922,14 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── Format partitions ──
GLib.idle_add(append_text, buf, "\n=== Formatting partitions ===\n")
boot_p1 = f"{boot_path}p1" if "nvme" in boot_path else f"{boot_path}1"
boot_p2 = f"{boot_path}p2" if "nvme" in boot_path else f"{boot_path}2"
boot_p1 = self.partition_path(boot_path, 1)
boot_p2 = self.partition_path(boot_path, 2)
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
if data_path:
data_p1 = f"{data_path}p1" if "nvme" in data_path else f"{data_path}1"
if data_path and not self.data_drive_has_timechain:
data_p1 = self.partition_path(data_path, 1)
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "BTCEcoandBackup", data_p1], buf)
# ── Mount filesystems ──
@@ -812,7 +939,7 @@ class InstallerWindow(Adw.ApplicationWindow):
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
if data_path:
data_p1 = f"{data_path}p1" if "nvme" in data_path else f"{data_path}1"
data_p1 = self.partition_path(data_path, 1)
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive"], buf)
run_stream(["sudo", "mount", data_p1, "/mnt/run/media/Second_Drive"], buf)
run_stream(["sudo", "mkdir", "-p", "/mnt/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"], buf)
@@ -855,8 +982,9 @@ class InstallerWindow(Adw.ApplicationWindow):
if proc.returncode != 0:
raise RuntimeError(f"Failed to write role-state.nix: {proc.stderr}")
run(["sudo", "cp", "/mnt/etc/nixos/custom.template.nix", "/mnt/etc/nixos/custom.nix"])
run(["sudo", "chmod", "644", "/mnt/etc/nixos/custom.nix"])
# ── Step 4: Ready to install ──────────────────────────────────────────
# ── Step 4: Ready to install ──────────────────────────────────────────
def push_ready(self):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
@@ -907,7 +1035,7 @@ class InstallerWindow(Adw.ApplicationWindow):
outer.append(self.nav_row(
next_label="Install Now",
next_cb=lambda b: self.push_progress(
"Installing Sovran SystemsOS",
"Installing Sovran_SystemsOS",
"Building and installing your system. Please wait...",
self.do_install
)
@@ -966,173 +1094,87 @@ class InstallerWindow(Adw.ApplicationWindow):
if proc.returncode != 0:
log(proc.stderr)
raise RuntimeError(proc.stderr.strip() or "Failed to write deployed flake.nix")
run(["sudo", "rm", "-f", "/mnt/etc/nixos/flake.lock"])
GLib.idle_add(append_text, buf, "Locking flake to stable...\n")
run_stream(["sudo", "nix", "--extra-experimental-features", "nix-command flakes",
"flake", "lock", "/mnt/etc/nixos"], buf)
GLib.idle_add(self.push_create_password)
# Generate diceware passwords and write them to the installed system
GLib.idle_add(append_text, buf, "Setting up user passwords...\n")
self.free_password = generate_diceware_password()
root_password = generate_diceware_password()
# ── Step 5b: Create Password ──────────────────────────────────────────
def push_create_password(self):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
status = Adw.StatusPage()
status.set_title("Create Your Password")
status.set_description(
"Choose a password for your 'free' user account. "
"This will be your login password."
run(["sudo", "mkdir", "-p", "/mnt/var/lib/secrets"])
run(["sudo", "chmod", "700", "/mnt/var/lib/secrets"])
proc = subprocess.run(
["sudo", "tee", "/mnt/var/lib/secrets/free-password"],
input=self.free_password, capture_output=True, text=True
)
status.set_vexpand(True)
if proc.returncode != 0:
log(proc.stderr)
raise RuntimeError(proc.stderr.strip() or "Failed to write free-password")
run(["sudo", "chmod", "600", "/mnt/var/lib/secrets/free-password"])
form_group = Adw.PreferencesGroup()
form_group.set_margin_start(40)
form_group.set_margin_end(40)
proc = subprocess.run(
["sudo", "tee", "/mnt/var/lib/secrets/root-password"],
input=root_password, capture_output=True, text=True
)
if proc.returncode != 0:
log(proc.stderr)
raise RuntimeError(proc.stderr.strip() or "Failed to write root-password")
run(["sudo", "chmod", "600", "/mnt/var/lib/secrets/root-password"])
pw_row = Adw.PasswordEntryRow()
pw_row.set_title("Password")
form_group.add(pw_row)
GLib.idle_add(self.push_complete)
confirm_row = Adw.PasswordEntryRow()
confirm_row.set_title("Confirm Password")
form_group.add(confirm_row)
error_lbl = Gtk.Label()
error_lbl.set_margin_start(40)
error_lbl.set_margin_end(40)
error_lbl.set_margin_top(8)
error_lbl.set_visible(False)
error_lbl.add_css_class("error")
content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
content_box.append(status)
content_box.append(form_group)
content_box.append(error_lbl)
outer.append(content_box)
def on_submit(btn):
password = pw_row.get_text()
confirm = confirm_row.get_text()
if not password:
error_lbl.set_text("Password cannot be empty.")
error_lbl.set_visible(True)
return
if len(password) < 8:
error_lbl.set_text("Password must be at least 8 characters.")
error_lbl.set_visible(True)
return
if password != confirm:
error_lbl.set_text("Passwords do not match.")
error_lbl.set_visible(True)
return
btn.set_sensitive(False)
error_lbl.set_visible(False)
try:
run(["sudo", "mkdir", "-p", "/mnt/var/lib/secrets"])
proc = subprocess.run(
["sudo", "tee", "/mnt/var/lib/secrets/free-password"],
input=password, text=True, capture_output=True
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or "Failed to write password file")
run(["sudo", "chmod", "600", "/mnt/var/lib/secrets/free-password"])
# Find chpasswd in the installed system's Nix store
# We run it directly from the host with --root /mnt so it
# modifies /mnt/etc/shadow — no chroot needed.
chpasswd_find = subprocess.run(
["sudo", "find", "/mnt/nix/store", "-maxdepth", "3",
"-name", "chpasswd", "-type", "f", "-path", "*/bin/chpasswd"],
capture_output=True, text=True
)
chpasswd_paths = chpasswd_find.stdout.strip().splitlines()
if not chpasswd_paths:
raise RuntimeError("chpasswd binary not found in /mnt/nix/store")
# Use the full host path (e.g. /mnt/nix/store/...-shadow-xxx/bin/chpasswd)
chpasswd_bin = chpasswd_paths[0]
proc = subprocess.run(
["sudo", chpasswd_bin, "--root", "/mnt"],
input=f"free:{password}",
capture_output=True, text=True
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or "Failed to set password")
run(["sudo", "touch", "/mnt/var/lib/sovran-customer-onboarded"])
except Exception as e:
error_lbl.set_text(str(e))
error_lbl.set_visible(True)
btn.set_sensitive(True)
return
GLib.idle_add(self.push_complete)
submit_btn = Gtk.Button(label="Set Password & Continue")
submit_btn.add_css_class("suggested-action")
submit_btn.add_css_class("pill")
submit_btn.connect("clicked", on_submit)
nav = Gtk.Box()
nav.set_margin_bottom(24)
nav.set_margin_end(40)
nav.set_halign(Gtk.Align.END)
nav.append(submit_btn)
outer.append(nav)
self.push_page("Create Password", outer)
return False
# ── Step 6: Complete ───────────────────────────────────────────────────
# ── Complete ───────────────────────────────────────────────────────────
def push_complete(self):
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
status = Adw.StatusPage()
status.set_title("Installation Complete!")
status.set_description("Welcome to Sovran SystemsOS.")
status.set_vexpand(True)
status.set_description("Before rebooting, write down your login password.")
status.set_icon_name("dialog-password-symbolic")
outer.append(status)
creds_group = Adw.PreferencesGroup()
creds_group.set_title("⚠ Write down your login details before rebooting")
creds_group.set_margin_start(40)
creds_group.set_margin_end(40)
user_row = Adw.ActionRow()
user_row.set_title("Username")
user_row.set_subtitle("free")
creds_group.add(user_row)
pass_row = Adw.ActionRow()
pass_row.set_title("Password")
pass_row.set_subtitle("The password you just created")
creds_group.add(pass_row)
note_row = Adw.ActionRow()
note_row.set_title("App Passwords")
note_row.set_subtitle(
"After rebooting, all app passwords (Nextcloud, Bitcoin, Matrix, etc.) "
"will be available in the Sovran Hub on your dashboard."
pw_frame = Gtk.Frame()
pw_frame.set_margin_start(60)
pw_frame.set_margin_end(60)
pw_label = Gtk.Label()
pw_label.set_markup(
f"<span font_family='monospace' size='xx-large' weight='bold'>"
f"{GLib.markup_escape_text(self.free_password)}</span>"
)
creds_group.add(note_row)
pw_label.set_selectable(True)
pw_label.set_margin_top(16)
pw_label.set_margin_bottom(16)
pw_frame.set_child(pw_label)
outer.append(pw_frame)
content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
content_box.append(status)
content_box.append(creds_group)
outer.append(content_box)
warning = Gtk.Label()
warning.set_markup(
"<span foreground='#e8a838' size='medium' weight='bold'>"
"⚠ Write this password down now.\n"
"You will need it to log in to your computer and the Sovran Hub.\n"
"This password cannot be recovered.</span>"
)
warning.set_justify(Gtk.Justification.CENTER)
warning.set_wrap(True)
warning.set_margin_top(20)
warning.set_margin_start(48)
warning.set_margin_end(48)
outer.append(warning)
reboot_btn = Gtk.Button(label="Reboot Now")
reboot_btn.add_css_class("success")
outer.append(Gtk.Label(label="", vexpand=True))
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
btn_box.set_halign(Gtk.Align.CENTER)
btn_box.set_margin_bottom(32)
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Restart Entire System")
reboot_btn.add_css_class("suggested-action")
reboot_btn.add_css_class("pill")
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
nav = Gtk.Box()
nav.set_margin_bottom(24)
nav.set_margin_end(40)
nav.set_halign(Gtk.Align.END)
nav.append(reboot_btn)
outer.append(nav)
btn_box.append(reboot_btn)
outer.append(btn_box)
self.push_page("Complete", outer)
return False
-39
View File
@@ -1,39 +0,0 @@
{ pkgs, lib }:
pkgs.stdenv.mkDerivation {
pname = "sovran-plymouth-theme";
version = "1.0";
src = ./.;
installPhase = ''
mkdir -p $out/share/plymouth/themes/sovran
cp ${./assets/splash-logo.png} $out/share/plymouth/themes/sovran/logo.png
cat > $out/share/plymouth/themes/sovran/sovran.plymouth <<EOF
[Plymouth Theme]
Name=Sovran Systems
Description=Sovran Systems Splash
ModuleName=script
[script]
ImageDir=$out/share/plymouth/themes/sovran
ScriptFile=$out/share/plymouth/themes/sovran/sovran.script
EOF
cat > $out/share/plymouth/themes/sovran/sovran.script <<'EOF'
# Background color: #CFFFD7 (RGB 207,255,215)
bg_r = 207/255.0
bg_g = 255/255.0
bg_b = 215/255.0
Window.SetBackgroundTopColor (bg_r, bg_g, bg_b);
Window.SetBackgroundBottomColor (bg_r, bg_g, bg_b);
logo = Image("logo.png");
logo_sprite = Sprite(logo);
logo_sprite.SetX((Window.GetWidth() - logo.GetWidth()) / 2);
logo_sprite.SetY((Window.GetHeight() - logo.GetHeight()) / 2);
EOF
'';
}
View File
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env bash
# sovran-install-headless.sh — Non-interactive remote installer for Sovran_SystemsOS
usage() {
cat <<'USAGE'
Usage: sovran-install-headless.sh [OPTIONS]
Options:
--disk /dev/sda Target OS disk (required)
--data-disk /dev/sdb Data disk for Bitcoin (optional)
--role server|desktop|node Installation role (default: server)
--deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install
--headscale-server URL Headscale login server for post-install Tailnet
--headscale-key KEY Headscale pre-auth key for the installed OS
USAGE
}
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────────────
DISK=""
DATA_DISK=""
ROLE="server"
DEPLOY_KEY=""
HEADSCALE_SERVER=""
HEADSCALE_KEY=""
DATA_DISK_HAS_TIMECHAIN=false
FLAKE="/etc/sovran/flake"
LOG="/tmp/sovran-headless-install.log"
BYTES_256GB=$((256 * 1024 * 1024 * 1024))
BYTES_2TB=$((2 * 1000 * 1000 * 1000 * 1000))
# ── Logging ───────────────────────────────────────────────────────────────────
exec > >(tee -a "$LOG") 2>&1
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
die() {
log "ERROR: $*"
exit 1
}
# ── Argument parsing ─────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--disk) DISK="$2"; shift 2 ;;
--data-disk) DATA_DISK="$2"; shift 2 ;;
--role) ROLE="$2"; shift 2 ;;
--deploy-key) DEPLOY_KEY="$2"; shift 2 ;;
--headscale-server) HEADSCALE_SERVER="$2"; shift 2 ;;
--headscale-key) HEADSCALE_KEY="$2"; shift 2 ;;
-h|--help)
usage
exit 0
;;
*) die "Unknown argument: $1" ;;
esac
done
# ── Validate required arguments ───────────────────────────────────────────────
[[ -n "$DISK" ]] || die "--disk is required"
case "$ROLE" in
server|desktop|node) ;;
*) die "--role must be one of: server, desktop, node" ;;
esac
# ── Validate disk existence and size ─────────────────────────────────────────
log "=== Validating disks ==="
[[ -b "$DISK" ]] || die "OS disk not found: $DISK"
disk_size_bytes() {
local dev="$1"
lsblk -b -dno SIZE "$dev" 2>/dev/null || echo 0
}
OS_SIZE=$(disk_size_bytes "$DISK")
log "OS disk $DISK: $OS_SIZE bytes"
[[ "$OS_SIZE" -ge "$BYTES_256GB" ]] \
|| die "OS disk $DISK is too small ($(( OS_SIZE / 1024 / 1024 / 1024 )) GB). Minimum is 256 GB."
if [[ -n "$DATA_DISK" ]]; then
[[ -b "$DATA_DISK" ]] || die "Data disk not found: $DATA_DISK"
[[ "$DATA_DISK" != "$DISK" ]] || die "OS disk and data disk cannot be the same device"
DATA_SIZE=$(disk_size_bytes "$DATA_DISK")
log "Data disk $DATA_DISK: $DATA_SIZE bytes"
[[ "$DATA_SIZE" -ge "$BYTES_2TB" ]] \
|| die "Data disk $DATA_DISK is too small ($(( DATA_SIZE / 1024 / 1024 / 1024 )) GB). Minimum is 2 TB."
fi
# ── Helper: partition suffix ──────────────────────────────────────────────────
part_suffix() {
local dev="$1" n="$2"
if [[ "$dev" == *nvme* ]]; then
echo "${dev}p${n}"
else
echo "${dev}${n}"
fi
}
# ── Detect existing Bitcoin timechain data on data disk ───────────────────────
if [[ -n "$DATA_DISK" ]]; then
DATA_P1=$(part_suffix "$DATA_DISK" 1)
if [[ -b "$DATA_P1" ]]; then
DATA_LABEL=$(lsblk -no LABEL "$DATA_P1" 2>/dev/null | head -n1 || true)
if [[ -z "$DATA_LABEL" ]]; then
DATA_LABEL=$(blkid -o value -s LABEL "$DATA_P1" 2>/dev/null || true)
fi
if [[ "$DATA_LABEL" == "BTCEcoandBackup" ]]; then
CHECK_MOUNT=$(mktemp -d /tmp/sovran-data-check.XXXXXX)
if mount -o ro "$DATA_P1" "$CHECK_MOUNT" 2>/dev/null; then
if [[ -d "$CHECK_MOUNT/BTCEcoandBackup/Bitcoin_Node" && -d "$CHECK_MOUNT/BTCEcoandBackup/Electrs_Data" ]]; then
DATA_DISK_HAS_TIMECHAIN=true
log "Existing Bitcoin timechain detected on data drive — preserving data"
fi
umount "$CHECK_MOUNT" || true
fi
rmdir "$CHECK_MOUNT" 2>/dev/null || true
fi
fi
fi
# ── Step 1: Wipe disks ────────────────────────────────────────────────────────
log "=== Wiping disk(s) ==="
sgdisk --zap-all "$DISK"
wipefs --all --force "$DISK"
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
sgdisk --zap-all "$DATA_DISK"
wipefs --all --force "$DATA_DISK"
fi
partprobe "$DISK"
[[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]] && partprobe "$DATA_DISK"
sleep 2
# ── Step 2: Partition OS disk ─────────────────────────────────────────────────
log "=== Partitioning OS disk ==="
sgdisk \
-n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \
-n "2:0:0" -t "2:8300" -c "2:root" \
"$DISK"
partprobe "$DISK"
sleep 2
# ── Step 3: Partition data disk (if present) ──────────────────────────────────
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
log "=== Partitioning data disk ==="
sgdisk \
-n "1:1M:0" -t "1:8300" -c "1:primary" \
"$DATA_DISK"
partprobe "$DATA_DISK"
sleep 2
fi
# ── Step 4: Format partitions ─────────────────────────────────────────────────
log "=== Formatting partitions ==="
BOOT_P1=$(part_suffix "$DISK" 1)
BOOT_P2=$(part_suffix "$DISK" 2)
mkfs.vfat -F 32 "$BOOT_P1"
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
DATA_P1=$(part_suffix "$DATA_DISK" 1)
mkfs.ext4 -F -L BTCEcoandBackup "$DATA_P1"
fi
# ── Step 5: Mount filesystems ─────────────────────────────────────────────────
log "=== Mounting filesystems ==="
mount "$BOOT_P2" /mnt
mkdir -p /mnt/boot/efi
mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi
if [[ -n "$DATA_DISK" ]]; then
DATA_P1=$(part_suffix "$DATA_DISK" 1)
mkdir -p /mnt/run/media/Second_Drive
mount "$DATA_P1" /mnt/run/media/Second_Drive
# ── Step 6: Create Bitcoin data directories ─────────────────────────────
log "=== Creating Bitcoin data directories ==="
mkdir -p /mnt/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
mkdir -p /mnt/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
mkdir -p /mnt/run/media/Second_Drive/BTCEcoandBackup/NixOS_Snapshot_Backup
fi
# ── Step 7: Generate hardware config ─────────────────────────────────────────
log "=== Generating hardware config ==="
nixos-generate-config --root /mnt
# ── Step 8: Copy flake source ─────────────────────────────────────────────────
log "=== Copying flake to /mnt ==="
cp /mnt/etc/nixos/hardware-configuration.nix /tmp/hardware-configuration.nix
rm -rf /mnt/etc/nixos/
mkdir -p /mnt/etc/nixos
cp -a "${FLAKE}/." /mnt/etc/nixos/
cp /tmp/hardware-configuration.nix /mnt/etc/nixos/hardware-configuration.nix
# ── Step 9: Write role-state.nix ─────────────────────────────────────────────
log "=== Writing role config ==="
case "$ROLE" in
server)
IS_SERVER=true; IS_DESKTOP=false; IS_NODE=false ;;
desktop)
IS_SERVER=false; IS_DESKTOP=true; IS_NODE=false ;;
node)
IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;;
esac
cat > /mnt/etc/nixos/role-state.nix <<EOF
# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
{ config, lib, ... }:
{
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER};
sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP};
sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE};
}
EOF
# ── Step 10: Write custom.nix with deploy config ──────────────────────────────
log "=== Writing custom.nix ==="
if [[ -n "$DEPLOY_KEY" || -n "$HEADSCALE_SERVER" ]]; then
{
echo '{ config, lib, ... }:'
echo '{'
echo ' sovran_systemsOS.deploy = {'
echo ' enable = true;'
[[ -n "$DEPLOY_KEY" ]] && echo " authorizedKey = \"${DEPLOY_KEY}\";"
[[ -n "$HEADSCALE_SERVER" ]] && echo " headscaleServer = \"${HEADSCALE_SERVER}\";"
echo ' };'
echo '}'
} > /mnt/etc/nixos/custom.nix
else
cp /mnt/etc/nixos/custom.template.nix /mnt/etc/nixos/custom.nix
chmod 644 /mnt/etc/nixos/custom.nix
fi
# ── Write Headscale auth key if provided ─────────────────────────────────────
if [[ -n "$HEADSCALE_KEY" ]]; then
mkdir -p /mnt/var/lib/secrets
echo "$HEADSCALE_KEY" > /mnt/var/lib/secrets/headscale-authkey
chmod 600 /mnt/var/lib/secrets/headscale-authkey
log "Headscale auth key written to /mnt/var/lib/secrets/headscale-authkey"
fi
# ── Step 11: Copy configs to host for flake evaluation ───────────────────────
log "=== Copying config files to host /etc/nixos for flake evaluation ==="
mkdir -p /etc/nixos
cp /mnt/etc/nixos/role-state.nix /etc/nixos/role-state.nix
cp /mnt/etc/nixos/custom.nix /etc/nixos/custom.nix
cp /mnt/etc/nixos/hardware-configuration.nix /etc/nixos/hardware-configuration.nix
# ── Step 12: Run nixos-install ────────────────────────────────────────────────
log "=== Running nixos-install ==="
nixos-install \
--root /mnt \
--flake /mnt/etc/nixos#nixos \
--no-root-password \
--impure
log "=== Installation complete! ==="
log "You can now reboot into Sovran_SystemsOS."
log "After reboot, the machine will be accessible via SSH on port 22 (if --deploy-key was provided)."
[[ -n "$HEADSCALE_SERVER" ]] && \
log "Tailscale will connect to Headscale at ${HEADSCALE_SERVER} on first boot."
@@ -1,24 +0,0 @@
{config, pkgs, lib, ...}:
{
systemd.services.Sovran_SystemsOS_File_Fixes_And_New_Services = {
unitConfig = {
After = "btcpayserver.service";
Requires = "network-online.target";
};
serviceConfig = {
ExecStartPre= "/run/current-system/sw/bin/sleep 30";
ExecStart = "/run/current-system/sw/bin/wget https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/raw/branch/main/file_fixes_and_new_services/Sovran_SystemsOS_File_Fixes_And_New_Services.sh -O /home/free/Downloads/Sovran_SystemsOS_File_Fixes_And_New_Services.sh ; /run/current-system/sw/bin/bash /home/free/Downloads/Sovran_SystemsOS_File_Fixes_And_New_Services.sh";
RemainAfterExit = "yes";
User = "root";
Type = "oneshot";
};
wantedBy = [ "multi-user.target" ];
};
}
-23
View File
@@ -1,23 +0,0 @@
{ config, lib, pkgs, ... }:
let
cfg = config.sovran_systemsOS;
in
{
options.sovran_systemsOS.packages.bip110 = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
description = "BIP110 Bitcoin package";
};
config = lib.mkIf (
cfg.features.bip110 &&
cfg.packages.bip110 != null
) {
services.bitcoind.package = lib.mkForce cfg.packages.bip110;
environment.systemPackages = [
cfg.packages.bip110
];
};
}
+42 -7
View File
@@ -4,7 +4,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
services.bitcoind = {
enable = true;
package = config.nix-bitcoin.pkgs.bitcoind-knots;
package = pkgs.bitcoind-knots;
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
txindex = true;
tor.proxy = true;
@@ -55,7 +55,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
};
services.btcpayserver = {
enable = true;
enable = config.sovran_systemsOS.web.btcpayserver;
};
services.btcpayserver.lightningBackend = "lnd";
@@ -70,12 +70,47 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
nix-bitcoin.useVersionLockedPkgs = false;
systemd.tmpfiles.rules = [
"d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node 0770 bitcoin bitcoin -"
"d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data 0770 electrs electrs -"
];
systemd.services.bitcoind = {
requires = [ "run-media-Second_Drive.mount" ];
after = [ "run-media-Second_Drive.mount" ];
serviceConfig.PrivateUsers = lib.mkForce false;
};
sovran_systemsOS.domainRequirements = [
systemd.services.electrs = {
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
wants = [ "bitcoind.service" ];
};
systemd.services.lnd = {
wants = [ "bitcoind.service" ];
# requires for bitcoind set by nix-bitcoin; mkForce removes it
requires = lib.mkForce [ ];
};
systemd.services.sovran-btc-permissions = {
description = "Fix Bitcoin/Electrs data directory ownership on second drive";
wantedBy = [ "multi-user.target" ];
after = [ "run-media-Second_Drive.mount" ];
before = [ "bitcoind.service" "electrs.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node ]; then
chown -R bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
fi
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data ]; then
chown -R electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
fi
'';
};
networking.firewall.allowedTCPPorts = [ 3051 ];
networking.firewall.allowedUDPPorts = [ 3051 ];
sovran_systemsOS.domainRequirements = [
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
];
}
+55 -9
View File
@@ -3,13 +3,45 @@
let
exposeBtcpay = config.sovran_systemsOS.web.btcpayserver;
extraVhosts = config.sovran_systemsOS.caddy.extraVirtualHosts;
# True when any service needs HTTPS/ACME (domain-based vhosts)
needsHttpsPorts =
config.sovran_systemsOS.web.btcpayserver
|| config.sovran_systemsOS.services.synapse
|| config.sovran_systemsOS.services.wordpress
|| config.sovran_systemsOS.services.nextcloud
|| config.sovran_systemsOS.services.vaultwarden
|| config.sovran_systemsOS.features.haven
|| config.sovran_systemsOS.features.element-calling;
in
{
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";
group = "root";
configFile = "/run/caddy/Caddyfile";
};
# Only open ports 80/443 when at least one domain-based service is active
networking.firewall.allowedTCPPorts = lib.mkIf needsHttpsPorts [ 80 443 ];
networking.firewall.allowedUDPPorts = lib.mkIf needsHttpsPorts [ 80 443 ];
systemd.tmpfiles.rules = [
"d /var/lib/domains 0755 caddy root -"
];
# Override ExecStart + ExecReload to point at the runtime-generated Caddyfile
systemd.services.caddy.serviceConfig = {
ExecStart = lib.mkForce [
""
"${pkgs.caddy}/bin/caddy run --config /run/caddy/Caddyfile --adapter caddyfile"
];
ExecReload = lib.mkForce [
""
"${pkgs.caddy}/bin/caddy reload --config /run/caddy/Caddyfile --adapter caddyfile --force"
];
};
systemd.services.caddy-generate-config = {
@@ -40,12 +72,20 @@ in
HAVEN=$(read_domain haven)
ACME_EMAIL=$(read_domain sslemail)
# Start with global config
# Start with global config use ACME only when domain-based services are active
${if needsHttpsPorts then ''
cat > /run/caddy/Caddyfile <<EOF
{
email $ACME_EMAIL
}
EOF
'' else ''
cat > /run/caddy/Caddyfile <<EOF
{
auto_https off
}
EOF
''}
# Matrix
if [ -n "$MATRIX" ]; then
@@ -57,10 +97,10 @@ EOF
$MATRIX {
reverse_proxy /_matrix/* http://localhost:8008
reverse_proxy /_synapse/client/* http://localhost:8008
}
$MATRIX:8448 {
reverse_proxy http://localhost:8008
handle /.well-known/matrix/server {
header Content-Type application/json
respond \`{"m.server":"$MATRIX:443"}\` 200
}
}
EOF
fi
@@ -73,7 +113,7 @@ EOF
$WORDPRESS {
encode gzip zstd
root * /var/lib/www/wordpress
php_fastcgi unix//run/phpfpm/mypool.sock
php_fastcgi unix//run/phpfpm/wordpress.sock
file_server browse
}
EOF
@@ -86,7 +126,7 @@ EOF
$NEXTCLOUD {
encode gzip zstd
root * /var/lib/www/nextcloud
php_fastcgi unix//run/phpfpm/mypool.sock {
php_fastcgi unix//run/phpfpm/nextcloud.sock {
trusted_proxies private_ranges
}
file_server
@@ -151,6 +191,12 @@ EOF
http://sovransystemsos.local {
reverse_proxy localhost:8937
header {
Clear-Site-Data "\"cache\""
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Pragma "no-cache"
Expires "0"
}
}
EOF
+52
View File
@@ -0,0 +1,52 @@
# ── modules/core/cpu-performance.nix ──────────────────────────────────────────
# Forces all CPU cores to run at maximum frequency on node and server_plus_desktop
# roles. Desktop-only installs retain normal OS power management behaviour.
#
# Three layers:
# 1. power-profiles-daemon disabled — removes the GNOME power profile picker;
# no user can switch profiles
# 2. cpufreq performance governor — pins every core to max frequency via
# kernel, enforced at boot by a oneshot unit
# 3. systemd oneshot enforcement — belt-and-suspenders; applies the governor
# after every boot even if module loads late
{ config, lib, pkgs, ... }:
{
config = lib.mkIf (!config.sovran_systemsOS.roles.desktop) {
# ── Layer 1: disable power-profiles-daemon ───────────────────────────────
# This removes the power-profile switcher from GNOME Settings entirely.
services.power-profiles-daemon.enable = false;
# ── Layer 2: set cpufreq governor to performance ─────────────────────────
# Pins all cores to max frequency. Works on Intel (intel_pstate) and AMD
# (amd-pstate / acpi-cpufreq) alike.
powerManagement.cpuFreqGovernor = "performance";
# ── Layer 3: enforce at boot via systemd oneshot ─────────────────────────
# Belt-and-suspenders: ensures the governor is applied after every boot even
# if the kernel module loads late.
systemd.services.cpu-performance = {
description = "Set CPU governor to performance on all cores";
wantedBy = [ "multi-user.target" ];
after = [ "systemd-modules-load.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
found=0
for gov in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
if [ -w "$gov" ]; then
echo performance > "$gov"
found=1
fi
done
if [ "$found" -eq 0 ]; then
echo "cpu-performance: no writable cpufreq governors found (VM or unsupported hardware)" >&2
fi
'';
};
};
}
-292
View File
@@ -1,292 +0,0 @@
{ config, pkgs, lib, ... }:
let
sovran-factory-seal = pkgs.writeShellScriptBin "sovran-factory-seal" ''
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Error: must be run as root." >&2
exit 1
fi
echo ""
echo ""
echo " SOVRAN FACTORY SEAL WARNING "
echo ""
echo " This command will PERMANENTLY DELETE: "
echo " All generated passwords and secrets "
echo " LND wallet data (seed words, channels, macaroons) "
echo " SSH factory login key "
echo " Application databases (Matrix, Nextcloud, WordPress) "
echo " Vaultwarden database "
echo " "
echo " After sealing, all credentials will be regenerated fresh "
echo " when the customer boots the device for the first time. "
echo " "
echo " DO NOT run this on a customer's live system. "
echo ""
echo ""
echo -n "Type SEAL to confirm: "
read -r CONFIRM
if [ "$CONFIRM" != "SEAL" ]; then
echo "Aborted." >&2
exit 1
fi
echo ""
echo "Sealing system..."
# 1. Delete all generated secrets
echo " Wiping secrets..."
[ -d /var/lib/secrets ] && find /var/lib/secrets -mindepth 1 -delete || true
rm -rf /var/lib/matrix-synapse/registration-secret
rm -rf /var/lib/matrix-synapse/db-password
rm -rf /var/lib/gnome-remote-desktop/rdp-password
rm -rf /var/lib/gnome-remote-desktop/rdp-username
rm -rf /var/lib/gnome-remote-desktop/rdp-credentials
rm -rf /var/lib/livekit/livekit_keyFile
rm -rf /etc/nix-bitcoin-secrets/*
# 2. Wipe LND wallet (seed words, wallet DB, macaroons)
echo " Wiping LND wallet data..."
rm -rf /var/lib/lnd/*
# 3. Wipe SSH factory key so it regenerates with new passphrase
echo " Removing SSH factory key..."
rm -f /home/free/.ssh/factory_login /home/free/.ssh/factory_login.pub
if [ -f /root/.ssh/authorized_keys ]; then
sed -i '/factory_login/d' /root/.ssh/authorized_keys
fi
# 4. Drop application databases
echo " Dropping application databases..."
sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"matrix-synapse\";" 2>/dev/null || true
sudo -u postgres psql -c "DROP DATABASE IF EXISTS nextclouddb;" 2>/dev/null || true
mysql -u root -e "DROP DATABASE IF EXISTS wordpressdb;" 2>/dev/null || true
# 5. Remove application config files (so init services re-run)
echo " Removing application config files..."
rm -rf /var/lib/www/wordpress/wp-config.php
rm -rf /var/lib/www/nextcloud/config/config.php
# 6. Wipe Vaultwarden database
echo " Wiping Vaultwarden data..."
rm -rf /var/lib/bitwarden_rs/*
rm -rf /var/lib/vaultwarden/*
# 7. Set sealed flag and remove onboarded flag
echo " Setting sealed flag..."
touch /var/lib/sovran-factory-sealed
rm -f /var/lib/sovran-customer-onboarded
echo ""
echo "System sealed. Power off now or the system will shut down in 10 seconds."
sleep 10
poweroff
'';
in
{
environment.systemPackages = [ sovran-factory-seal ];
# ── Auto-seal on first customer boot ───────────────────────────────
systemd.services.sovran-auto-seal = {
description = "Auto-seal Sovran system on first customer boot";
wantedBy = [ "multi-user.target" ];
before = [ "sovran-hub.service" "sovran-legacy-security-check.service" ];
after = [ "local-fs.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.coreutils pkgs.e2fsprogs pkgs.openssl pkgs.postgresql pkgs.mariadb pkgs.shadow ];
script = ''
# Idempotency check
if [ -f /var/lib/sovran-factory-sealed ]; then
echo "sovran-auto-seal: already sealed, nothing to do."
exit 0
fi
echo "sovran-auto-seal: seal flag missing checking system state..."
# Safety guard 1: customer has already onboarded
if [ -f /var/lib/sovran-customer-onboarded ]; then
echo "sovran-auto-seal: /var/lib/sovran-customer-onboarded exists live system detected. Restoring flag and exiting."
touch /var/lib/sovran-factory-sealed
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
exit 0
fi
# Safety guard 2: onboarding was completed
if [ -f /var/lib/sovran/onboarding-complete ]; then
echo "sovran-auto-seal: /var/lib/sovran/onboarding-complete exists live system detected. Restoring flag and exiting."
touch /var/lib/sovran-factory-sealed
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
exit 0
fi
# Safety guard 3: password has been changed from factory defaults
if [ -f /etc/shadow ]; then
FREE_HASH=$(grep '^free:' /etc/shadow | cut -d: -f2)
if [ -n "$FREE_HASH" ] && [ "$FREE_HASH" != "!" ] && [ "$FREE_HASH" != "*" ]; then
ALGO_ID=$(printf '%s' "$FREE_HASH" | cut -d'$' -f2)
SALT=$(printf '%s' "$FREE_HASH" | cut -d'$' -f3)
STILL_DEFAULT=false
# If the salt field starts with "rounds=", we cannot extract the real salt
# with a simple cut treat as still-default for safety
if printf '%s' "$SALT" | grep -q '^rounds='; then
STILL_DEFAULT=true
else
for DEFAULT_PW in "free" "gosovransystems"; do
case "$ALGO_ID" in
6) EXPECTED=$(openssl passwd -6 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
5) EXPECTED=$(openssl passwd -5 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
*)
# Unknown hash algorithm treat as still-default for safety
STILL_DEFAULT=true
break
;;
esac
if [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$FREE_HASH" ]; then
STILL_DEFAULT=true
break
fi
done
fi
if [ "$STILL_DEFAULT" = "false" ]; then
echo "sovran-auto-seal: password has been changed from factory defaults live system detected. Restoring flag and exiting."
touch /var/lib/sovran-factory-sealed
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
exit 0
fi
fi
fi
# All safety guards passed: this is a fresh/unsealed system
echo "sovran-auto-seal: fresh system confirmed performing auto-seal..."
# 1. Wipe generated secrets
echo "sovran-auto-seal: wiping secrets..."
[ -d /var/lib/secrets ] && find /var/lib/secrets -mindepth 1 -delete || true
rm -rf /var/lib/matrix-synapse/registration-secret
rm -rf /var/lib/matrix-synapse/db-password
rm -rf /var/lib/gnome-remote-desktop/rdp-password
rm -rf /var/lib/gnome-remote-desktop/rdp-username
rm -rf /var/lib/gnome-remote-desktop/rdp-credentials
rm -rf /var/lib/livekit/livekit_keyFile
rm -rf /etc/nix-bitcoin-secrets/*
# 2. Wipe LND wallet data
echo "sovran-auto-seal: wiping LND wallet data..."
rm -rf /var/lib/lnd/*
# 3. Remove SSH factory key
echo "sovran-auto-seal: removing SSH factory key..."
rm -f /home/free/.ssh/factory_login /home/free/.ssh/factory_login.pub
if [ -f /root/.ssh/authorized_keys ]; then
sed -i '/factory_login/d' /root/.ssh/authorized_keys
fi
# 4. Drop application databases
echo "sovran-auto-seal: dropping application databases..."
sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"matrix-synapse\";" 2>/dev/null || true
sudo -u postgres psql -c "DROP DATABASE IF EXISTS nextclouddb;" 2>/dev/null || true
mysql -u root -e "DROP DATABASE IF EXISTS wordpressdb;" 2>/dev/null || true
# 5. Remove application config files
echo "sovran-auto-seal: removing application config files..."
rm -rf /var/lib/www/wordpress/wp-config.php
rm -rf /var/lib/www/nextcloud/config/config.php
# 6. Wipe Vaultwarden data
echo "sovran-auto-seal: wiping Vaultwarden data..."
rm -rf /var/lib/bitwarden_rs/*
rm -rf /var/lib/vaultwarden/*
# 7. Set sealed flag and make it immutable
echo "sovran-auto-seal: setting sealed flag..."
touch /var/lib/sovran-factory-sealed
chattr +i /var/lib/sovran-factory-sealed 2>/dev/null || true
# 8. Remove onboarded flag so onboarding runs fresh
rm -f /var/lib/sovran-customer-onboarded
echo "sovran-auto-seal: auto-seal complete. Continuing boot into onboarding."
'';
};
# ── Legacy security check: warn existing (pre-seal) machines ───────
systemd.services.sovran-legacy-security-check = {
description = "Check for legacy (pre-factory-seal) security status";
wantedBy = [ "multi-user.target" ];
after = [ "local-fs.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.coreutils pkgs.openssl ];
script = ''
# If sealed AND onboarded fully clean, nothing to do
[ -f /var/lib/sovran-factory-sealed ] && [ -f /var/lib/sovran-customer-onboarded ] && exit 0
# If sealed but not yet onboarded seal was run, customer hasn't finished setup yet, that's fine
[ -f /var/lib/sovran-factory-sealed ] && exit 0
# If onboarded but NOT sealed installer ran without factory seal!
if [ -f /var/lib/sovran-customer-onboarded ] && [ ! -f /var/lib/sovran-factory-sealed ]; then
mkdir -p /var/lib/sovran
echo "unsealed" > /var/lib/sovran/security-status
cat > /var/lib/sovran/security-warning << 'EOF'
This machine was set up without the factory seal process. Factory test data including SSH keys, database contents, and wallet information may still be present on this system. It is strongly recommended to back up any important data and re-install using a fresh ISO, or contact Sovran Systems support for assistance.
EOF
exit 0
fi
# If the user completed Hub onboarding, they've addressed security
[ -f /var/lib/sovran/onboarding-complete ] && exit 0
# If the free password has been changed from ALL known factory defaults, no warning needed
if [ -f /etc/shadow ]; then
FREE_HASH=$(grep '^free:' /etc/shadow | cut -d: -f2)
if [ -n "$FREE_HASH" ] && [ "$FREE_HASH" != "!" ] && [ "$FREE_HASH" != "*" ]; then
ALGO_ID=$(printf '%s' "$FREE_HASH" | cut -d'$' -f2)
SALT=$(printf '%s' "$FREE_HASH" | cut -d'$' -f3)
STILL_DEFAULT=false
# If the salt field starts with "rounds=", we cannot extract the real salt
# with a simple cut treat as still-default for safety
if printf '%s' "$SALT" | grep -q '^rounds='; then
STILL_DEFAULT=true
else
for DEFAULT_PW in "free" "gosovransystems"; do
case "$ALGO_ID" in
6) EXPECTED=$(openssl passwd -6 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
5) EXPECTED=$(openssl passwd -5 -salt "$SALT" "$DEFAULT_PW" 2>/dev/null) ;;
*)
# Unknown hash algorithm treat as still-default for safety
STILL_DEFAULT=true
break
;;
esac
if [ -n "$EXPECTED" ] && [ "$EXPECTED" = "$FREE_HASH" ]; then
STILL_DEFAULT=true
break
fi
done
fi
if [ "$STILL_DEFAULT" = "false" ]; then
# Password was changed clear any legacy warning and exit
rm -f /var/lib/sovran/security-status /var/lib/sovran/security-warning
exit 0
fi
fi
fi
# No flags at all + secrets exist = legacy (pre-seal era) machine
if [ -f /var/lib/secrets/root-password ]; then
mkdir -p /var/lib/sovran
echo "legacy" > /var/lib/sovran/security-status
echo "This system was deployed before the factory seal feature. Your passwords may be known to the factory. Please change your passwords through the Sovran Hub." > /var/lib/sovran/security-warning
fi
'';
};
}
+33
View File
@@ -0,0 +1,33 @@
{ config, lib, pkgs, ... }:
{
# ── Legacy Cleanup ─────────────────────────────────────────────
# Removes deprecated apps and folders from the old Sovran_Systems
# repo that are no longer needed under staging_alpha.
# This runs on every activation but is idempotent (no-ops if
# the files are already gone).
system.activationScripts.cleanupLegacySovran = lib.stringAfter [ "users" ] ''
echo " Cleaning up legacy Sovran_Systems artifacts "
# Remove deprecated .desktop files
for f in \
/home/free/.local/share/applications/Sovran_SystemsOS_External_Backup.desktop \
/home/free/.local/share/applications/Sovran_SystemsOS_Updater.desktop \
/home/free/.local/share/applications/Sovran_SystemsOS_Resetter.desktop
do
if [ -f "$f" ]; then
rm -f "$f"
echo " Removed: $f"
fi
done
# Remove legacy Sovran_Systems folder (skip if it's a symlink)
if [ -d /home/free/.Sovran_Systems ] && [ ! -L /home/free/.Sovran_Systems ]; then
rm -rf /home/free/.Sovran_Systems
echo " Removed: /home/free/.Sovran_Systems/"
fi
echo " Legacy cleanup complete "
'';
}
+171
View File
@@ -0,0 +1,171 @@
{ config, pkgs, lib, ... }:
# ── Server-local domain loopback overrides ────────────────────────────────────
#
# Some routers (especially newer ISP-provided devices) do not support NAT
# loopback (hairpin NAT). When a request originates on this computer and
# targets a public domain name that resolves to the router's WAN address, the
# router may refuse to loop the connection back in — causing Nextcloud, WordPress
# background jobs, and other server-side callbacks to fail even when the service
# is fully operational from the internet.
#
# This module installs a one-shot systemd service,
# ``sovran-hosts-update.service``, that reads the configured service domains
# from ``/var/lib/domains/`` at boot (and whenever triggered by the Hub after a
# domain is saved) and writes ``127.0.0.1`` entries for them into a dedicated
# Sovran-managed block in ``/etc/hosts``.
#
# With those entries in place:
# • Requests originating on this computer resolve the public domain name to
# 127.0.0.1, reach Caddy directly, and never touch the router.
# • Caddy still receives the correct public hostname via TLS SNI so virtual-
# host routing and certificate validation continue to work.
# • The Sovran Hub can verify Caddy reachability locally without needing NAT
# loopback.
#
# Limitation: this does not help other devices on your home network (phones,
# laptops). Those devices resolve domains via the router's DNS and still depend
# on NAT loopback (or require manual router DNS overrides). For now, only
# server-originated requests benefit from this override.
#
# On NixOS, /etc/hosts is normally a symlink into the Nix store and is
# regenerated by the system activation script. The ``system.activationScripts``
# hook below converts it to a writable file each time the system is activated
# (i.e. after every ``nixos-rebuild switch``) and then injects the Sovran block.
# The same wrapped Nix-store executable is reused by both the activation hook
# and the ``sovran-hosts-update.service`` unit, ensuring a deterministic runtime
# PATH in every execution context.
let
# ── Wrapped Nix-store executable ──────────────────────────────────────────
# Built with pkgs.writeShellApplication so that all required runtime tools
# (awk, grep, coreutils) are declared explicitly and injected into PATH by
# Nix. Both the systemd service and the activation hook reference this same
# store-path executable — there is no second raw script body.
hostsUpdateScript = pkgs.writeShellApplication {
name = "sovran-hosts-update";
# Declare every external command the script calls. These packages are
# added to the script's runtime PATH by writeShellApplication; nothing from
# the system PATH is relied upon.
runtimeInputs = [
pkgs.coreutils # readlink, cp, mv, chmod, mktemp, rm, tr, head
pkgs.gawk # awk
pkgs.gnugrep # grep
];
text = ''
# Regenerate the Sovran-managed loopback block in /etc/hosts.
# Safe to run multiple times idempotent.
DOMAINS_DIR="/var/lib/domains"
HOSTS_FILE="/etc/hosts"
BEGIN_MARKER="# Sovran managed begin server-local loopback overrides"
END_MARKER="# Sovran managed end"
# Step 1: ensure /etc/hosts is a regular writable file
# On NixOS /etc/hosts starts as a symlink to the Nix store. We replace
# it with a copy so we can append our block without touching the store.
if [ -L "$HOSTS_FILE" ]; then
TARGET=$(readlink -f "$HOSTS_FILE")
cp --no-preserve=all "$TARGET" "$HOSTS_FILE.sovran-tmp"
mv "$HOSTS_FILE.sovran-tmp" "$HOSTS_FILE"
chmod 644 "$HOSTS_FILE"
fi
# Step 2: remove any existing Sovran block
# Use a temp file so the operation is atomic.
# awk -v passes marker strings safely without shell interpolation.
TMP=$(mktemp "$HOSTS_FILE.XXXXXX")
trap 'rm -f "$TMP"' EXIT
awk -v begin="$BEGIN_MARKER" -v end="$END_MARKER" '
$0 == begin { skip=1; next }
$0 == end { skip=0; next }
!skip
' "$HOSTS_FILE" > "$TMP"
# Step 3: collect valid configured service domains
# NOTE: The hostname validation regex below must stay in sync with
# _SAFE_DOMAIN_RE in app/sovran_systemsos_web/server.py.
ENTRIES=""
for KEY in matrix wordpress nextcloud btcpayserver vaultwarden haven element-calling; do
FILE="$DOMAINS_DIR/$KEY"
[ -f "$FILE" ] || continue
# Read the domain value (strip all whitespace, limit to 253 chars)
DOMAIN=$(tr -d '[:space:]' < "$FILE" | head -c 253)
[ -z "$DOMAIN" ] && continue
# Validate: must match a reasonable hostname pattern (no injection)
if ! printf '%s' "$DOMAIN" | grep -qE \
'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$'; then
echo "sovran-hosts-update: skipping invalid domain value for $KEY: $DOMAIN" >&2
continue
fi
ENTRIES="$ENTRIES
127.0.0.1 $DOMAIN
::1 $DOMAIN"
done
# Step 4: append the Sovran block if there are any entries
if [ -n "$ENTRIES" ]; then
{
printf '\n%s\n' "$BEGIN_MARKER"
printf '%s\n' "# These entries route configured service domains to local Caddy."
printf '%s\n' "# They are managed automatically do not edit this block."
printf '%s\n' "$ENTRIES"
printf '%s\n' "$END_MARKER"
} >> "$TMP"
fi
# Step 5: atomically replace /etc/hosts
mv "$TMP" "$HOSTS_FILE"
chmod 644 "$HOSTS_FILE"
'';
};
in
{
# ── /etc/sovran-hosts-update.sh — operator discoverability symlink ─────────
# Retain the familiar /etc path so administrators can inspect or manually
# invoke the helper. The target is the wrapped Nix-store executable, so
# there is no second raw script body to keep in sync.
environment.etc."sovran-hosts-update.sh".source = lib.getExe hostsUpdateScript;
# ── Systemd service ────────────────────────────────────────────────────────
systemd.services.sovran-hosts-update = {
description = "Update /etc/hosts with Sovran server-local loopback overrides";
documentation = [ "https://github.com/naturallaw777/sovran-systems" ];
# Run before Caddy so loopback entries are ready when it starts.
before = [
"caddy.service"
"network-online.target"
];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
# Point directly at the wrapped Nix-store executable, not the /etc path.
ExecStart = lib.getExe hostsUpdateScript;
};
};
# ── Activation script (runs after every nixos-rebuild switch) ─────────────
# This ensures the loopback block survives rebuilds that restore the /etc/hosts
# symlink. The "users" and "etc" scripts must complete first.
# The same wrapped Nix-store executable used by the systemd service is
# referenced here, guaranteeing identical runtime dependencies in both
# execution contexts.
system.activationScripts.sovranDomainLoopback = {
text = ''
if [ -d /var/lib/domains ]; then
if ! ${lib.getExe hostsUpdateScript}; then
echo "warning: sovran-hosts-update: failed to update /etc/hosts loopback entries" >&2
fi
fi
'';
deps = [ "etc" "users" ];
};
}
+40
View File
@@ -0,0 +1,40 @@
# ── modules/core/no-sleep.nix ─────────────────────────────────────────────────
# Prevents the machine from ever sleeping or suspending at the system level.
#
# Only applies to server_plus_desktop and node roles. Desktop-only installs
# retain normal OS sleep/suspend behaviour.
#
# This operates at two layers below GNOME:
# 1. systemd-logind — ignores all hardware power events (lid, suspend key, etc.)
# 2. systemd targets — masks sleep/suspend/hibernate targets so nothing can
# trigger them, not even `systemctl suspend` or D-Bus calls.
#
# This is intentional for a 24/7 server/node. The GNOME-layer power settings in
# sovran_systemsos-desktop.nix remain in place as a belt-and-suspenders complement
# for active user sessions.
{ config, lib, ... }:
{
config = lib.mkIf (!config.sovran_systemsOS.roles.desktop) {
# ── Layer 1: logind hardware event handling ──────────────────────────────
services.logind.settings.Login = {
HandleLidSwitch = "ignore";
HandleLidSwitchDocked = "ignore";
HandleLidSwitchExternalPower = "ignore";
HandleSuspendKey = "ignore";
HandleHibernateKey = "ignore";
HandlePowerKey = "ignore";
IdleAction = "ignore";
IdleActionSec = 0;
};
# ── Layer 2: mask systemd sleep targets ─────────────────────────────────
# Nothing on the system can suspend/hibernate — not root, not GNOME, not D-Bus.
systemd.targets.sleep.enable = false;
systemd.targets.suspend.enable = false;
systemd.targets.hibernate.enable = false;
systemd.targets.hybrid-sleep.enable = false;
};
}
+121
View File
@@ -0,0 +1,121 @@
{ config, lib, pkgs, ... }:
let
cfg = config.sovran_systemsOS.deploy;
in
{
options.sovran_systemsOS.deploy = {
enable = lib.mkEnableOption "Remote deploy mode";
authorizedKey = lib.mkOption {
type = lib.types.str;
default = "";
description = "Deployer's SSH public key for root access";
};
headscaleServer = lib.mkOption {
type = lib.types.str;
default = "";
description = "Headscale login server URL (e.g. https://hs.sovransystems.com). If set, Tailscale is used for post-install connectivity.";
};
headscaleAuthKeyFile = lib.mkOption {
type = lib.types.str;
default = "/var/lib/secrets/headscale-authkey";
description = "Path to file containing the Headscale pre-auth key for post-install enrollment";
};
};
config = lib.mkIf cfg.enable {
# ── Force SSH open on all interfaces ────────────────────────────────────
services.openssh = {
enable = true;
listenAddresses = lib.mkForce [
{ addr = "0.0.0.0"; port = 22; }
{ addr = "127.0.0.1"; port = 22; }
];
settings = {
PermitRootLogin = lib.mkForce "prohibit-password";
PasswordAuthentication = lib.mkForce false;
};
};
networking.firewall.allowedTCPPorts = [ 22 ];
# ── Inject deployer's SSH public key into root's authorized keys ─────────
users.users.root.openssh.authorizedKeys.keys =
lib.mkIf (cfg.authorizedKey != "") [ cfg.authorizedKey ];
# ── Force RDP on ─────────────────────────────────────────────────────────
sovran_systemsOS.features.rdp = lib.mkForce true;
# ── Enable Fail2Ban for SSH protection ───────────────────────────────────
services.fail2ban = {
enable = true;
ignoreIP = [ "127.0.0.0/8" ];
};
# ── Tailscale / Headscale VPN (only when headscaleServer is configured) ──
services.tailscale = lib.mkIf (cfg.headscaleServer != "") {
enable = true;
};
environment.systemPackages = lib.mkIf (cfg.headscaleServer != "") [ pkgs.tailscale ];
systemd.services.deploy-tailscale-connect = lib.mkIf (cfg.headscaleServer != "") {
description = "Connect to Headscale Tailnet for post-install remote access";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" "tailscaled.service" ];
wants = [ "network-online.target" "tailscaled.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
AUTH_KEY_FILE="${cfg.headscaleAuthKeyFile}"
if [ ! -f "$AUTH_KEY_FILE" ]; then
echo "Headscale auth key file not found: $AUTH_KEY_FILE skipping Tailscale enrollment"
exit 0
fi
AUTH_KEY=$(cat "$AUTH_KEY_FILE")
[ -n "$AUTH_KEY" ] || { echo "Auth key file is empty, skipping"; exit 0; }
HOSTNAME_SUFFIX=$(hostname | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/-\{2,\}/-/g; s/^-//; s/-$//')
HOSTNAME="sovran-$HOSTNAME_SUFFIX"
echo "Joining Tailnet via ${cfg.headscaleServer} as $HOSTNAME..."
${pkgs.tailscale}/bin/tailscale up \
--login-server="${cfg.headscaleServer}" \
--authkey="$AUTH_KEY" \
--hostname="$HOSTNAME"
echo "Tailscale IP: $(${pkgs.tailscale}/bin/tailscale ip -4 2>/dev/null || echo 'pending')"
'';
path = [ pkgs.tailscale pkgs.coreutils ];
};
# ── Safety auto-expiry service ────────────────────────────────────────────
systemd.services.deploy-auto-expire = {
description = "Auto-expire remote deploy mode after 48 hours";
wantedBy = [ "multi-user.target" ];
after = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = false;
};
script = ''
# 48 hours = 172800 seconds
sleep $((48 * 60 * 60))
systemctl stop deploy-tailscale-connect || true
mkdir -p /etc/sovran
echo "expired" > /etc/sovran/deploy-mode
'';
path = [ pkgs.coreutils ];
};
# ── Deploy-mode indicator file ────────────────────────────────────────────
environment.etc."sovran/deploy-mode".text = "active";
};
}
+24 -8
View File
@@ -3,27 +3,44 @@
{
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) ─────────────────────────
(lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop {
sovran_systemsOS.web.btcpayserver = lib.mkDefault true;
})
# ── Desktop Only Role ─────────────────────────────────────
(lib.mkIf config.sovran_systemsOS.roles.desktop {
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 = {
synapse = lib.mkDefault false;
bitcoin = lib.mkDefault false;
vaultwarden = lib.mkDefault false;
wordpress = lib.mkDefault false;
nextcloud = lib.mkDefault false;
synapse = lib.mkForce false;
bitcoin = lib.mkForce false;
vaultwarden = lib.mkForce false;
wordpress = lib.mkForce 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 ecosystem + mempool + bip110, BTCPay runs but not exposed via Caddy
# Bitcoin ecosystem + mempool, BTCPay runs but not exposed via Caddy
(lib.mkIf config.sovran_systemsOS.roles.node {
sovran_systemsOS.services = {
bitcoin = lib.mkDefault true;
@@ -35,7 +52,6 @@
sovran_systemsOS.features = {
mempool = lib.mkDefault true;
bip110 = lib.mkDefault true;
};
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
+25 -2
View File
@@ -43,19 +43,31 @@
# ── Features (default OFF — user can enable in custom.nix) ──
features = {
haven = lib.mkEnableOption "Haven NOSTR relay";
bip110 = lib.mkEnableOption "BIP-110 Bitcoin Better Money";
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
rdp = lib.mkEnableOption "Gnome Remote Desktop";
sshd = lib.mkEnableOption "SSH remote access";
# Deprecated: BIP-110 is now built into mainline Bitcoin Knots and is the
# default node. This option is retained ONLY so that existing machines with
# `sovran_systemsOS.features.bip110 = lib.mkForce true;` left in their local
# custom.nix continue to evaluate. It has no effect and will be removed in a
# future release once the Hub has cleaned up old custom.nix files.
bip110 = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
internal = true;
visible = false;
description = "(Deprecated, no-op) BIP-110 is now built into Bitcoin Knots.";
};
};
# ── Web exposure (controls Caddy vhosts) ──────────────────
web = {
btcpayserver = lib.mkOption {
type = lib.types.bool;
default = true;
default = false;
description = "Expose BTCPay Server via Caddy";
};
};
@@ -89,4 +101,15 @@
description = "Nostr public key (npub1...) for Haven relay";
};
};
config = lib.mkIf (config.sovran_systemsOS.features.bip110 != null) {
warnings = [
''
sovran_systemsOS.features.bip110 is deprecated and has no effect:
BIP-110 is now built into mainline Bitcoin Knots, which is the default node.
You can safely remove the `sovran_systemsOS.features.bip110` line from
/etc/nixos/custom.nix. The Sovran Hub will also remove it automatically.
''
];
};
}
+126 -36
View File
@@ -8,9 +8,9 @@ let
[
{ name = "System Passwords"; unit = "root-password-setup.service"; type = "system"; icon = "passwords"; enabled = true; category = "infrastructure"; credentials = [
{ label = "Free Account Username"; value = "free"; }
{ label = "Free Account Password"; file = "/var/lib/secrets/free-password"; }
{ label = "Root Password"; file = "/var/lib/secrets/root-password"; }
{ label = "SSH Passphrase"; file = "/var/lib/secrets/ssh-passphrase"; }
{ label = "Free Account / Hub Login Password"; file = "/var/lib/secrets/free-password"; }
{ label = "Administrator (root) Password"; file = "/var/lib/secrets/root-password"; }
{ label = "SSH Passphrase use via: ssh root@localhost"; file = "/var/lib/secrets/ssh-passphrase"; }
]; }
]
# ── Infrastructure — Caddy + Tor (NOT desktop-only) ────────
@@ -24,52 +24,47 @@ let
{ label = "Username"; file = "/var/lib/gnome-remote-desktop/rdp-username"; }
{ label = "Password"; file = "/var/lib/gnome-remote-desktop/rdp-password"; }
{ label = "Address"; file = "/var/lib/secrets/internal-ip"; suffix = ":3389"; }
{ label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above\n5. Connect you will see your desktop remotely"; }
{ label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above"; }
]; }
]
# ── Bitcoin Base (node implementations) ────────────────────
++ lib.optionals cfg.services.bitcoin [
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; credentials = [
{ label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
]; }
{ name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; credentials = [
{ label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
]; }
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
{ label = "Tor Address"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
]; }
]
# ── Bitcoin Apps (services on top of the node) ─────────────
++ lib.optionals cfg.services.bitcoin [
{ name = "Electrs"; unit = "electrs.service"; type = "system"; icon = "electrs"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
{ label = "Tor Address"; file = "/var/lib/tor/onion/electrs/hostname"; prefix = "http://"; }
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/electrs/hostname"; prefix = "http://"; }
{ label = "Port"; value = "50001"; }
]; }
{ name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; }
{ name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
{ label = "Tor Access"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; }
{ label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; }
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; }
{ label = "Local Network Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; }
{ label = "Password"; file = "/etc/nix-bitcoin-secrets/rtl-password"; }
{ label = "How to Access"; value = " Tor Address: Open in Tor Browser from any device, anywhere in the world\n Local Network: Open in any browser, but only when connected to your home network"; }
]; }
{ name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
{ name = "BTCPayserver"; unit = "btcpayserver.service"; type = "system"; icon = "btcpayserver"; enabled = cfg.web.btcpayserver; category = "bitcoin-apps"; credentials = [
{ label = "URL"; file = "/var/lib/domains/btcpayserver"; prefix = "https://"; }
{ label = "Note"; value = "Create your admin account on first visit"; }
]; }
{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
{ label = "Connection URL"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; }
{ label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus Scan Node Config\n3. Scan the QR code above or paste the Connection URL"; }
{ label = "QR Code"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; qronly = true; }
{ label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus Scan Node Config\n3. Scan the QR code above"; }
]; }
{ name = "Sparrow Auto-Connect"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
{ name = "Sparrow Auto-Link"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
{ label = "Status"; value = "Auto-configured on first boot"; }
]; }
{ name = "Bisq Auto-Connect"; unit = "bisq-autoconnect.service"; type = "system"; icon = "bisq"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
{ label = "Node"; value = "127.0.0.1:8333 (Bitcoin Core)"; }
{ label = "Status"; value = "Auto-configured on first boot"; }
]; }
{ name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
{ label = "Tor Access"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
{ label = "Local Network"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
{ label = "Local Network Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
{ label = "How to Access"; value = " Tor Address: Open in Tor Browser from any device, anywhere in the world\n Local Network: Open in any browser, but only when connected to your home network"; }
]; }
]
# ── Communication (server+desktop only) ────────────────────
@@ -140,16 +135,27 @@ let
RC=0
echo " Step 1/3: nix flake update "
if ! nix flake update --flake /etc/nixos --print-build-logs 2>&1; then
if ! nix flake update --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 "[ERROR] nix flake update failed"
RC=1
fi
echo ""
if [ "$RC" -eq 0 ]; then
echo " Step 2/3: nixos-rebuild switch "
if ! nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1; then
echo "[ERROR] nixos-rebuild switch failed"
echo " Step 2/3: nixos-rebuild boot (stage next reboot) "
BOOT_OUT=$(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)
BOOT_RC=$?
echo "$BOOT_OUT"
if [ "$BOOT_RC" -ne 0 ]; then
echo "[ERROR] nixos-rebuild boot failed"
RC=1
fi
echo ""
@@ -165,9 +171,10 @@ let
if [ "$RC" -eq 0 ]; then
echo ""
echo " Update completed successfully"
echo " Update staged successfully"
echo " Reboot required to activate the new system"
echo ""
echo "SUCCESS" > "$STATUS"
echo "REBOOT_REQUIRED" > "$STATUS"
else
echo ""
echo " Update failed see errors above"
@@ -195,12 +202,34 @@ let
echo ""
echo ""
echo " Rebuilding system configuration "
if nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1; then
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1)
SWITCH_RC=$?
echo "$SWITCH_OUT"
if [ "$SWITCH_RC" -eq 0 ]; then
echo ""
echo ""
echo " Rebuild completed successfully"
echo ""
echo "SUCCESS" > "$STATUS"
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then
echo ""
echo " Build succeeded a reboot is required to apply this rebuild"
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"
else
echo "[ERROR] nixos-rebuild boot also failed"
echo "FAILED" > "$STATUS"
exit 1
fi
else
echo ""
echo ""
@@ -211,9 +240,28 @@ let
fi
'';
# ── Brave launcher wrapper: stable profile dir so Wayland app_id is
# deterministic and GNOME Shell can match the window to the .desktop
# entry (fixes generic gear icon appearing in the dock).
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
export PATH="${lib.makeBinPath [ pkgs.brave pkgs.coreutils ]}:$PATH"
HUB_DATA="/tmp/sovran-hub-brave-$(id -u)"
mkdir -p "$HUB_DATA"
trap '[ -n "$HUB_DATA" ] && rm -rf "$HUB_DATA"' EXIT INT TERM
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
brave --app=http://localhost:8937/auto-login \
--class=sovran-hub \
--user-data-dir="$HUB_DATA" \
--password-store=basic \
--disable-gpu \
--disable-features=WebRtcPipeWireCapturer \
--ozone-platform=wayland
'';
# ── Hub auto-launch wrapper script ────────────────────────────────
hub-autolaunch-script = pkgs.writeShellScript "sovran-hub-autolaunch.sh" ''
export PATH="${lib.makeBinPath [ pkgs.curl pkgs.xdg-utils ]}:$PATH"
export PATH="${lib.makeBinPath [ pkgs.curl pkgs.coreutils ]}:$PATH"
DISABLE_FLAG="/var/lib/sovran/hub-autolaunch-disabled"
BOOT_FLAG="/run/sovran-hub-autolaunch-done"
@@ -232,7 +280,7 @@ let
sleep 1
done
xdg-open http://localhost:8937
${hub-brave-wrapper}
'';
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
@@ -242,6 +290,8 @@ let
src = ../../app;
nativeBuildInputs = [ pkgs.librsvg ];
propagatedBuildInputs = with pkgs.python3Packages; [
fastapi
uvicorn
@@ -265,17 +315,24 @@ let
install -d $out/share/icons/hicolor/scalable/apps
cp sovran_systemsos_web/static/sovran-hub-icon.svg $out/share/icons/hicolor/scalable/apps/sovran-hub.svg
for size in 48 128 256 512; do
install -d $out/share/icons/hicolor/''${size}x''${size}/apps
rsvg-convert -w ''${size} -h ''${size} sovran_systemsos_web/static/sovran-hub-icon.svg -o $out/share/icons/hicolor/''${size}x''${size}/apps/sovran-hub.png
done
install -d $out/share/applications
cat > $out/share/applications/sovran-hub.desktop <<DESKTOP
[Desktop Entry]
Type=Application
Name=Sovran Hub
Comment=Open Sovran_SystemsOS Hub dashboard
Exec=xdg-open http://localhost:8937
Exec=${hub-brave-wrapper}
Icon=sovran-hub
Terminal=false
Categories=System;
StartupNotify=false
StartupNotify=true
StartupWMClass=brave-localhost__auto-login-Default
X-GNOME-SingleWindow=true
DESKTOP
install -d $out/bin
@@ -312,21 +369,44 @@ in
description = "Sovran_SystemsOS Hub Web Interface";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
conflicts = [ "sovran-hub-reboot.service" ];
serviceConfig = {
ExecStart = "${sovran-hub-web}/bin/sovran-hub-web";
Restart = "on-failure";
RestartPreventExitStatus = "SIGTERM";
RestartSec = "5s";
User = "root";
StandardOutput = "journal";
StandardError = "journal";
};
path = [ pkgs.qrencode ] ++ lib.optional cfg.services.bitcoin config.services.bitcoind.package;
path = [
pkgs.bash
pkgs.gawk
pkgs.qrencode
pkgs.curl
pkgs.iproute2
pkgs.nftables
pkgs.iptables
pkgs.hostname
pkgs.coreutils
pkgs.findutils
pkgs.gnugrep
pkgs.rsync
pkgs.acl
pkgs.util-linux
]
++ lib.optional cfg.services.bitcoin config.services.bitcoind.package
++ lib.optionals cfg.services.bitcoin [ pkgs.lnd ]
++ lib.optionals (cfg.services.nextcloud || cfg.services.synapse) [ config.services.postgresql.package ]
++ lib.optionals config.services.mysql.enable [ config.services.mysql.package ];
};
systemd.services.sovran-hub-update = {
description = "Sovran_SystemsOS System Update";
restartIfChanged = false; # Don't let nixos-rebuild kill an in-flight update
stopIfChanged = false; # Don't stop it during activation either
serviceConfig = {
Type = "oneshot";
ExecStart = "${update-script}";
@@ -335,15 +415,25 @@ in
systemd.services.sovran-hub-rebuild = {
description = "Sovran_SystemsOS System Rebuild";
restartIfChanged = false; # Don't let nixos-rebuild kill an in-flight rebuild
stopIfChanged = false; # Don't stop it during activation either
serviceConfig = {
Type = "oneshot";
ExecStart = "${rebuild-script}";
};
};
systemd.services.sovran-hub-reboot = {
description = "Sovran_SystemsOS System Reboot";
serviceConfig = {
Type = "oneshot";
ExecStart = "/run/current-system/sw/bin/systemctl --force reboot";
};
};
environment.systemPackages = [ sovran-hub-web ];
networking.firewall.allowedTCPPorts = [ 3051 8937 60847 ];
networking.firewall.allowedTCPPorts = [ 8937 60847 ];
# ── Auto-launch Hub in browser on login ───────────────────────
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
+423
View File
@@ -0,0 +1,423 @@
{ config, lib, pkgs, ... }:
# ── sovran-provisioner.nix ────────────────────────────────────────────────────
# NixOS module for the Sovran Systems VPS provisioning server.
#
# Deploys:
# - Headscale (coordination server, listening on 127.0.0.1:8080)
# - Python Flask provisioning API (port 9090)
# - Caddy reverse proxy (80/443 with automatic TLS)
# - Bootstrap service (creates Headscale users + enrollment token on first boot)
#
# Headscale 0.28.0 compatible — uses numeric user IDs (-u <id>) throughout.
# ─────────────────────────────────────────────────────────────────────────────
let
cfg = config.sovranProvisioner;
# ── Python Flask provisioner script ────────────────────────────────────────
provisionerScript = pkgs.writeText "sovran-provisioner.py" ''
#!/usr/bin/env python3
"""
Sovran Systems provisioning API Headscale 0.28.0 compatible.
Endpoints:
POST /register register a new machine and return a Headscale pre-auth key
GET /machines list registered machines (requires Bearer token)
GET /health liveness check
"""
import json
import os
import subprocess
import time
from collections import defaultdict
from functools import wraps
from pathlib import Path
from flask import Flask, request, jsonify, abort
app = Flask(__name__)
# Configuration
DATA_DIR = Path(os.environ.get("PROVISIONER_DATA_DIR", "/var/lib/sovran-provisioner"))
TOKEN_FILE = DATA_DIR / "enroll-token"
MACHINES_FILE = DATA_DIR / "machines.json"
HEADSCALE_USER = os.environ.get("HEADSCALE_USER", "sovran-deploy")
KEY_EXPIRY = os.environ.get("KEY_EXPIRY", "1h")
RATE_LIMIT_MAX = int(os.environ.get("RATE_LIMIT_MAX", "10"))
RATE_LIMIT_WIN = int(os.environ.get("RATE_LIMIT_WINDOW", "60"))
# Simple in-memory rate limiter
_rate_buckets: dict = defaultdict(list)
def _rate_limit_check(key: str) -> bool:
"""Return True if the request is allowed, False if rate-limited."""
now = time.monotonic()
bucket = _rate_buckets[key]
# Purge entries outside the window
_rate_buckets[key] = [t for t in bucket if now - t < RATE_LIMIT_WIN]
if len(_rate_buckets[key]) >= RATE_LIMIT_MAX:
return False
_rate_buckets[key].append(now)
return True
# Helper: read enrollment token
def _get_token() -> str:
try:
return TOKEN_FILE.read_text().strip()
except FileNotFoundError:
return ""
# Helper: require Bearer token
def require_token(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
abort(401)
token = auth[len("Bearer "):].strip()
expected = _get_token()
if not expected or token != expected:
abort(401)
return f(*args, **kwargs)
return decorated
# Helper: persist machine record
def _save_machine(hostname: str, mac: str, tailscale_ip: str = ""):
machines = _load_machines()
machines[mac] = {
"hostname": hostname,
"mac": mac,
"registered_at": time.time(),
"tailscale_ip": tailscale_ip,
}
MACHINES_FILE.write_text(json.dumps(machines, indent=2))
def _load_machines() -> dict:
try:
return json.loads(MACHINES_FILE.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return {}
# Headscale helpers (0.28.0 compatible)
def get_user_id(username: str):
"""Look up numeric user ID from username for Headscale 0.28.0."""
result = subprocess.run(
["headscale", "users", "list", "-o", "json"],
capture_output=True, text=True
)
if result.returncode != 0:
app.logger.error("headscale users list failed: %s", result.stderr)
return None
try:
users = json.loads(result.stdout)
except json.JSONDecodeError:
app.logger.error("headscale users list returned invalid JSON: %s", result.stdout)
return None
for user in users:
if user.get("name") == username:
return user.get("id")
return None
def create_preauthkey(user_id, expiry: str = "1h") -> str | None:
"""Create a pre-auth key using the numeric user ID (Headscale 0.28.0)."""
result = subprocess.run(
["headscale", "preauthkeys", "create",
"-u", str(user_id),
"-e", expiry,
"-o", "json"],
capture_output=True, text=True
)
if result.returncode != 0:
app.logger.error("headscale preauthkeys create failed: %s", result.stderr)
return None
try:
key_data = json.loads(result.stdout)
except json.JSONDecodeError:
app.logger.error("preauthkeys create returned invalid JSON: %s", result.stdout)
return None
return key_data.get("key")
# Routes
@app.route("/health")
def health():
return jsonify({"status": "ok"})
@app.route("/register", methods=["POST"])
@require_token
def register():
# Rate-limit by source IP
client_ip = request.remote_addr or "unknown"
if not _rate_limit_check(client_ip):
return jsonify({"error": "rate limit exceeded"}), 429
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "JSON body required"}), 400
hostname = data.get("hostname", "").strip()
mac = data.get("mac", "").strip()
if not hostname or not mac:
return jsonify({"error": "hostname and mac are required"}), 400
# Look up the numeric user ID (Headscale 0.28.0 requires -u <id>)
user_id = get_user_id(HEADSCALE_USER)
if user_id is None:
app.logger.error("Headscale user '%s' not found", HEADSCALE_USER)
return jsonify({"error": "provisioning user not found on Headscale server"}), 500
# Create a single-use pre-auth key
key = create_preauthkey(user_id, expiry=KEY_EXPIRY)
if key is None:
return jsonify({"error": "failed to create pre-auth key"}), 500
# Persist the registration record
_save_machine(hostname, mac)
login_server = os.environ.get("HEADSCALE_URL", "")
return jsonify({
"headscale_key": key,
"login_server": login_server,
"hostname": hostname,
})
@app.route("/machines")
@require_token
def machines():
return jsonify(list(_load_machines().values()))
# Entry point
if __name__ == "__main__":
DATA_DIR.mkdir(parents=True, exist_ok=True)
app.run(host="127.0.0.1", port=9090)
'';
# ── Headscale YAML config ──────────────────────────────────────────────────
headscaleConfig = pkgs.writeText "headscale.yaml" ''
server_url: https://${cfg.headscaleDomain}
listen_addr: 127.0.0.1:8080
metrics_listen_addr: 127.0.0.1:9090
# Logging
log:
level: info
# Database
database:
type: sqlite
sqlite:
path: /var/lib/headscale/db.sqlite
# DERP (relay/STUN)
derp:
server:
enabled: false
urls:
- https://controlplane.tailscale.com/derpmap/default
auto_update_enabled: true
update_frequency: 24h
# Disable magic DNS by default (clients opt in)
dns:
magic_dns: false
base_domain: sovran.internal
# Node expiry
node_update_check_interval: 10s
'';
in
{
# ── Module options ─────────────────────────────────────────────────────────
options.sovranProvisioner = {
enable = lib.mkEnableOption "Sovran Systems provisioning server (Headscale + Flask API + Caddy)";
domain = lib.mkOption {
type = lib.types.str;
description = "Public FQDN for the provisioning API (e.g. prov.yourdomain.com)";
};
headscaleDomain = lib.mkOption {
type = lib.types.str;
description = "Public FQDN for the Headscale coordination server (e.g. hs.yourdomain.com)";
};
headscaleUser = lib.mkOption {
type = lib.types.str;
default = "sovran-deploy";
description = "Headscale user namespace for deployed machines";
};
adminUser = lib.mkOption {
type = lib.types.str;
default = "admin";
description = "Headscale user namespace for admin workstations";
};
keyExpiry = lib.mkOption {
type = lib.types.str;
default = "1h";
description = "Lifetime of generated pre-auth keys (e.g. 1h, 2h, 24h)";
};
rateLimitMax = lib.mkOption {
type = lib.types.int;
default = 10;
description = "Maximum number of /register calls per rateLimitWindow seconds per IP";
};
rateLimitWindow = lib.mkOption {
type = lib.types.int;
default = 60;
description = "Rate-limit sliding window in seconds";
};
};
# ── Module implementation ──────────────────────────────────────────────────
config = lib.mkIf cfg.enable {
# ── Headscale ─────────────────────────────────────────────────────────────
services.headscale = {
enable = true;
address = "127.0.0.1";
port = 8080;
settings = {
server_url = "https://${cfg.headscaleDomain}";
listen_addr = "127.0.0.1:8080";
database = {
type = "sqlite";
sqlite = { path = "/var/lib/headscale/db.sqlite"; };
};
dns = {
magic_dns = false;
base_domain = "sovran.internal";
};
derp = {
server.enabled = false;
urls = [ "https://controlplane.tailscale.com/derpmap/default" ];
auto_update_enabled = true;
update_frequency = "24h";
};
log.level = "info";
};
};
# ── Python / Flask dependencies ────────────────────────────────────────────
environment.systemPackages = [
pkgs.headscale
(pkgs.python3.withPackages (ps: [ ps.flask ]))
];
# ── Provisioner systemd service ────────────────────────────────────────────
systemd.services.sovran-provisioner = {
description = "Sovran provisioning API";
after = [ "network-online.target" "headscale.service" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
PROVISIONER_DATA_DIR = "/var/lib/sovran-provisioner";
HEADSCALE_USER = cfg.headscaleUser;
KEY_EXPIRY = cfg.keyExpiry;
RATE_LIMIT_MAX = toString cfg.rateLimitMax;
RATE_LIMIT_WINDOW = toString cfg.rateLimitWindow;
HEADSCALE_URL = "https://${cfg.headscaleDomain}";
};
serviceConfig = {
Type = "simple";
Restart = "on-failure";
RestartSec = "5s";
DynamicUser = false;
User = "sovran-provisioner";
Group = "sovran-provisioner";
StateDirectory = "sovran-provisioner";
RuntimeDirectory = "sovran-provisioner";
ExecStart = "${pkgs.python3.withPackages (ps: [ ps.flask ])}/bin/python3 ${provisionerScript}";
};
};
# ── Dedicated system user for the provisioner ──────────────────────────────
users.users.sovran-provisioner = {
isSystemUser = true;
group = "sovran-provisioner";
description = "Sovran provisioning API service user";
};
users.groups.sovran-provisioner = {};
# Allow the provisioner user to call headscale CLI
security.sudo.extraRules = [{
users = [ "sovran-provisioner" ];
commands = [{
command = "${pkgs.headscale}/bin/headscale";
options = [ "NOPASSWD" ];
}];
}];
# ── Bootstrap service (first-boot: create Headscale users + enroll token) ──
systemd.services.sovran-provisioner-bootstrap = {
description = "Bootstrap Headscale users and enrollment token";
after = [ "headscale.service" ];
wants = [ "headscale.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
StateDirectory = "sovran-provisioner";
};
path = [ pkgs.headscale pkgs.coreutils pkgs.openssl ];
script = ''
DATA_DIR="/var/lib/sovran-provisioner"
TOKEN_FILE="$DATA_DIR/enroll-token"
STAMP="$DATA_DIR/.bootstrap-done"
# Idempotent only run once
[ -f "$STAMP" ] && exit 0
# Wait for headscale socket to be ready
for i in $(seq 1 30); do
headscale users list -o json >/dev/null 2>&1 && break
sleep 2
done
# Create headscale users if they don't exist
headscale users list -o json | grep -q '"name":"${cfg.headscaleUser}"' \
|| headscale users create ${cfg.headscaleUser}
headscale users list -o json | grep -q '"name":"${cfg.adminUser}"' \
|| headscale users create ${cfg.adminUser}
# Generate enrollment token if not already present
if [ ! -f "$TOKEN_FILE" ] || [ ! -s "$TOKEN_FILE" ]; then
openssl rand -hex 32 > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
fi
touch "$STAMP"
echo "Bootstrap complete."
'';
};
# ── Caddy reverse proxy ────────────────────────────────────────────────────
services.caddy = {
enable = true;
virtualHosts."${cfg.headscaleDomain}" = {
extraConfig = ''
reverse_proxy 127.0.0.1:8080
'';
};
virtualHosts."${cfg.domain}" = {
extraConfig = ''
reverse_proxy 127.0.0.1:9090
'';
};
};
# ── Firewall ────────────────────────────────────────────────────────────────
networking.firewall = {
allowedTCPPorts = [ 80 443 ];
allowedUDPPorts = [ 3478 ];
};
};
}
+310 -14
View File
@@ -2,25 +2,196 @@
let
wallpaperSrc = ../../assets/wallpapers;
customWallpaper = pkgs.stdenvNoCC.mkDerivation {
pname = "sovran-systemsos-wallpaper";
version = "1.0";
src = pkgs.fetchurl {
url = "https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS_iso/raw/branch/main/post-install-scripts/Wallpaper_Dark_Wide.png";
sha256 = "0609gy0vp92fywl7pcr4y3mg05ca6pwxsnlsax14jd371fj4y7fn";
};
dontUnpack = true;
version = "2.0";
src = wallpaperSrc;
nativeBuildInputs = [ pkgs.librsvg ];
installPhase = ''
mkdir -p $out/share/backgrounds/sovran
cp $src $out/share/backgrounds/sovran/Wallpaper_Dark_Wide.png
'';
rsvg-convert -w 3440 -h 1440 \
$src/sovran-wallpaper-12-ultrawide-3440x1440.svg \
-o $out/share/backgrounds/sovran/sovran-ultrawide.png
'';
};
sovranThemeInit = pkgs.writeShellScriptBin "sovran-theme-init" ''
STAMP="$HOME/.config/sovran-theme-applied"
USER_DB="$HOME/.config/dconf/user"
# Always apply wallpaper on version change
WALLPAPER_VERSION="${customWallpaper.version}"
WALLPAPER_STAMP="$HOME/.config/sovran-wallpaper-version"
BG_DIR="/run/current-system/sw/share/backgrounds/sovran"
ULTRAWIDE="$BG_DIR/sovran-ultrawide.png"
CURRENT_WALLPAPER_VERSION=""
if [ -r "$WALLPAPER_STAMP" ]; then
read -r CURRENT_WALLPAPER_VERSION < "$WALLPAPER_STAMP"
fi
if [ "$CURRENT_WALLPAPER_VERSION" != "$WALLPAPER_VERSION" ]; then
if [ -f "$ULTRAWIDE" ]; then
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-uri "'file://$ULTRAWIDE'"
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-uri-dark "'file://$ULTRAWIDE'"
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-options "'zoom'"
mkdir -p "$(dirname "$WALLPAPER_STAMP")"
echo "$WALLPAPER_VERSION" > "$WALLPAPER_STAMP"
fi
fi
# Already applied skip
if [ -f "$STAMP" ]; then
exit 0
fi
# Existing machine updating user already has their own settings, don't overwrite
if [ -f "$USER_DB" ]; then
mkdir -p "$HOME/.config"
touch "$STAMP"
exit 0
fi
# Fresh install no user-db exists yet, apply full Sovran theme below
mkdir -p "$HOME/.config"
cat > "$HOME/.config/mimeapps.list" << EOF
[Default Applications]
text/html=brave-browser.desktop
x-scheme-handler/http=brave-browser.desktop
x-scheme-handler/https=brave-browser.desktop
x-scheme-handler/about=brave-browser.desktop
x-scheme-handler/unknown=brave-browser.desktop
EOF
${pkgs.dconf}/bin/dconf load / << EOF
[org/gnome/desktop/interface]
color-scheme='prefer-dark'
enable-animations=true
icon-theme='Papirus-Dark'
[org/gnome/settings-daemon/plugins/power]
sleep-inactive-ac-type='nothing'
sleep-inactive-ac-timeout=0
sleep-inactive-battery-type='nothing'
sleep-inactive-battery-timeout=0
idle-dim=false
ambient-enabled=false
power-button-action='nothing'
[org/gnome/desktop/session]
idle-delay=uint32 0
[org/gnome/desktop/screensaver]
lock-enabled=false
idle-activation-enabled=false
[org/gnome/mutter]
edge-tiling=false
[org/gnome/nautilus/icon-view]
default-zoom-level='large'
[org/gnome/nautilus/preferences]
default-folder-viewer='icon-view'
migrated-gtk-settings=true
search-filter-time-type='last_modified'
[org/gnome/shell]
disabled-extensions=['just-perfection-desktop@just-perfection']
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'light-style@gnome-shell-extensions.gcampax.github.com']
favorite-apps=['brave-browser.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
welcome-dialog-last-shown-version='48.4'
[org/gnome/desktop/app-folders]
folder-children=['Browsers', 'Office', 'Terminal', 'Chat', 'Bitcoin', 'Media', 'System']
[org/gnome/desktop/app-folders/folders/Browsers]
name='Browsers'
apps=['brave-browser.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
[org/gnome/desktop/app-folders/folders/Office]
name='Office'
apps=['libreoffice-writer.desktop', 'libreoffice-calc.desktop', 'libreoffice-impress.desktop', 'libreoffice-draw.desktop', 'libreoffice-base.desktop', 'libreoffice-math.desktop', 'libreoffice-startcenter.desktop', 'org.gnome.TextEditor.desktop', 'org.gnome.gedit.desktop', 'org.gnome.Calculator.desktop', 'org.gnome.Calendar.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Evince.desktop', 'onlyoffice-desktopeditors.desktop', 'simple-scan.desktop', 'system-config-printer.desktop']
[org/gnome/desktop/app-folders/folders/Terminal]
name='Terminal'
apps=['org.gnome.Terminal.desktop', 'org.gnome.tweaks.desktop', 'gparted.desktop', 'htop.desktop', 'btop.desktop', 'ranger.desktop', 'org.gnome.Console.desktop']
[org/gnome/desktop/app-folders/folders/Chat]
name='Chat'
apps=['element-desktop.desktop']
[org/gnome/desktop/app-folders/folders/Bitcoin]
name='Bitcoin'
apps=['sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
[org/gnome/desktop/app-folders/folders/Media]
name='Media'
apps=['org.gnome.Loupe.desktop', 'org.gnome.Totem.desktop', 'org.gnome.Snapshot.desktop', 'org.gnome.Weather.desktop', 'org.gnome.Maps.desktop', 'org.gnome.Clocks.desktop', 'org.gnome.Music.desktop', 'org.gnome.Characters.desktop', 'org.gnome.font-viewer.desktop']
[org/gnome/desktop/app-folders/folders/System]
name='System'
apps=['org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'org.gnome.Software.desktop', 'sovran-hub.desktop', 'bitwarden.desktop', 'org.gnome.DiskUtility.desktop', 'org.gnome.SystemMonitor.desktop', 'org.gnome.Logs.desktop', 'org.gnome.Connections.desktop', 'org.gnome.baobab.desktop', 'zenity.desktop']
[org/gnome/shell/extensions/dash-to-dock]
background-color='rgb(0,0,0)'
background-opacity=0.50000000000000001
custom-background-color=true
dash-max-icon-size=47
dock-position='BOTTOM'
height-fraction=0.90000000000000002
preferred-monitor=-2
preferred-monitor-by-connector='Virtual-1'
show-trash=false
transparency-mode='FIXED'
[org/gnome/shell/extensions/date-menu-formatter]
font-size=12
pattern='EEEE, MMM d h:mm a'
text-align='center'
update-level=1
[org/gnome/shell/extensions/just-perfection]
support-notifier-showed-version=34
support-notifier-type=0
[org/gnome/shell/extensions/pop-shell]
tile-by-default=true
[org/gnome/shell/extensions/vitals]
hot-sensors=['_storage_free_', '_processor_usage_', '_memory_usage_']
[org/gnome/software]
first-run=false
[org/gtk/gtk4/settings/color-chooser]
selected-color=(true, 0.0, 0.0, 0.0, 1.0)
EOF
mkdir -p "$HOME/.config"
touch "$STAMP"
'';
in
{
environment.systemPackages = [ customWallpaper ];
environment.systemPackages = [ customWallpaper sovranThemeInit ];
environment.etc."xdg/autostart/sovran-theme-init.desktop".text = ''
[Desktop Entry]
Type=Application
Name=Sovran Theme Init
Exec=${sovranThemeInit}/bin/sovran-theme-init
X-GNOME-Autostart-enabled=true
X-GNOME-Autostart-Phase=Application
NoDisplay=true
'';
programs.dconf.enable = true;
@@ -29,8 +200,8 @@ in
settings = {
"org/gnome/desktop/background" = {
picture-uri = "file:///run/current-system/sw/share/backgrounds/sovran/Wallpaper_Dark_Wide.png";
picture-uri-dark = "file:///run/current-system/sw/share/backgrounds/sovran/Wallpaper_Dark_Wide.png";
picture-uri = "file:///run/current-system/sw/share/backgrounds/sovran/sovran-ultrawide.png";
picture-uri-dark = "file:///run/current-system/sw/share/backgrounds/sovran/sovran-ultrawide.png";
picture-options = "zoom";
primary-color = "#000000";
secondary-color = "#000000";
@@ -47,6 +218,25 @@ in
icon-theme = "Papirus-Dark";
};
"org/gnome/settings-daemon/plugins/power" = {
sleep-inactive-ac-type = "nothing";
sleep-inactive-ac-timeout = lib.gvariant.mkInt32 0;
sleep-inactive-battery-type = "nothing";
sleep-inactive-battery-timeout = lib.gvariant.mkInt32 0;
idle-dim = false;
ambient-enabled = false;
power-button-action = "nothing";
};
"org/gnome/desktop/session" = {
idle-delay = lib.gvariant.mkUint32 0;
};
"org/gnome/desktop/screensaver" = {
lock-enabled = false;
idle-activation-enabled = false;
};
"org/gnome/evolution-data-server" = {
migrated = true;
};
@@ -82,13 +272,12 @@ in
"brave-browser.desktop"
"org.gnome.Settings.desktop"
"org.gnome.Nautilus.desktop"
"Sovran_SystemsOS_Updater.desktop"
"sovran-hub.desktop"
"org.gnome.Software.desktop"
"org.gnome.Geary.desktop"
"org.gnome.Contacts.desktop"
"org.gnome.Calendar.desktop"
"sparrow-desktop.desktop"
"sparrow.desktop"
"Bisq.desktop"
"bisq2.desktop"
];
@@ -96,6 +285,103 @@ in
welcome-dialog-last-shown-version = "48.4";
};
"org/gnome/desktop/app-folders" = {
folder-children = [ "Browsers" "Office" "Terminal" "Chat" "Bitcoin" "Media" "System" ];
};
"org/gnome/desktop/app-folders/folders/Browsers" = {
name = "Browsers";
apps = [
"brave-browser.desktop"
"firefox.desktop"
"org.gnome.Epiphany.desktop"
];
};
"org/gnome/desktop/app-folders/folders/Office" = {
name = "Office";
apps = [
"libreoffice-writer.desktop"
"libreoffice-calc.desktop"
"libreoffice-impress.desktop"
"libreoffice-draw.desktop"
"libreoffice-base.desktop"
"libreoffice-math.desktop"
"libreoffice-startcenter.desktop"
"org.gnome.TextEditor.desktop"
"org.gnome.gedit.desktop"
"org.gnome.Calculator.desktop"
"org.gnome.Calendar.desktop"
"org.gnome.Contacts.desktop"
"org.gnome.Geary.desktop"
"org.gnome.Evince.desktop"
"onlyoffice-desktopeditors.desktop"
"simple-scan.desktop"
"system-config-printer.desktop"
];
};
"org/gnome/desktop/app-folders/folders/Terminal" = {
name = "Terminal";
apps = [
"org.gnome.Terminal.desktop"
"org.gnome.tweaks.desktop"
"gparted.desktop"
"htop.desktop"
"btop.desktop"
"ranger.desktop"
"org.gnome.Console.desktop"
];
};
"org/gnome/desktop/app-folders/folders/Chat" = {
name = "Chat";
apps = [
"element-desktop.desktop"
];
};
"org/gnome/desktop/app-folders/folders/Bitcoin" = {
name = "Bitcoin";
apps = [
"sparrow.desktop"
"Bisq.desktop"
"bisq2.desktop"
];
};
"org/gnome/desktop/app-folders/folders/Media" = {
name = "Media";
apps = [
"org.gnome.Loupe.desktop"
"org.gnome.Totem.desktop"
"org.gnome.Snapshot.desktop"
"org.gnome.Weather.desktop"
"org.gnome.Maps.desktop"
"org.gnome.Clocks.desktop"
"org.gnome.Music.desktop"
"org.gnome.Characters.desktop"
"org.gnome.font-viewer.desktop"
];
};
"org/gnome/desktop/app-folders/folders/System" = {
name = "System";
apps = [
"org.gnome.Settings.desktop"
"org.gnome.Nautilus.desktop"
"org.gnome.Software.desktop"
"sovran-hub.desktop"
"bitwarden.desktop"
"org.gnome.DiskUtility.desktop"
"org.gnome.SystemMonitor.desktop"
"org.gnome.Logs.desktop"
"org.gnome.Connections.desktop"
"org.gnome.baobab.desktop"
"zenity.desktop"
];
};
"org/gnome/shell/extensions/dash-to-dock" = {
background-color = "rgb(0,0,0)";
background-opacity = 0.50000000000000001;
@@ -146,4 +432,14 @@ in
}
];
}
xdg.mime.defaultApplications = {
"text/html" = "brave-browser.desktop";
"x-scheme-handler/http" = "brave-browser.desktop";
"x-scheme-handler/https" = "brave-browser.desktop";
"x-scheme-handler/about" = "brave-browser.desktop";
"x-scheme-handler/unknown" = "brave-browser.desktop";
};
environment.sessionVariables.BROWSER = "brave-browser";
}
+37 -4
View File
@@ -31,7 +31,7 @@ lib.mkIf userExists {
};
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" ];
after = [ "ssh-passphrase-setup.service" ];
requires = [ "ssh-passphrase-setup.service" ];
@@ -39,14 +39,47 @@ lib.mkIf userExists {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.openssh pkgs.coreutils ];
path = [ pkgs.openssh pkgs.coreutils pkgs.util-linux ];
script = ''
if [ ! -f "${keyPath}" ]; then
PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase)
set -eu
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}"
chown ${userName}:users "${keyPath}" "${keyPath}.pub"
chmod 600 "${keyPath}"
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
'';
};
+18
View File
@@ -11,6 +11,8 @@
# (u:sovran-support:---) by the Hub API as soon as a session is started.
# • The Hub web UI lets the user grant time-limited access to wallet files
# and view a full audit log of every session event.
# • Scoped sudo rules allow support staff to edit custom.nix, trigger rebuilds,
# restart services, and read logs — without full root or wallet access.
#
# The `acl` package provides the `setfacl` / `getfacl` utilities required by
# the Hub's _apply_wallet_acls() and _revoke_wallet_acls() helpers.
@@ -39,4 +41,20 @@
"d /var/lib/sovran-support 0700 sovran-support sovran-support -"
"d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -"
];
# ── Scoped sudo rules for support staff ───────────────────────────────────
# Grants only the minimum privileges needed for a support session.
# Support staff cannot stop/disable/mask services or access wallet files.
security.sudo.extraRules = [
{
users = [ "sovran-support" ];
commands = [
{ command = "/run/current-system/sw/bin/nano /etc/nixos/custom.nix"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/nano /etc/nixos/configuration.nix"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/nixos-rebuild switch --flake /etc/nixos"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart *"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/journalctl *"; options = [ "NOPASSWD" ]; }
];
}
];
}
+129 -10
View File
@@ -33,6 +33,9 @@ let
echo "$NEW_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
echo "Password for 'free' updated and saved."
# Delete the old GNOME Keyring databases so a fresh one is created on next GDM login.
rm -f /home/free/.local/share/keyrings/*.keyring
echo "GNOME Keyring files cleared a fresh keyring will be created on next login."
'';
in
{
@@ -82,35 +85,151 @@ in
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.pwgen pkgs.shadow pkgs.coreutils ];
path = [ pkgs.shadow pkgs.coreutils ];
script = ''
set -euo pipefail
SECRET_FILE="/var/lib/secrets/root-password"
if [ ! -f "$SECRET_FILE" ]; then
mkdir -p /var/lib/secrets
ROOT_PASS=$(pwgen -s 20 1)
echo "root:$ROOT_PASS" | chpasswd
# Generate a diceware-style passphrase: word-word-word-N
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
fern field flame flora flint frost grove haven hedge holly heron \
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
prism quartz raven ridge river robin rocky rose rowan sage sand \
sierra silver slate snow solar spark spruce stone storm summit \
swift thorn tide timber torch trout vale vault vine walnut wave \
willow wren amber aspen birch blaze bloom bluff coast copper crest \
dune elder fjord forge glade glen glow gulf"
WORD_ARRAY=($WORDS)
COUNT=''${#WORD_ARRAY[@]}
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
DIGIT=$((RANDOM % 10))
ROOT_PASS="$W1-$W2-$W3-$DIGIT"
echo "$ROOT_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
fi
echo "root:$(cat "$SECRET_FILE")" | chpasswd
'';
};
# ── 1b. Save 'free' password on first boot ─────────────────
# ── 1b. Generate random 'free' password on first boot ──────
systemd.services.free-password-setup = {
description = "Save the initial 'free' user password";
description = "Generate and set a random 'free' user password";
wantedBy = [ "multi-user.target" ];
before = [ "display-manager.service" ];
after = [ "systemd-user-sessions.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.coreutils ];
path = [ pkgs.shadow pkgs.coreutils ];
script = ''
set -euo pipefail
SECRET_FILE="/var/lib/secrets/free-password"
if [ ! -f "$SECRET_FILE" ]; then
mkdir -p /var/lib/secrets
echo "free" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
PENDING_FILE="/var/lib/secrets/free-password-migration-pending"
if [ -f "$SECRET_FILE" ]; then
echo "free:$(cat "$SECRET_FILE")" | chpasswd
exit 0
fi
SHADOW_HASH=""
while IFS=: read -r user hash _; do
if [ "$user" = "free" ]; then
SHADOW_HASH="$hash"
break
fi
done < /etc/shadow
HAS_REAL_HASH=0
case "$SHADOW_HASH" in
""|"!"|"*"|"!!"|"!"*|"*"*)
HAS_REAL_HASH=0
;;
*)
HAS_REAL_HASH=1
;;
esac
if [ "$HAS_REAL_HASH" -eq 1 ]; then
mkdir -p /var/lib/secrets
touch "$PENDING_FILE"
chmod 600 "$PENDING_FILE"
exit 0
fi
mkdir -p /var/lib/secrets
# Generate a diceware-style passphrase: word-word-word-N
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
fern field flame flora flint frost grove haven hedge holly heron \
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
prism quartz raven ridge river robin rocky rose rowan sage sand \
sierra silver slate snow solar spark spruce stone storm summit \
swift thorn tide timber torch trout vale vault vine walnut wave \
willow wren amber aspen birch blaze bloom bluff coast copper crest \
dune elder fjord forge glade glen glow gulf"
WORD_ARRAY=($WORDS)
COUNT=''${#WORD_ARRAY[@]}
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
DIGIT=$((RANDOM % 10))
FREE_PASS="$W1-$W2-$W3-$DIGIT"
echo "$FREE_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
echo "free:$FREE_PASS" | chpasswd
'';
};
systemd.services.free-password-migration = {
description = "Generate and set 'free' password for migrated machines";
wantedBy = [ "multi-user.target" ];
before = [ "display-manager.service" ];
after = [ "systemd-user-sessions.service" "free-password-setup.service" ];
requires = [ "free-password-setup.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.shadow pkgs.coreutils ];
script = ''
set -euo pipefail
PENDING_FILE="/var/lib/secrets/free-password-migration-pending"
SECRET_FILE="/var/lib/secrets/free-password"
NEWPASS_FILE="/var/lib/secrets/free-password-migration-newpass"
[ -f "$PENDING_FILE" ] || exit 0
mkdir -p /var/lib/secrets
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
fern field flame flora flint frost grove haven hedge holly heron \
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
prism quartz raven ridge river robin rocky rose rowan sage sand \
sierra silver slate snow solar spark spruce stone storm summit \
swift thorn tide timber torch trout vale vault vine walnut wave \
willow wren amber aspen birch blaze bloom bluff coast copper crest \
dune elder fjord forge glade glen glow gulf"
WORD_ARRAY=($WORDS)
COUNT=''${#WORD_ARRAY[@]}
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
DIGIT=$((RANDOM % 10))
FREE_PASS="$W1-$W2-$W3-$DIGIT"
printf '%s\n' "$FREE_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
printf '%s\n' "$FREE_PASS" > "$NEWPASS_FILE"
chmod 600 "$NEWPASS_FILE"
rm -f "$PENDING_FILE"
'';
};
+106 -20
View File
@@ -34,8 +34,8 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
};
####### ENSURE SERVICES START AFTER KEY EXISTS #######
systemd.services.livekit.after = [ "livekit-key-setup.service" ];
systemd.services.livekit.wants = [ "livekit-key-setup.service" ];
systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-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.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-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" }] }\`
}
$MATRIX:8448 {
reverse_proxy http://localhost:8008
respond /.well-known/matrix/server \`{"m.server":"$MATRIX:443"}\`
}
$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
reverse_proxy [::1]:8073 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
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
'';
};
####### LIVEKIT RUNTIME CONFIG #######
systemd.services.livekit-runtime-config = {
description = "Generate LiveKit runtime config from domain files";
####### LIVEKIT TURN SETUP (runtime cert + config) #######
# Replaces the old dead livekit-runtime-config.service. At runtime this:
# * 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" ];
after = [ "livekit-key-setup.service" ];
requiredBy = [ "livekit.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
@@ -106,20 +125,63 @@ EOF
unitConfig = {
ConditionPathExists = "/var/lib/domains/element-calling";
};
path = [ pkgs.coreutils ];
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ];
script = ''
MATRIX=$(cat /var/lib/domains/matrix)
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:
enabled: true
domain: $MATRIX
cert_file: /var/lib/livekit/$MATRIX.crt
key_file: /var/lib/livekit/$MATRIX.key
tls_port: 5349
udp_port: 3478
cert_file: /run/credentials/livekit.service/turn-cert
key_file: /run/credentials/livekit.service/turn-key
EOF
chmod 640 /run/livekit/runtime-config.yaml
chmod 644 /run/livekit/livekit.yaml
'';
};
@@ -130,7 +192,11 @@ EOF
keyFile = livekitKeyFile;
settings = {
rtc.use_external_ip = true;
rtc.udp_port = "7882-7894";
rtc.skip_external_ip_validation = true;
rtc.tcp_port = 7881;
rtc.udp_port = 7882;
rtc.port_range_start = 30000;
rtc.port_range_end = 40000;
room.auto_create = false;
turn = {
enabled = true;
@@ -140,9 +206,27 @@ EOF
};
};
networking.firewall.allowedTCPPorts = [ 7881 ];
# 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.allowedUDPPorts = [ 3478 7882 ];
networking.firewall.allowedUDPPortRanges = [
{ from = 7882; to = 7894; }
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range
];
####### JWT SERVICE RUNTIME CONFIG #######
@@ -162,11 +246,13 @@ EOF
path = [ pkgs.coreutils ];
script = ''
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
MATRIX=$(cat /var/lib/domains/matrix)
mkdir -p /run/lk-jwt-service
cat > /run/lk-jwt-service/env <<EOF
LIVEKIT_URL=wss://$ELEMENT_CALLING
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX
EOF
chmod 640 /run/lk-jwt-service/env
+5 -3
View File
@@ -12,11 +12,14 @@
./core/sovran_systemsos-desktop.nix
./core/sshd-localhost.nix
./core/sovran-hub.nix
./core/factory-seal.nix
./core/legacy-cleanup.nix
./core/remote-deploy.nix
./core/no-sleep.nix
./core/cpu-performance.nix
./core/local-domain-loopback.nix
# ── Always on (no flag) ───────────────────────────────────
./php.nix
./Sovran_SystemsOS_File_Fixes_And_New_Services.nix
./credentials.nix
# ── Services (default ON — disable in custom.nix) ─────────
@@ -29,7 +32,6 @@
# ── Features (default OFF — enable in custom.nix) ─────────
./haven.nix
./bip110.nix
./element-calling.nix
./mempool.nix
./bitcoin-core.nix
+106 -12
View File
@@ -53,7 +53,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
# ── Fully automated Nextcloud setup ───────────────────────
systemd.services.nextcloud-init = {
description = "Download, extract, and fully configure Nextcloud";
after = [ "network-online.target" "postgresql.service" "phpfpm-mypool.service" "nextcloud-db-init.service" ];
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" ];
wants = [ "network-online.target" ];
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
wantedBy = [ "multi-user.target" ];
@@ -73,7 +73,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
set -euo pipefail
INSTALL_DIR="/var/lib/www/nextcloud"
DATA_DIR="/var/lib/www/nextcloud-data"
DATA_DIR="/var/lib/nextcloud"
DOMAIN=$(cat /var/lib/domains/nextcloud)
DB_NAME="nextclouddb"
DB_USER="ncusr"
@@ -81,6 +81,11 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
DB_HOST="localhost"
ADMIN_USER=$(pwgen -s 16 1)
ADMIN_PASS=$(pwgen -s 24 1)
SERVER_ID=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
if [ -z "$SERVER_ID" ]; then
echo "Failed to generate Nextcloud server_id"
exit 1
fi
echo ""
echo " Nextcloud Automated Installation"
@@ -92,20 +97,22 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
curl -L -o "$TEMP_DIR/nextcloud.zip" "https://download.nextcloud.com/server/releases/latest.zip"
unzip -q "$TEMP_DIR/nextcloud.zip" -d "$TEMP_DIR"
mkdir -p "$INSTALL_DIR"
cp -a "$TEMP_DIR/nextcloud/"* "$INSTALL_DIR/"
cp -a "$TEMP_DIR/nextcloud/." "$INSTALL_DIR/"
rm -rf "$TEMP_DIR"
echo "Download complete."
fi
mkdir -p "$DATA_DIR"
chown -R caddy:root "$INSTALL_DIR"
chown -R caddy:root "$DATA_DIR"
chown -R caddy:php "$INSTALL_DIR"
find "$INSTALL_DIR" -type d -exec chmod 750 {} \;
find "$INSTALL_DIR" -type f -exec chmod 640 {} \;
chmod -R 770 "$INSTALL_DIR/apps"
chmod -R 770 "$INSTALL_DIR/config"
chmod -R 770 "$DATA_DIR"
if [ ! -d "$DATA_DIR" ]; then
mkdir -p "$DATA_DIR"
chown -R caddy:php "$DATA_DIR"
chmod -R 770 "$DATA_DIR"
fi
echo "Waiting for PostgreSQL..."
for i in $(seq 1 30); do
@@ -132,15 +139,35 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ config:system:set trusted_domains 0 --value='$DOMAIN'
php $INSTALL_DIR/occ config:system:set overwrite.cli.url --value='https://$DOMAIN'
php $INSTALL_DIR/occ config:system:set overwritehost --value='$DOMAIN'
php $INSTALL_DIR/occ config:system:set overwriteprotocol --value='https'
"
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ config:system:set trusted_proxies 0 --value='127.0.0.1'
php $INSTALL_DIR/occ config:system:set trusted_proxies 1 --value='::1'
php $INSTALL_DIR/occ config:system:set forwarded_for_headers 0 --value='HTTP_X_FORWARDED_FOR'
php $INSTALL_DIR/occ config:system:set default_phone_region --value='US'
php $INSTALL_DIR/occ config:system:set maintenance_window_start --type=integer --value=1
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\APCu'
php $INSTALL_DIR/occ config:system:set server_id --value='$SERVER_ID'
php $INSTALL_DIR/occ background:cron
"
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ integrity:check-core
php $INSTALL_DIR/occ maintenance:repair
php $INSTALL_DIR/occ db:add-missing-indices
php $INSTALL_DIR/occ db:add-missing-columns
php $INSTALL_DIR/occ db:add-missing-primary-keys
php $INSTALL_DIR/occ maintenance:repair --include-expensive
# AppAPI deploy daemon warnings are avoided by disabling app_api when present.
if php $INSTALL_DIR/occ app:info app_api >/dev/null 2>&1; then
php $INSTALL_DIR/occ app:disable app_api
fi
"
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ app:install calendar || true
php $INSTALL_DIR/occ app:install contacts || true
@@ -172,19 +199,86 @@ CREDS
'';
};
systemd.services.nextcloud-detect-existing = {
description = "Detect pre-existing Nextcloud installation and populate hub credentials";
after = [ "postgresql.service" ];
wants = [ "postgresql.service" ];
wantedBy = [ "multi-user.target" ];
unitConfig = {
ConditionPathExists = [
"/var/lib/www/nextcloud/config/config.php"
"!/var/lib/secrets/nextcloud-admin"
];
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = with pkgs; [ coreutils gnused ];
script = ''
set -euo pipefail
CREDS_FILE="/var/lib/secrets/nextcloud-admin"
DOMAIN_FILE="/var/lib/domains/nextcloud"
DOMAIN="your-domain"
if [ -f "$DOMAIN_FILE" ]; then
FILE_DOMAIN="$(sed -n '1{s/^[[:space:]]*//;s/[[:space:]]*$//;p;}' "$DOMAIN_FILE")"
if [ -n "$FILE_DOMAIN" ]; then
DOMAIN="$FILE_DOMAIN"
fi
fi
mkdir -p /var/lib/secrets
cat > "$CREDS_FILE" << CREDS
Nextcloud (Pre-existing Installation)
URL: https://$DOMAIN/
Note: This Nextcloud was installed before Sovran_SystemsOS.
Use your existing admin credentials to log in.
Reset: sudo -u caddy php /var/lib/www/nextcloud/occ user:resetpassword <username>
CREDS
chmod 600 "$CREDS_FILE"
'';
};
services.cron.systemCronJobs = [
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
];
systemd.tmpfiles.rules = [
"d /var/lib/www 0755 caddy root -"
"d /var/lib/www/nextcloud 0750 caddy root -"
"d /var/lib/www/nextcloud-data 0770 caddy root -"
"d /var/lib/www 0755 caddy php -"
"d /var/lib/www/nextcloud 0750 caddy php -"
"d /var/lib/nextcloud 0770 caddy php -"
];
services.phpfpm.pools.nextcloud = {
user = "caddy";
group = "php";
phpPackage = config.sovran_systemsOS.phpPackage;
phpOptions = lib.mkAfter ''
output_buffering = 0
'';
settings = {
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 10;
"pm.min_spare_servers" = 5;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 500;
"clear_env" = "no";
"listen" = "/run/phpfpm/nextcloud.sock";
};
};
environment.systemPackages = with pkgs; [ unzip ];
sovran_systemsOS.domainRequirements = [
{ name = "nextcloud"; label = "Nextcloud"; example = "cloud.yourdomain.com"; }
];
}
}
+21 -30
View File
@@ -28,39 +28,30 @@ let
};
in
{
users.users = {
php = {
isSystemUser = true;
createHome = false;
uid = 7777;
};
{
options.sovran_systemsOS.phpPackage = lib.mkOption {
type = lib.types.package;
default = custom-php;
description = "Shared PHP package with all extensions for Sovran_SystemsOS services";
};
users.users.php.group = "php";
users.groups.php = {};
config = {
users.users = {
environment.systemPackages = with pkgs; [
custom-php
];
services.phpfpm.pools = {
mypool = {
user = "caddy";
group = "php";
phpPackage = custom-php;
settings = {
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 10;
"pm.min_spare_servers" = 5;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 500;
"clear_env" = "no";
php = {
isSystemUser = true;
createHome = false;
uid = 7777;
};
};
};
users.users.php.group = "php";
users.groups.php = {};
environment.systemPackages = with pkgs; [
custom-php
];
};
}
Executable → Regular
+127 -55
View File
@@ -2,63 +2,104 @@
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
services.gnome.gnome-remote-desktop.enable = true;
# Open RDP port in the firewall
networking.firewall.allowedTCPPorts = [ 3389 ];
# Ensure the service only starts after setup succeeds
systemd.services.gnome-remote-desktop = {
wantedBy = [ "graphical.target" ];
after = [ "gnome-remote-desktop-setup.service" ];
requires = [ "gnome-remote-desktop-setup.service" ];
};
systemd.tmpfiles.rules = [
"d /var/lib/gnome-remote-desktop 0750 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 0750 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/.local 0700 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/gnome-remote-desktop 0700 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 = {
description = "Configure GNOME Remote Desktop RDP";
wantedBy = [ "multi-user.target" ];
wantedBy = [ "graphical.target" ];
before = [ "gnome-remote-desktop.service" ];
after = [ "systemd-tmpfiles-setup.service" "network-online.target" ];
wants = [ "network-online.target" ];
after = [
"dbus.service"
"systemd-tmpfiles-setup.service"
"network-online.target"
"gnome-remote-desktop-configuration.service"
];
wants = [
"network-online.target"
"gnome-remote-desktop-configuration.service"
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
TimeoutStartSec = "2min";
};
path = [
pkgs.gnome-remote-desktop
pkgs.polkit
pkgs.openssl
pkgs.hostname
pkgs.coreutils
pkgs.gawk
pkgs.gnome-remote-desktop
pkgs.hostname
pkgs.openssl
pkgs.systemd
];
script = ''
# Ensure directory structure exists
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
set -euo pipefail
TLS_DIR="/var/lib/gnome-remote-desktop/tls"
CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials"
# GRD 50.x invokes pkexec internally for every grdctl --system call, even
# 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
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
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
fi
if [ "$NEED_REGEN" = "1" ]; then
mkdir -p "$TLS_DIR"
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-sha256 -nodes -days 3650 \
@@ -68,39 +109,59 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
echo "Generated new RDP TLS certificate"
fi
# Always fix ownership and permissions (handles re-enable after disable)
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
chmod 600 "$TLS_DIR/rdp-tls.key"
chmod 644 "$TLS_DIR/rdp-tls.crt"
# Configure TLS certificate
grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
if [ ! -f "$USERNAME_FILE" ]; then
printf '%s\n' "$DEFAULT_USERNAME" > "$USERNAME_FILE"
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
PASSWORD=""
if [ ! -f /var/lib/gnome-remote-desktop/rdp-password ]; then
PASSWORD=$(openssl rand -base64 16)
echo "$PASSWORD" > /var/lib/gnome-remote-desktop/rdp-password
chmod 600 /var/lib/gnome-remote-desktop/rdp-password
else
PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password)
if [ ! -f "$PASSWORD_FILE" ]; then
openssl rand -base64 16 > "$PASSWORD_FILE"
fi
PASSWORD="$(tr -d '\n' < "$PASSWORD_FILE")"
if [ -z "$PASSWORD" ]; then
echo "RDP password file is empty: $PASSWORD_FILE" >&2
exit 1
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
# 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
========================================
GNOME Remote Desktop (RDP) Credentials
========================================
Username: sovran
Username: $USERNAME
Password: $PASSWORD
Connect from any RDP client to:
@@ -109,13 +170,24 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
========================================
EOF
chown gnome-remote-desktop:gnome-remote-desktop "$CRED_FILE"
chmod 600 "$CRED_FILE"
# Enable RDP backend and set credentials
grdctl --system rdp enable
grdctl --system rdp set-credentials sovran "$PASSWORD"
# Preflight: the NixOS setuid pkexec wrapper must be present and executable
# before any grdctl --system call. Absence means the system was booted
# 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"
'';
};
}
}
-1
View File
@@ -118,7 +118,6 @@ EOF
"198.18.0.0/15" "198.51.100.0/24" "2001:db8::/32" "203.0.113.0/24"
"224.0.0.0/4" "::1/128" "fc00::/7" "fe80::/10" "fec0::/10" "ff00::/8"
];
url_preview_ip_ranger_whitelist = [ "127.0.0.1" ];
presence.enabled = true;
enable_registration = false;
listeners = [
+1 -38
View File
@@ -31,6 +31,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
cat > "$CONFIG_FILE" << 'EOF'
{
"mode": "ONLINE",
"serverType": "ELECTRUM_SERVER",
"electrumServer": "tcp://127.0.0.1:50001",
"useProxy": false
@@ -42,44 +43,6 @@ EOF
'';
};
# ── Bisq 1 Auto-Connect ─────────────────────────────────────
systemd.services.bisq-autoconnect = {
description = "Auto-configure Bisq to use local Bitcoin node";
after = [ "bitcoind.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [ pkgs.coreutils pkgs.iproute2 ];
script = ''
BISQ_CONF="/home/free/.local/share/Bisq/bisq.properties"
if [ -f "$BISQ_CONF" ]; then
echo "Bisq config already exists, skipping"
exit 0
fi
# Wait for bitcoind RPC to be ready (up to 30 attempts)
ATTEMPTS=0
until ss -ltn 2>/dev/null | grep -q ':8333' || [ "$ATTEMPTS" -ge 30 ]; do
ATTEMPTS=$((ATTEMPTS + 1))
sleep 2
done
mkdir -p /home/free/.local/share/Bisq
cat > "$BISQ_CONF" << 'EOF'
btcNodes=127.0.0.1:8333
useTorForBtc=true
useCustomBtcNodes=true
EOF
chown -R free:users /home/free/.local/share/Bisq
echo "Bisq auto-configured to use local Bitcoin node"
'';
};
# ── Zeus Connect (lndconnect URL for mobile wallet) ──────────
systemd.services.zeus-connect-setup = {
description = "Save Zeus lndconnect URL";
+75 -7
View File
@@ -46,7 +46,7 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
# ── Fully automated WordPress setup ───────────────────────
systemd.services.wordpress-init = {
description = "Download, extract, and fully configure WordPress";
after = [ "network-online.target" "mysql.service" "phpfpm-mypool.service" "wordpress-db-init.service" ];
after = [ "network-online.target" "mysql.service" "phpfpm-wordpress.service" "wordpress-db-init.service" ];
wants = [ "network-online.target" ];
requires = [ "mysql.service" "wordpress-db-init.service" ];
wantedBy = [ "multi-user.target" ];
@@ -73,7 +73,11 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
DB_HOST="localhost"
ADMIN_USER=$(pwgen -s 16 1)
ADMIN_PASS=$(pwgen -s 24 1)
ADMIN_EMAIL="$ADMIN_USER@''${DOMAIN#*.}"
EMAIL_DOMAIN="''${DOMAIN#*.}"
if ! echo "$EMAIL_DOMAIN" | grep -q '\.'; then
EMAIL_DOMAIN="$DOMAIN"
fi
ADMIN_EMAIL="$ADMIN_USER@$EMAIL_DOMAIN"
echo ""
echo " WordPress Automated Installation"
@@ -90,10 +94,10 @@ lib.mkIf config.sovran_systemsOS.services.wordpress {
echo "Download complete."
fi
chown -R caddy:root "$INSTALL_DIR"
find "$INSTALL_DIR" -type d -exec chmod 755 {} \;
find "$INSTALL_DIR" -type f -exec chmod 644 {} \;
chmod -R 775 "$INSTALL_DIR/wp-content"
chown -R caddy:php "$INSTALL_DIR"
find "$INSTALL_DIR" -type d -exec chmod 750 {} \;
find "$INSTALL_DIR" -type f -exec chmod 640 {} \;
chmod -R 770 "$INSTALL_DIR/wp-content"
echo "Generating wp-config.php..."
cd "$INSTALL_DIR"
@@ -158,9 +162,73 @@ CREDS
'';
};
systemd.services.wordpress-detect-existing = {
description = "Detect pre-existing WordPress installation and populate hub credentials";
after = [ "mysql.service" ];
wants = [ "mysql.service" ];
wantedBy = [ "multi-user.target" ];
unitConfig = {
ConditionPathExists = [
"/var/lib/www/wordpress/wp-config.php"
"!/var/lib/secrets/wordpress-admin"
];
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = with pkgs; [ coreutils gnused ];
script = ''
set -euo pipefail
CREDS_FILE="/var/lib/secrets/wordpress-admin"
DOMAIN_FILE="/var/lib/domains/wordpress"
DOMAIN="your-domain"
if [ -f "$DOMAIN_FILE" ]; then
FILE_DOMAIN="$(sed -n '1{s/^[[:space:]]*//;s/[[:space:]]*$//;p;}' "$DOMAIN_FILE")"
if [ -n "$FILE_DOMAIN" ]; then
DOMAIN="$FILE_DOMAIN"
fi
fi
mkdir -p /var/lib/secrets
cat > "$CREDS_FILE" << CREDS
WordPress (Pre-existing Installation)
URL: https://$DOMAIN/wp-admin/
Note: This WordPress was installed before Sovran_SystemsOS.
Use your existing admin credentials to log in.
Reset: wp user update <username> --user_pass=<new-password>
CREDS
chmod 600 "$CREDS_FILE"
'';
};
services.phpfpm.pools.wordpress = {
user = "caddy";
group = "php";
phpPackage = config.sovran_systemsOS.phpPackage;
settings = {
"pm" = "dynamic";
"pm.max_children" = 75;
"pm.start_servers" = 10;
"pm.min_spare_servers" = 5;
"pm.max_spare_servers" = 20;
"pm.max_requests" = 500;
"clear_env" = "no";
"listen" = "/run/phpfpm/wordpress.sock";
};
};
systemd.tmpfiles.rules = [
"d /var/lib/www 0755 caddy root -"
"d /var/lib/www/wordpress 0755 caddy root -"
"d /var/lib/www/wordpress 0750 caddy php -"
];
environment.systemPackages = with pkgs; [ wp-cli unzip ];
-1
View File
@@ -1 +0,0 @@
/nix/store/2y2saivdbfj6j3r4rpwlw7k4sys89bp1-Sovran_SystemsOS.iso