Compare commits

..
1090 Commits
Author SHA1 Message Date
naturallaw777 ff70668f38 Merge remote-tracking branch 'github/main' into staging-dev 2026-07-13 12:46:51 -05:00
Sovran Systems 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] bd4c4f8716 Adjust update status docstring ordering 2026-07-13 17:41:36 +00:00
copilot-swe-agent[bot] 201cdb5bf9 Polish staged-update test and log header formatting 2026-07-13 17:40:29 +00:00
copilot-swe-agent[bot] 7ba4adb376 Stage Hub full updates for next reboot 2026-07-13 17:39:14 +00:00
copilot-swe-agent[bot] a4fc880c38 Initial plan 2026-07-13 17:36:29 +00:00
naturallaw777 edf6294315 Update nixpkgs 2026-07-04 11:25:52 -05:00
Sovran Systems 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] 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] afe51c4abd Initial plan 2026-07-03 23:33:55 +00:00
Sovran Systems 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] 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] 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 Systems 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 Systems 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] c2f3f048b9 fix: simplify internal IP copy handling 2026-06-24 22:19:14 +00:00
copilot-swe-agent[bot] bd3dbcb057 fix: clarify router forwarding IP guidance 2026-06-24 22:17:13 +00:00
copilot-swe-agent[bot] 7f975bc4f1 chore: use flock for ssh bootstrap repair 2026-06-24 22:16:44 +00:00
copilot-swe-agent[bot] 31abf40722 chore: serialize ssh bootstrap key repairs 2026-06-24 22:16:05 +00:00
copilot-swe-agent[bot] 439021f798 chore: harden ssh bootstrap script 2026-06-24 22:15:24 +00:00
copilot-swe-agent[bot] 181465c376 chore: log legacy ssh key regeneration 2026-06-24 22:14:42 +00:00
copilot-swe-agent[bot] 6ec28b1ad7 Initial plan 2026-06-24 22:14:35 +00:00
copilot-swe-agent[bot] db1a88ab2e fix: repair legacy factory ssh key passphrases 2026-06-24 22:14:06 +00:00
copilot-swe-agent[bot] aa148fe435 Initial plan 2026-06-24 22:12:39 +00:00
Sovran Systems 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] 22402cb4fd Align router setup wording and local port statuses 2026-06-24 19:06:12 +00:00
copilot-swe-agent[bot] 1868a58ee0 Initial plan 2026-06-24 19:03:36 +00:00
naturallaw777 50cbd2fa28 added onlyoffice 2026-06-24 13:19:41 -05:00
Sovran Systems 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] 302eb43233 fix: deliver livekit config via LoadCredential to fix DynamicUser permission denied 2026-06-24 01:21:05 +00:00
copilot-swe-agent[bot] 949391ed44 Initial plan 2026-06-24 01:19:37 +00:00
Sovran Systems 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 Systems 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] 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] 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 Systems 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] 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] 29960e9937 Initial plan 2026-06-13 14:49:39 +00:00
Sovran Systems 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] 0945092dde Remove orphaned ./branding.nix import from iso/common.nix 2026-06-10 18:41:49 +00:00
copilot-swe-agent[bot] 6f12117521 Initial plan 2026-06-10 18:40:41 +00:00
naturallaw777 8bf8814fa7 nixpkgs update 2026-06-09 11:03:40 -05:00
Sovran Systems d90b5b091b Add files via upload 2026-06-05 15:15:19 -05:00
Sovran Systems 4275ac1d2f Add files via upload 2026-06-05 13:27:04 -05:00
Sovran Systems 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] 5fb8279d61 Improve screenshot alt text for accessibility 2026-06-05 18:14:13 +00:00
copilot-swe-agent[bot] 6eb63d3f85 Add desktop screenshot placeholder and README embed 2026-06-05 18:13:46 +00:00
copilot-swe-agent[bot] 2702854513 Plan README changes with placeholder screenshot 2026-06-05 18:13:16 +00:00
copilot-swe-agent[bot] 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 Systems 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] 7ac1985508 Refine BIP110 matching and add regression coverage 2026-06-04 20:15:35 +00:00
copilot-swe-agent[bot] 0ecf2eb651 Fix BIP110 detection for reduced_data deployments 2026-06-04 20:11:58 +00:00
copilot-swe-agent[bot] 18c7095aaf Initial plan 2026-06-04 20:07:44 +00:00
Sovran Systems 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] 06988d0ff0 Fix docstring accuracy, extract _firstElementFromHtml helper, address all code review feedback 2026-06-04 19:49:01 +00:00
copilot-swe-agent[bot] 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] df08a7c413 Add live BIP-110 deployment status: new helpers, endpoint, badge UI 2026-06-04 19:42:23 +00:00
copilot-swe-agent[bot] 602464189f Initial plan 2026-06-04 19:36:51 +00:00
Sovran Systems 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] f8c717db25 Address code review: fix whitespace and log migration exceptions 2026-06-04 19:18:28 +00:00
copilot-swe-agent[bot] 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] c1119b03a8 Initial plan 2026-06-04 19:12:56 +00:00
Sovran Systems 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] 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] 875a6a9297 Retire deprecated bip110 flake input; collapse Bitcoin node tiles to two 2026-06-04 18:52:28 +00:00
copilot-swe-agent[bot] 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 Systems 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 Systems 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] 66cacaaf9d Harden asset-version fallback hashing separators 2026-05-27 16:03:31 +00:00
copilot-swe-agent[bot] 15cd07d12f Add resilient Nix download/fallback settings for hub update flows 2026-05-27 16:02:37 +00:00
copilot-swe-agent[bot] fae57c0375 Initial plan 2026-05-27 15:59:57 +00:00
copilot-swe-agent[bot] 3745eedd74 Add unified template asset cache-busting version 2026-05-27 15:59:50 +00:00
copilot-swe-agent[bot] 1cd4fc8b40 Initial plan 2026-05-27 15:56:18 +00:00
Sovran Systems 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 Systems 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 Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 24098a209a Initial plan 2026-05-22 03:34:50 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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 Systems 342f60ce0d Delete docs directory 2026-05-21 16:50:48 -05:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] fd2e12ced1 Initial plan 2026-05-21 14:28:00 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] c728eee924 Initial plan 2026-05-21 01:32:55 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 53ee31c5d2 Initial plan 2026-05-17 14:05:39 +00:00
naturallaw777 283b439d59 nixpkgs update 2026-05-14 10:17:11 -05:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 2b76a766ad Initial plan 2026-05-09 14:58:55 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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 Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 646d877c4d Initial plan 2026-05-03 22:33:54 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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 Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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 Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 175f48ef37 Initial plan 2026-04-30 14:26:43 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 953fb04671 Initial plan 2026-04-30 13:48:25 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 02e662454c Initial plan 2026-04-30 13:10:40 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] ef683a6aa9 Initial plan 2026-04-30 08:36:41 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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 Systems 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 Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 68c3aa95fd Initial plan 2026-04-30 01:39:53 +00:00
copilot-swe-agent[bot]andnaturallaw777 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] ca1ff3ee20 Initial plan 2026-04-30 01:25:15 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 7da0463dce Initial plan 2026-04-30 00:51:11 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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 Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 17fbd5fd2c Initial plan 2026-04-29 20:00:45 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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 Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] d3327e05d4 Initial plan 2026-04-22 14:22:01 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] e147fd8f4d Initial plan 2026-04-22 12:34:17 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 71d8eae6d8 Initial plan 2026-04-22 12:05:50 +00:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 16898e8eb9 Initial plan 2026-04-17 22:55:25 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] c40db26e6f Initial plan 2026-04-17 15:38:41 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 5324344eed Initial plan 2026-04-17 13:05:11 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 5e33a250d5 Initial plan 2026-04-17 12:15:03 +00:00
Sovran_Systems 7e0cda17f3 Remove localhost from url_preview_ip_ranger_whitelist 2026-04-16 23:49:04 -05:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 0c596fb396 Initial plan 2026-04-17 03:48:26 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] cb9172d069 Initial plan 2026-04-17 03:24:31 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 12b2d85fb4 Initial plan 2026-04-16 17:50:02 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 80fea3301b Initial plan 2026-04-16 17:40:57 +00:00
Sovran_Systems 91a3e68119 Remove server configuration from Bitcoin ecosystem 2026-04-16 12:21:19 -05:00
Sovran_Systems 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_Systems 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] 8fd08057d8 Initial plan 2026-04-16 14:58:54 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] c8eb452a70 Initial plan 2026-04-15 20:11:07 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] ecd5ecd659 Initial plan 2026-04-15 19:59:35 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 6c0afc0e6b Initial plan 2026-04-15 18:55:17 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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_Systems 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] d636e0fa38 Initial plan 2026-04-15 18:35:51 +00:00
copilot-swe-agent[bot]andnaturallaw777 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] 8a57734a42 Initial plan 2026-04-15 18:31:55 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] c86cb9afe0 Initial plan 2026-04-15 18:15:23 +00:00
copilot-swe-agent[bot]andnaturallaw777 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] 337f858a7a Initial plan 2026-04-15 18:08:40 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] d458d8c07a Initial plan 2026-04-15 18:00:21 +00:00
Sovran_Systems 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] d3d90f6e94 Initial plan 2026-04-15 17:35:39 +00:00
copilot-swe-agent[bot]andnaturallaw777 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] 2e93514a4d Initial plan 2026-04-15 17:25:34 +00:00
Sovran_Systems 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] 2ff983f5f4 Initial plan 2026-04-15 17:05:40 +00:00
copilot-swe-agent[bot] fc6f58b00e Initial plan 2026-04-15 17:04:40 +00:00
Sovran_Systems 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] 1ce4a2a520 Initial plan 2026-04-15 16:33:30 +00:00
copilot-swe-agent[bot] 97a868e0f9 Initial plan 2026-04-15 16:31:03 +00:00
Sovran_Systems 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] 4119a4ef61 Initial plan 2026-04-15 16:04:21 +00:00
copilot-swe-agent[bot] 604eb11584 Initial plan 2026-04-15 16:01:35 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 9998306a0c Initial plan 2026-04-15 15:32:38 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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_Systems 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] 4ce6341eb3 Initial plan 2026-04-15 15:11:07 +00:00
copilot-swe-agent[bot]andnaturallaw777 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] cf39e28921 Initial plan 2026-04-15 15:01:26 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 13f15cb845 Initial plan 2026-04-15 12:34:39 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] bebca8f1ab Initial plan 2026-04-15 01:43:37 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 04587efad3 Initial plan 2026-04-15 01:30:18 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 9c47c99645 Initial plan 2026-04-14 22:00:01 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 6c2cbd5b3b Initial plan 2026-04-14 21:42:15 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 8c4a8e4313 Initial plan 2026-04-14 21:16:39 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 99413a5dbe Initial plan 2026-04-14 13:44:07 +00:00
naturallaw777 a23e9f5c45 update readme 2026-04-13 19:59:42 -05:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 55cd583569 Initial plan 2026-04-14 00:43:21 +00:00
naturallaw777 77bdf710c7 update readme 2026-04-13 19:05:47 -05:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] c5bbb5220e Initial plan 2026-04-13 23:59:21 +00:00
naturallaw777 57dcf312bc update readme 2026-04-13 18:50:04 -05:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 32b2ee7117 Initial plan 2026-04-13 23:15:32 +00:00
Sovran_Systems 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] 38b45013b3 Initial plan 2026-04-13 22:54:20 +00:00
copilot-swe-agent[bot] 2db344f91f Initial plan 2026-04-13 22:52:35 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 21be0af9c1 Initial plan 2026-04-13 22:36:23 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 5b0babed1f Initial plan 2026-04-13 22:31:26 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] bf9f596bb3 Initial plan 2026-04-13 22:24:18 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 5e4e2c8d71 Initial plan 2026-04-13 22:11:54 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 6126fbf0ca Initial plan 2026-04-13 21:58:15 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 6da8730453 Initial plan 2026-04-13 03:57:26 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] be6766871a Initial plan 2026-04-13 01:19:35 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 9d81af72ff Initial plan 2026-04-13 01:08:24 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 97a7a9163e Initial plan 2026-04-13 00:59:53 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 211a32db45 Initial plan 2026-04-13 00:50:55 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 9f47fd6ba3 Initial plan 2026-04-13 00:25:41 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 377bb63122 Initial plan 2026-04-13 00:14:35 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] e6c76e636e Initial plan 2026-04-13 00:07:01 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 3fd000ac4e Initial plan 2026-04-12 23:08:49 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 92b46d1bba Initial plan 2026-04-12 21:35:00 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 8f792bb192 Initial plan 2026-04-12 20:50:10 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 8af03e53db Initial plan 2026-04-12 19:07:20 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 56652c37f4 Initial plan 2026-04-12 18:47:12 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] d61211e300 Initial plan 2026-04-12 18:33:50 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] a8efb3d880 Initial plan 2026-04-12 18:16:38 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] fe51d69700 Initial plan 2026-04-12 18:06:28 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] 650d693849 Initial plan 2026-04-12 15:31:33 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 38acee7319 Initial plan 2026-04-12 15:01:51 +00:00
naturallaw777 7cba8e6258 readme update 2026-04-12 09:26:15 -05:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 57de0d31a7 Initial plan 2026-04-12 12:56:17 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] ece73148cd Initial plan 2026-04-12 12:24:20 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] d2d2ed58a6 Initial plan 2026-04-12 12:05:34 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 0f77c6834c Initial plan 2026-04-12 11:45:49 +00:00
naturallaw777 f1188abff5 readme update 2026-04-12 00:11:46 -05:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] e1a06079fd Initial plan 2026-04-12 04:58:42 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 2edc4c1829 Initial plan 2026-04-12 04:47:49 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 458b8fae0b Initial plan 2026-04-12 04:28:13 +00:00
copilot-swe-agent[bot] 6e5863ed48 Initial plan 2026-04-12 04:26:11 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 51537c47b3 Initial plan 2026-04-12 04:09:58 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] eb3463cdfe Initial plan 2026-04-12 02:13:26 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 35e56afa0f Initial plan 2026-04-12 00:48:14 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] af14622e45 Initial plan 2026-04-12 00:14:43 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 9ec8618f7d Initial plan 2026-04-11 23:27:26 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 357624193b Initial plan 2026-04-11 22:23:50 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 9c8f359c0d Initial plan 2026-04-11 21:37:04 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 1bda7e9920 Initial plan 2026-04-09 22:33:20 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 47049a94bd Initial plan 2026-04-09 22:24:36 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] df353d1318 Initial plan 2026-04-09 22:11:49 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] d398faa0ed Initial plan 2026-04-09 19:45:43 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 7fdee314c8 Initial plan 2026-04-09 19:30:28 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] bd5ae05e20 Initial plan 2026-04-09 19:22:49 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 21ea5e5303 Initial plan 2026-04-09 19:09:56 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 21723a6860 Initial plan 2026-04-09 18:44:42 +00:00
naturallaw777 3d646fec0a key 2026-04-09 12:59:11 -05:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 5bb2c67b1b Initial plan 2026-04-09 17:26:39 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 885fc7f099 Initial plan 2026-04-09 17:17:07 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 701c400916 Initial plan 2026-04-09 10:49:18 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 2cfb23e670 Initial plan 2026-04-09 04:04:38 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] dca53835f4 Initial plan 2026-04-09 03:51:28 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] d77dde4020 Initial plan 2026-04-09 03:43:01 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 5cd9b6bb3d Initial plan 2026-04-09 03:21:30 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] bf9e82cd79 Initial plan 2026-04-09 02:58:35 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] f8bdc1cb15 Initial plan 2026-04-09 02:06:47 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 477d265de8 Initial plan 2026-04-09 01:49:35 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] b073564cce Initial plan 2026-04-09 00:16:48 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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_Systems 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] d9fba84243 Initial plan 2026-04-09 00:08:33 +00:00
copilot-swe-agent[bot]andnaturallaw777 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] b17ccce53a Initial plan 2026-04-09 00:02:10 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 059eaefa0c Initial plan 2026-04-08 22:10:31 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 73401353e4 Initial plan 2026-04-08 21:28:22 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 4c5e639cfa Initial plan 2026-04-08 21:00:10 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] f7af81eba4 Initial plan 2026-04-08 20:41:59 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 48926d1937 Initial plan 2026-04-08 18:15:54 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] f15e5616b7 Initial plan 2026-04-08 17:48:16 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] b47f0986d5 Initial plan 2026-04-08 16:57:47 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 3365ab5639 Initial plan 2026-04-08 16:49:49 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 3673ccf39b Initial plan 2026-04-08 16:00:59 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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_Systems 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] 0ac7ac4343 Initial plan 2026-04-08 14:43:14 +00:00
copilot-swe-agent[bot] 92734dd251 Initial plan 2026-04-08 14:36:01 +00:00
copilot-swe-agent[bot] 08c8b7d09c Initial plan 2026-04-08 14:33:44 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 349de76b6b Initial plan 2026-04-08 13:40:51 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 9470ce74c1 Initial plan 2026-04-08 13:13:41 +00:00
copilot-swe-agent[bot] cb0bcdb94c Initial plan 2026-04-08 13:09:47 +00:00
Sovran_Systems 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_Systems 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] 7781d6c849 Initial plan 2026-04-08 12:40:12 +00:00
copilot-swe-agent[bot] 4227024fba Initial plan 2026-04-08 12:39:45 +00:00
copilot-swe-agent[bot] 7243f4444f Initial plan 2026-04-08 12:34:30 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 8d6a20d375 Initial plan 2026-04-08 12:22:13 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 51c7d172b3 Initial plan 2026-04-08 02:46:49 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] d2703ff84b Initial plan 2026-04-08 00:51:40 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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_Systems 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] 60084c292e Initial plan 2026-04-08 00:38:42 +00:00
copilot-swe-agent[bot]andnaturallaw777 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] 70f0af98f6 Initial plan 2026-04-08 00:33:37 +00:00
Sovran_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] 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_Systems 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_Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] c7bbb97a68 Initial plan 2026-04-07 21:39:42 +00:00
copilot-swe-agent[bot]andnaturallaw777 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] 3b73eb3bd1 Initial plan 2026-04-07 21:28:36 +00:00
Sovran_Systems 6ffcc056ad Merge pull request #131 from naturallaw777/copilot/fix-sovran-legacy-security-check
Replace Python `crypt` module with `openssl passwd` (Python 3.13 compatibility)
2026-04-07 16:17:02 -05:00
copilot-swe-agent[bot]andnaturallaw777 742f680d0d fix: replace Python crypt module with openssl passwd for Python 3.13 compatibility
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9544e3d5-f7f8-4299-9198-3b5f1f835d14

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 21:11:13 +00:00
copilot-swe-agent[bot] c872f1c6b0 Initial plan 2026-04-07 21:04:58 +00:00
Sovran_Systems bc5a40f143 Merge pull request #130 from naturallaw777/copilot/add-sovran-auto-seal-service
Add sovran-auto-seal: automatic first-boot seal with live-system safety guards
2026-04-07 15:48:25 -05:00
copilot-swe-agent[bot]andnaturallaw777 c2bd3f6273 Add sovran-auto-seal systemd service to factory-seal.nix
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/869df8d4-3811-4a1a-b026-e978d3a81589

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 20:43:15 +00:00
copilot-swe-agent[bot] 343dee3576 Initial plan 2026-04-07 20:40:53 +00:00
Sovran_Systems ebcafd3c6d Merge pull request #129 from naturallaw777/copilot/add-tmpfiles-rules-for-bitcoin-electrs
[WIP] Add tmpfiles rules for Bitcoin and Electrs data directories
2026-04-07 15:21:26 -05:00
copilot-swe-agent[bot]andnaturallaw777 5231b5ca4b Add systemd.tmpfiles.rules for Bitcoin/Electrs directory permissions
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ea46340b-7cf5-404b-9cef-b5ed1fcb2ecb

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 20:21:07 +00:00
Sovran_Systems 1195456bee Merge pull request #128 from naturallaw777/copilot/fix-flake-nix-references
[WIP] Fix flake.nix references after nixos-install cleanup
2026-04-07 15:21:02 -05:00
copilot-swe-agent[bot]andnaturallaw777 48de6b9821 fix(installer): improve error handling for deployed flake.nix write
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b7dfaecc-2b2e-4f5f-bb9a-f97ced90e76e

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 20:20:36 +00:00
copilot-swe-agent[bot] cd4a17fe31 Initial plan 2026-04-07 20:20:01 +00:00
copilot-swe-agent[bot]andnaturallaw777 d3a5b3e6ef fix(installer): write deployed flake.nix and remove flake.lock after install cleanup
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b7dfaecc-2b2e-4f5f-bb9a-f97ced90e76e

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 20:18:36 +00:00
copilot-swe-agent[bot] 3c4c6c7389 Initial plan 2026-04-07 20:16:57 +00:00
Sovran_Systems 876f728aa2 Merge pull request #127 from naturallaw777/copilot/update-api-password-check
Use /etc/shadow as authoritative source for factory default password detection
2026-04-07 13:55:53 -05:00
copilot-swe-agent[bot]andnaturallaw777 950a6dabd8 Use /etc/shadow as source of truth for factory default password detection
- server.py: add _is_free_password_default() helper that reads /etc/shadow
  and hashes known defaults ("free", "gosovransystems") via crypt module;
  update api_password_is_default to use it instead of reading the secrets file
- factory-seal.nix: replace file-based free-password check with shadow-based
  cryptographic check using python3 + crypt module; add pkgs.python3 to path;
  pass values via env vars to avoid shell expansion of hash $ characters

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/31e6fc93-8b4b-47af-9c47-568da0905301

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 18:50:16 +00:00
copilot-swe-agent[bot] 1d9589a186 Initial plan 2026-04-07 18:46:24 +00:00
Sovran_Systems b13fa7dc05 Merge pull request #126 from naturallaw777/copilot/fix-security-warning-reappearance
Fix legacy security warning reappearing on every reboot after password change
2026-04-07 13:29:32 -05:00
copilot-swe-agent[bot]andnaturallaw777 069f6c3ec7 Avoid storing password in variable to prevent process listing exposure
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c18311e4-609d-4edf-a2a1-a018baede373

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 18:27:32 +00:00
copilot-swe-agent[bot]andnaturallaw777 5a27b79b51 Fix security warning reappearing after every reboot
Add two early-exit checks in sovran-legacy-security-check before the
legacy fallthrough block:
1. Exit if /var/lib/sovran/onboarding-complete exists (Hub onboarding done)
2. Exit if /var/lib/secrets/free-password exists and is not "free" (password changed)

This prevents the boot-time service from overwriting the security-status
file that /api/change-password clears after a successful password change.

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c18311e4-609d-4edf-a2a1-a018baede373

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 18:26:54 +00:00
copilot-swe-agent[bot] 72453c80bf Initial plan 2026-04-07 18:25:47 +00:00
naturallaw777 14800ffb1e update flake 2026-04-07 13:14:21 -05:00
naturallaw777 e2f36d01bc update flake 2026-04-07 13:13:06 -05:00
naturallaw777 55b231b456 update flake and installer 2026-04-07 13:11:39 -05:00
Sovran_Systems b4b2607df1 Merge pull request #125 from naturallaw777/copilot/update-security-check-for-unsealed-state
[WIP] Update sovran-legacy-security-check to warn on unsealed state
2026-04-07 12:50:45 -05:00
copilot-swe-agent[bot]andnaturallaw777 ac9ba4776c Detect and warn when machine was set up without factory seal
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/169de2bb-0655-4504-a270-8c0341c0d3dd

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 17:48:38 +00:00
copilot-swe-agent[bot] 85aca0d022 Initial plan 2026-04-07 17:45:41 +00:00
Sovran_Systems 80c74b2d1a Merge pull request #124 from naturallaw777/copilot/add-password-creation-step-onboarding
Add password creation step to first-boot onboarding wizard
2026-04-07 12:45:34 -05:00
copilot-swe-agent[bot]andnaturallaw777 d28f224ad5 feat: add password creation step to onboarding wizard (#2)
- Add GET /api/security/password-is-default endpoint in server.py
- Add Step 2 (Create Your Password) to onboarding wizard HTML
- Renumber old steps: Domains→3, Ports→4, Complete→5
- Add 5th step dot indicator
- Update onboarding.js: TOTAL_STEPS=5, ROLE_SKIP_STEPS=[3,4] for desktop/node
- Add loadStep2/saveStep2 for password step with smart default detection
- Rename old step functions to loadStep3/saveStep3/loadStep4
- Add password form CSS styles in onboarding.css

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/74a30916-fb2d-4f1d-9763-e380b1aa5540

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 17:36:59 +00:00
copilot-swe-agent[bot] f2a808ed13 Initial plan 2026-04-07 17:29:46 +00:00
Sovran_Systems 4ef420651d Merge pull request #122 from naturallaw777/copilot/fix-installer-create-password-step
Fix installer password step: replace chroot+sh with direct chpasswd --root
2026-04-07 12:17:24 -05:00
copilot-swe-agent[bot]andnaturallaw777 65ce66a541 Fix chpasswd: run directly from host with --root /mnt, no chroot needed
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3ff98bf4-8f62-4c81-90fd-36854e88266f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 17:14:32 +00:00
copilot-swe-agent[bot] deae53b721 Initial plan 2026-04-07 17:13:16 +00:00
Sovran_Systems f459e83861 Merge pull request #121 from naturallaw777/copilot/fix-change-password-form-issues
Fix System Passwords change-password form: chpasswd path on NixOS, show/hide toggle, UX clarity
2026-04-07 12:03:23 -05:00
copilot-swe-agent[bot]andnaturallaw777 badab99242 Fix chpasswd path on NixOS, add password toggle/hints/validation in change-password form
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/de03873d-5cdb-4929-bd4a-4d306916b525

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 17:01:54 +00:00
copilot-swe-agent[bot] 84124ba1b1 Initial plan 2026-04-07 16:57:23 +00:00
Sovran_Systems 2ad0d2072d Merge pull request #119 from naturallaw777/copilot/fix-change-passwords-button
[WIP] Fix non-functional change passwords button in Hub
2026-04-07 11:45:15 -05:00
copilot-swe-agent[bot]andnaturallaw777 ff1632dcda Fix Change Passwords button: add API endpoint, system password modal, fix security banner link
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/bf43bea9-9f93-4f7b-b6fd-c76714e7f25b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 16:44:57 +00:00
Sovran_Systems 531b8c1d09 Merge pull request #120 from naturallaw777/copilot/fix-installer-password-step
[WIP] Fix installer failure at 'Create Password' step
2026-04-07 11:44:49 -05:00
copilot-swe-agent[bot]andnaturallaw777 a8128cef8d Fix chpasswd: find binary in Nix store and pipe password inline
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/630a25f6-417a-47de-b163-b519252b403c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 16:43:50 +00:00
copilot-swe-agent[bot] 3baffb2a69 Initial plan 2026-04-07 16:42:51 +00:00
copilot-swe-agent[bot] 06bdf999a6 Initial plan 2026-04-07 16:41:02 +00:00
Sovran_Systems 76ff1f4d4f Merge pull request #118 from naturallaw777/copilot/fix-update-status-handling
[WIP] Fix update status handling for interrupted builds
2026-04-07 11:29:49 -05:00
copilot-swe-agent[bot]andnaturallaw777 2360b4147c fix: recover stale RUNNING status files on Hub server startup
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/22f9df39-fb39-4ffb-8c6b-c7323a894bee

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 16:29:08 +00:00
copilot-swe-agent[bot] 37874ff58e Initial plan 2026-04-07 16:26:26 +00:00
Sovran_Systems aef13155fc Merge pull request #117 from naturallaw777/copilot/remove-security-warning-modal
[WIP] Remove legacy password warning modal and add inline message
2026-04-07 11:17:56 -05:00
copilot-swe-agent[bot]andnaturallaw777 1d4f104524 Replace security warning modal with inline banner in Preferences section
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e7946288-08c7-4081-85dd-6780f1eba17a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 16:17:23 +00:00
copilot-swe-agent[bot] 11ec4b4816 Initial plan 2026-04-07 16:14:42 +00:00
Sovran_Systems 2bd899848d Merge pull request #115 from naturallaw777/copilot/add-password-warning-screen
[WIP] Add old password warning screen for legacy machines
2026-04-07 10:50:11 -05:00
Sovran_Systems 18a6e8d24c Merge pull request #116 from naturallaw777/copilot/fix-installer-password-error
Fix installer password step: replace bare chroot with nixos-enter
2026-04-07 10:49:31 -05:00
copilot-swe-agent[bot]andnaturallaw777 13c686a8a1 feat: add legacy security warning API and UI modal for pre-factory-seal machines
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f7c8f11b-873b-403f-ac55-8b5b7cd9f1fb

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 15:49:25 +00:00
copilot-swe-agent[bot]andnaturallaw777 7a172c0306 Fix chpasswd not found by using nixos-enter instead of bare chroot
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/1bb103de-c4a5-4701-b1b8-6aad670b97c3

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 15:45:30 +00:00
copilot-swe-agent[bot] 7fc04fcf20 Initial plan 2026-04-07 15:44:31 +00:00
copilot-swe-agent[bot] a40ea61415 Initial plan 2026-04-07 15:43:22 +00:00
naturallaw777 eba517d34d update flake 2026-04-07 10:13:24 -05:00
Sovran_Systems 38257492bd Merge pull request #113 from naturallaw777/copilot/remove-pdf-references
Rename credentials-pdf.nix → credentials.nix and remove all pdf references
2026-04-07 10:06:28 -05:00
naturallaw777 93592c984d removed erroniousfile 2026-04-07 10:05:37 -05:00
copilot-swe-agent[bot]andnaturallaw777 7a08bc0b2b Remove all PDF references: rename credentials-pdf.nix and update references
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/150954c9-65a0-4d5b-b8e2-08f301f07511

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 15:04:33 +00:00
copilot-swe-agent[bot] 25e8cac613 Initial plan 2026-04-07 15:02:58 +00:00
Sovran_Systems 02eaea85d8 Merge pull request #112 from naturallaw777/copilot/add-zeus-connect-setup-service
[WIP] Add zeus-connect-setup service to wallet autoconnect module
2026-04-07 09:54:16 -05:00
copilot-swe-agent[bot]andnaturallaw777 6c433d642d Add zeus-connect-setup service and timer to wallet-autoconnect.nix
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6b3d9c59-40e1-45c1-93f9-a5ba6547567b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 14:52:40 +00:00
copilot-swe-agent[bot] 7aed3e09e8 Initial plan 2026-04-07 14:51:00 +00:00
Sovran_Systems e0e6ab0de6 Merge pull request #111 from naturallaw777/copilot/fix-wordpress-init-service
[WIP] Fix wordpress-init systemd service path issues
2026-04-07 09:47:04 -05:00
copilot-swe-agent[bot]andnaturallaw777 7a1cd8a6f6 fix(wordpress): use /run/wrappers/bin/su to fix su: command not found in wordpress-init service
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/24a9d2b1-6b09-41ac-bb3b-418f0ea2b2d7

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 14:46:46 +00:00
copilot-swe-agent[bot] 9407d500c8 Initial plan 2026-04-07 14:44:38 +00:00
naturallaw777 9f1dd7def1 updated nextcloud.nix 2026-04-07 09:35:23 -05:00
Sovran_Systems 480f188d86 Merge pull request #110 from naturallaw777/copilot/remove-credentials-pdf-generator
Factory security: per-device SSH passphrase, factory seal command, customer password onboarding
2026-04-07 09:28:07 -05:00
naturallaw777 e2bd366bb3 updated nextcloud.nix 2026-04-07 09:27:25 -05:00
copilot-swe-agent[bot]andnaturallaw777 f80c8a0481 Factory security: per-device SSH passphrase, factory seal, password onboarding, remove PDF generator
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4222f228-615c-4303-8286-979264c6f782

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 14:23:59 +00:00
naturallaw777 7e996fffa1 updated nextcloud.nix 2026-04-07 09:11:13 -05:00
copilot-swe-agent[bot] d14e25c29f Initial plan 2026-04-07 13:58:07 +00:00
Sovran_Systems 1ed7ab9776 Merge pull request #109 from naturallaw777/copilot/add-extra-virtual-hosts-option
[WIP] Add NixOS option for extra Caddy virtual hosts
2026-04-07 08:07:17 -05:00
copilot-swe-agent[bot]andnaturallaw777 dd8867b52f feat: add sovran_systemsOS.caddy.extraVirtualHosts NixOS option
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e966dd20-b74e-4ec5-b4db-68aa06129162

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 13:06:35 +00:00
copilot-swe-agent[bot] 3668eb2829 Initial plan 2026-04-07 13:02:55 +00:00
Sovran_Systems e751dfc1b2 Merge pull request #108 from naturallaw777/copilot/fix-hub-detection-bug
[WIP] Fix hub detection bug in port status check
2026-04-07 07:48:04 -05:00
copilot-swe-agent[bot]andnaturallaw777 6c3bbbf72b Fix Hub false closed port detection: is_listening alone is sufficient; add nftables package
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b57cc894-c639-400e-93f0-c1dc5d48870b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 12:47:03 +00:00
copilot-swe-agent[bot] 9dcb45a017 Initial plan 2026-04-07 12:44:22 +00:00
Sovran_Systems b9069433b1 Merge pull request #107 from naturallaw777/copilot/add-icon-to-service-detail-modal
[WIP] Add service icon to modal header
2026-04-07 05:40:19 -05:00
copilot-swe-agent[bot]andnaturallaw777 739f6a08da Add service icon to modal header in openServiceDetailModal and openCredsModal
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3f26f03c-29fc-4d37-9d53-eebfb8a34c52

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 10:39:59 +00:00
copilot-swe-agent[bot] 2fc8b64964 Initial plan 2026-04-07 10:36:32 +00:00
Sovran_Systems 6e133b6b59 Merge pull request #106 from naturallaw777/copilot/add-sparrow-bisq-auto-connect-tiles
[WIP] Add tile descriptions and launch links for Sparrow and Bisq Auto-Connect
2026-04-07 05:36:23 -05:00
copilot-swe-agent[bot]andnaturallaw777 01e3e02a62 Add sparrow/bisq tile descriptions, desktop launch API, and frontend launch buttons
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5a3d2f20-4635-442e-82ba-c0b7f4aeb96e

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 10:35:35 +00:00
copilot-swe-agent[bot] 85af70e2ee Initial plan 2026-04-07 10:32:41 +00:00
naturallaw777 b21d9bef87 updated branding for hub 2026-04-07 05:20:41 -05:00
Sovran_Systems 26b89dae76 Merge pull request #105 from naturallaw777/copilot/create-desktop-icon-svg
Separate Hub desktop/dock icon from web UI branding logo
2026-04-07 05:17:55 -05:00
copilot-swe-agent[bot]andnaturallaw777 b2a2ef70a4 fix: add SVG title element for accessibility in sovran-hub-icon.svg
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e3f466ae-eee1-4ba8-b93c-00fe04c7054d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 10:14:47 +00:00
copilot-swe-agent[bot]andnaturallaw777 8286e00eb3 feat: create dedicated desktop dock icon and update nix build to use it
- Add app/sovran_systemsos_web/static/sovran-hub-icon.svg: a new square
  256x256 app icon for the GNOME dock/dash. Uses the Sovran brand dark
  green (#0d3320) rounded-rectangle background, concentric arc rings in
  brand greens (#1C9954, #077233), and a white bold "S" letterform
  centered — visible at small sizes on both light and dark panels.
- Update modules/core/sovran-hub.nix line 266 to copy the new icon file
  to the hicolor icon path instead of reusing logo-light.svg.
- logo-light.svg is left untouched; it continues to serve the Hub web UI.

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e3f466ae-eee1-4ba8-b93c-00fe04c7054d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 10:14:04 +00:00
copilot-swe-agent[bot] c7487c9763 Initial plan 2026-04-07 10:11:52 +00:00
Sovran_Systems e910d0a8a7 Merge pull request #104 from naturallaw777/copilot/update-header-layout
[WIP] Update header layout for space efficiency
2026-04-07 05:09:12 -05:00
copilot-swe-agent[bot]andnaturallaw777 a3b9608887 fix: compact header layout - row direction, 80px logo, reduced padding
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4f63f512-d505-470b-9733-1054281a98d8

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 10:08:30 +00:00
copilot-swe-agent[bot] 09002cfe22 Initial plan 2026-04-07 10:07:15 +00:00
Sovran_Systems 8a3b5c031f Merge pull request #103 from naturallaw777/copilot/fix-bitcoin-version-formatting
[WIP] Fix bitcoin version formatting to retain BIP110 patch version
2026-04-07 04:56:08 -05:00
copilot-swe-agent[bot]andnaturallaw777 b441515f89 Fix _format_bitcoin_version to include BIP110 patch version in tile display
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/951363ee-aa21-479a-9d79-0c3b5f265bf7

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 09:55:13 +00:00
copilot-swe-agent[bot] b7c1632bb8 Initial plan 2026-04-07 09:53:45 +00:00
naturallaw777 a7d40fb138 updated branding for hub 2026-04-07 04:26:09 -05:00
Sovran_Systems 8f4ec83104 Merge pull request #101 from naturallaw777/copilot/fix-sparrow-bisq-tile-icons
Fix Sparrow and Bisq tile icons rendering too small
2026-04-06 23:42:47 -05:00
Sovran_Systems fba4ab13cf Merge pull request #102 from naturallaw777/copilot/fix-version-display-non-bitcoin-services
Fix version display: strip versions from non-Bitcoin tiles, wire BIP110 version correctly
2026-04-06 23:42:33 -05:00
copilot-swe-agent[bot]andnaturallaw777 5ba1a256fe fix: tighten viewBox and remove transforms on bisq.svg and sparrow.svg icons
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/bed7824c-f988-4a33-a052-4a013aa1110d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 04:41:37 +00:00
copilot-swe-agent[bot]andnaturallaw777 5ee0ef4d58 Remove non-Bitcoin version detection; only bitcoind.service tiles show versions
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/75ae6f5b-ccf1-4051-b9ae-e07c9218227d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 04:38:42 +00:00
copilot-swe-agent[bot] 5a77a03ac0 Initial plan 2026-04-07 04:36:05 +00:00
copilot-swe-agent[bot] 5349c2408a Initial plan 2026-04-07 04:33:21 +00:00
naturallaw777 9ea4fb32f4 updated sparrow and bisq icon 2026-04-06 23:27:38 -05:00
Sovran_Systems 9e1673ef7f Merge pull request #100 from naturallaw777/copilot/fix-bitcoin-tile-version-display
[WIP] Fix version display on Bitcoin tiles in Hub dashboard
2026-04-06 23:22:40 -05:00
copilot-swe-agent[bot]andnaturallaw777 44a7b2a8ab fix: use bitcoind --version for Bitcoin tile version display (works during IBD/startup)
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/63b5dc59-a630-4c14-a6a7-99a71ee517b7

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 04:22:19 +00:00
Sovran_Systems aa24505314 Merge pull request #99 from naturallaw777/copilot/add-sparrow-and-bisq-icons
Add missing sparrow.svg and bisq.svg service tile icons
2026-04-06 23:20:23 -05:00
copilot-swe-agent[bot] d0bf878555 Initial plan 2026-04-07 04:18:20 +00:00
copilot-swe-agent[bot]andnaturallaw777 2e5be9816e Add missing sparrow.svg and bisq.svg service tile icons
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d7bbfadc-d5a8-4750-81b4-685ccb993d70

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 03:06:12 +00:00
copilot-swe-agent[bot] 87ecccff9e Initial plan 2026-04-07 02:58:50 +00:00
naturallaw777 d60a44b438 updated hub-icon 2026-04-06 21:54:47 -05:00
Sovran_Systems 9413bb6403 Merge pull request #98 from naturallaw777/copilot/fix-bip110-version-check
Fix BIP110 version label: detect via tile icon, not subversion string
2026-04-06 21:53:44 -05:00
copilot-swe-agent[bot]andnaturallaw777 28bcddb957 Fix BIP110 version display: detect by tile icon, not subversion string
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/23090422-e59c-4d7e-8d5e-6fd36b6cf337

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 02:50:02 +00:00
copilot-swe-agent[bot] 1737e93c68 Initial plan 2026-04-07 02:47:51 +00:00
Sovran_Systems 90fdbdea70 Merge pull request #97 from naturallaw777/copilot/update-bitcoin-tiles-version-display
Fix Bitcoin tile version: only active tile shows version, preserve BIP110 tag
2026-04-06 21:39:30 -05:00
copilot-swe-agent[bot]andnaturallaw777 90ffadf2ea Fix Bitcoin tile version: preserve bip110 tag, only show version on enabled tile
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/05d1b130-dd46-4132-8120-2df883325c2a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 02:35:32 +00:00
copilot-swe-agent[bot] f6c9080cea Initial plan 2026-04-07 02:33:12 +00:00
Sovran_Systems 8e40faad75 Merge pull request #96 from naturallaw777/copilot/add-bitcoind-to-sovran-hub-path
Add bitcoind to sovran-hub-web service PATH so Bitcoin version renders on Hub tiles
2026-04-06 21:27:06 -05:00
copilot-swe-agent[bot]andnaturallaw777 4978d44ba2 Add bitcoind to sovran-hub-web PATH so Bitcoin version shows on Hub tiles
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/b8aaba8d-2c51-40ca-9826-69b78060a840

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 02:26:16 +00:00
copilot-swe-agent[bot] 6fefed2909 Initial plan 2026-04-07 02:25:28 +00:00
naturallaw777 1a48266cde updated custom-template 2026-04-06 21:13:22 -05:00
Sovran_Systems 639d39108a Merge pull request #95 from naturallaw777/copilot/fix-version-detection-nixos
[WIP] Fix version detection for NixOS systemd services
2026-04-06 21:10:32 -05:00
copilot-swe-agent[bot]andnaturallaw777 185ed4e3d8 Further tighten regex: stricter version pattern, no underscores in name segments, precise trailing-dot strip
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d75fe7da-369a-40e9-913e-7dba45de21c3

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 02:10:15 +00:00
copilot-swe-agent[bot]andnaturallaw777 8240b9af3c Address review feedback: module-level wrapper suffix regex, allow digit-starting name segments
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d75fe7da-369a-40e9-913e-7dba45de21c3

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 02:08:23 +00:00
copilot-swe-agent[bot]andnaturallaw777 deb66c9cb7 Replace CLI-based version detection with Nix store path extraction via systemctl show
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d75fe7da-369a-40e9-913e-7dba45de21c3

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 02:06:13 +00:00
copilot-swe-agent[bot] 8a49a3d04e Initial plan 2026-04-07 02:03:49 +00:00
Sovran_Systems cdccb8138c Merge pull request #94 from naturallaw777/copilot/cleanup-etc-nixos-post-install
[WIP] Remove unnecessary files from /etc/nixos after installation
2026-04-06 21:03:03 -05:00
copilot-swe-agent[bot]andnaturallaw777 09a817f02d feat: clean up /mnt/etc/nixos after nixos-install, keep only 5 required files
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/08d1a4eb-697e-46d4-bb8e-71af6bb4316f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 02:02:19 +00:00
copilot-swe-agent[bot] 4edcf066ca Initial plan 2026-04-07 02:01:07 +00:00
Sovran_Systems 0d49b67c7b Merge pull request #93 from naturallaw777/copilot/add-version-info-for-services
Add version numbers to all service tiles on the Hub dashboard
2026-04-06 20:56:52 -05:00
copilot-swe-agent[bot]andnaturallaw777 24bf72ef69 feat: add version display for all service tiles on Hub dashboard
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6b9b51e5-85a6-46ff-8683-120ecf3640da

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 01:55:41 +00:00
copilot-swe-agent[bot] 8459061968 Initial plan 2026-04-07 01:50:23 +00:00
Sovran_Systems b3f1c35995 Merge pull request #92 from naturallaw777/copilot/add-sparrow-bisq-integration
[WIP] Add NixOS module for Sparrow Wallet and Bisq 1 integration
2026-04-06 20:45:04 -05:00
copilot-swe-agent[bot]andnaturallaw777 27f27b1503 feat: add wallet-autoconnect module for Sparrow and Bisq 1
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/29aa6dce-667a-49a6-9740-68d501fed22c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 01:44:43 +00:00
copilot-swe-agent[bot] f108abd7ae Initial plan 2026-04-07 01:42:45 +00:00
Sovran_Systems 2be8fe65d8 Merge pull request #91 from naturallaw777/copilot/add-version-display-to-hub-dashboard
[WIP] Add version number display for active bitcoind implementation
2026-04-06 20:38:42 -05:00
copilot-swe-agent[bot]andnaturallaw777 a0c1628461 feat: display bitcoind version on Bitcoin node tile in Hub dashboard
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5b4f8da9-beec-45f2-b116-b5c0dcf4506d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 01:38:17 +00:00
copilot-swe-agent[bot] 06615a3541 Initial plan 2026-04-07 01:33:08 +00:00
Sovran_Systems 328b2a3ee8 Merge pull request #90 from naturallaw777/copilot/add-xdg-autostart-entry-hub
[WIP] Add XDG autostart entry for Sovran Hub auto-launch
2026-04-06 20:29:07 -05:00
copilot-swe-agent[bot]andnaturallaw777 5123287ef7 Fix curl command in hub-autolaunch-script (remove unnecessary -w flag)
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0b0d70c0-01d1-49d1-b9ca-8d4f8e5af64a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 01:28:55 +00:00
copilot-swe-agent[bot]andnaturallaw777 13e3b76c88 Add hub auto-launch: XDG autostart, API endpoints, and frontend toggle
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0b0d70c0-01d1-49d1-b9ca-8d4f8e5af64a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 01:26:11 +00:00
copilot-swe-agent[bot] 27502c6997 Initial plan 2026-04-07 01:22:39 +00:00
Sovran_Systems 6b467525cf Merge pull request #89 from naturallaw777/copilot/add-privacy-disclosure-to-upgrade-modal
Add privacy disclosure to Node → Server+Desktop upgrade modal
2026-04-06 19:59:39 -05:00
copilot-swe-agent[bot]andnaturallaw777 b1b0e85db7 Add privacy disclosure info box to Node→Server+Desktop upgrade modal
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5dadd5f8-7c8d-4aa1-be01-3dba9fc5dc1d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-07 00:42:58 +00:00
copilot-swe-agent[bot] e84dd7cb91 Initial plan 2026-04-07 00:41:57 +00:00
naturallaw777 94d94fb7a2 fixed ssh at first boot 2026-04-06 18:40:17 -05:00
Sovran_Systems e67b4fecc4 Merge pull request #87 from naturallaw777/copilot/remove-terminal-domain-setup
Remove redundant terminal domain setup script (fixes password prompt on boot)
2026-04-06 18:30:02 -05:00
copilot-swe-agent[bot]andnaturallaw777 f7539dc9b6 Remove redundant terminal domain setup script and update stale references
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ed7fee4d-b50e-4387-8eb6-46840b9d930f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-06 23:27:52 +00:00
copilot-swe-agent[bot] 5632068ca8 Initial plan 2026-04-06 23:25:46 +00:00
Sovran_Systems 99df7a097b Merge pull request #86 from naturallaw777/copilot/remove-pdf-mentions-and-icons
Installer completion screen: remove PDF ref, icon, and fix reboot button color
2026-04-06 18:19:28 -05:00
copilot-swe-agent[bot]andnaturallaw777 cc17c3fb42 Remove PDF mention, icon, and fix reboot button color in push_complete
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3bb82d50-1a0b-4f1d-b186-1e4efde002d1

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-06 23:18:39 +00:00
copilot-swe-agent[bot] d51919ec69 Initial plan 2026-04-06 23:17:29 +00:00
Sovran_Systems ad80b9d8c3 Merge pull request #85 from naturallaw777/copilot/replace-custom-template-nix
Simplify custom.template.nix and add-custom-nix.sh — remove hub-managed steps
2026-04-05 12:57:51 -05:00
copilot-swe-agent[bot]andnaturallaw777 3c4495c066 Simplify custom.template.nix and add-custom-nix.sh for hub-managed configuration
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f90e34be-674b-4047-8096-c0db7883de1a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 17:56:59 +00:00
copilot-swe-agent[bot] 4765a18224 Initial plan 2026-04-05 17:55:32 +00:00
Sovran_Systems 6bd11be8b5 Merge pull request #84 from naturallaw777/copilot/add-hardware-configuration-to-modules
Fix nixos-install: wire hardware-configuration.nix into flake and installer
2026-04-05 12:51:00 -05:00
Sovran_Systems f294828409 Merge pull request #83 from naturallaw777/copilot/add-internal-drive-check-role-cards
[WIP] Add internal drive check for role selection in push_welcome
2026-04-05 12:50:48 -05:00
copilot-swe-agent[bot]andnaturallaw777 953271eeee grey out node/server roles when no second internal drive detected
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/30ed5e6b-2d61-415c-ba07-aba31dbcd839

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 17:50:20 +00:00
copilot-swe-agent[bot]andnaturallaw777 70f3cef03a Fix NixOS install: add hardware-configuration.nix to flake modules and installer
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5db1cd99-2067-4b5c-ba11-3e9aa8fde973

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 17:50:18 +00:00
copilot-swe-agent[bot] c9a5a4dec9 Initial plan 2026-04-05 17:48:34 +00:00
copilot-swe-agent[bot] 37cc35bb1b Initial plan 2026-04-05 17:48:12 +00:00
Sovran_Systems 25b758304d Merge pull request #81 from naturallaw777/copilot/add-sovran-hub-desktop-entry
Add sovran-hub.desktop entry and icon for GNOME dock
2026-04-05 11:58:33 -05:00
Sovran_Systems 2385466b1a Merge pull request #82 from naturallaw777/copilot/fix-nixos-install-absolute-paths
fix(installer): copy role-state.nix and custom.nix to host /etc/nixos before nixos-install
2026-04-05 11:57:33 -05:00
copilot-swe-agent[bot]andnaturallaw777 37ad4fd2ad fix: copy role-state.nix and custom.nix to host /etc/nixos before nixos-install
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/38396f35-c812-43e5-9bf0-f7bd611cbba7

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 16:54:29 +00:00
copilot-swe-agent[bot] 35569e6ec0 Initial plan 2026-04-05 16:53:30 +00:00
copilot-swe-agent[bot]andnaturallaw777 536eb0deb1 Add sovran-hub.desktop entry and icon to GNOME dock
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/42720669-f980-4f13-989e-0728ea9307de

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 16:52:40 +00:00
copilot-swe-agent[bot] a390c4711a Initial plan 2026-04-05 16:51:21 +00:00
Sovran_Systems 86605bda05 Merge pull request #80 from naturallaw777/copilot/fix-nixos-install-error
Add --impure to nixos-install to allow absolute path references in flake
2026-04-05 11:24:47 -05:00
copilot-swe-agent[bot]andnaturallaw777 d6cdfcf31a Add --impure flag to nixos-install command
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7723d784-dc2c-41da-b523-451a63f335eb

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 16:23:40 +00:00
copilot-swe-agent[bot] a592b270af Initial plan 2026-04-05 16:22:43 +00:00
Sovran_Systems 4be9be34e0 Merge pull request #79 from naturallaw777/copilot/replace-disko-with-sgdisk-mkfs-mount
[WIP] Replace disko with direct sgdisk and mkfs commands in installer
2026-04-05 10:41:41 -05:00
copilot-swe-agent[bot]andnaturallaw777 cb7b097ce0 Drop disko: use direct sgdisk+mkfs+mount in installer, remove disko package and disko.nix
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3dbc739b-c3da-432d-b070-16217e58c76b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 15:40:42 +00:00
copilot-swe-agent[bot] 53a0010e47 Initial plan 2026-04-05 15:38:51 +00:00
Sovran_Systems 6d36023222 Merge pull request #77 from naturallaw777/copilot/fix-disable-ssh-option
[WIP] Fix bug to disable SSH after tech support session
2026-04-05 10:20:15 -05:00
copilot-swe-agent[bot]andnaturallaw777 7c1dbeac27 Fix disable SSH option and remove Feature Manager language
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3fc488d2-ef33-4d4f-aeb5-f2532c658aad

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 15:20:01 +00:00
Sovran_Systems 9386b073ba Merge pull request #78 from naturallaw777/copilot/fix-disko-command-wipe-handling
[WIP] Fix disko command handling in installer
2026-04-05 10:19:20 -05:00
copilot-swe-agent[bot]andnaturallaw777 cf09845431 Fix disko command: use single format,mount call since disks are pre-wiped
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/80b0e65c-24c9-4448-9fdb-870891ecc30e

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 15:18:54 +00:00
copilot-swe-agent[bot] d65193d7d3 Initial plan 2026-04-05 15:17:54 +00:00
copilot-swe-agent[bot] 0e0e91d1f8 Initial plan 2026-04-05 15:17:50 +00:00
Sovran_Systems a59165c31e Merge pull request #76 from naturallaw777/copilot/add-sshd-feature-module
[WIP] Add SSH feature module with default off setting
2026-04-05 10:11:11 -05:00
copilot-swe-agent[bot]andnaturallaw777 df2768c6fc feat: move sshd into its own Nix feature module, gate Tech Support behind it
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d45dc36f-0b3b-48bb-950f-700afe45dd06

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 15:09:02 +00:00
copilot-swe-agent[bot] 109c92a33a Initial plan 2026-04-05 15:03:10 +00:00
Sovran_Systems ca11cbbc79 Merge pull request #75 from naturallaw777/copilot/revert-commit-7c047a1
[WIP] Revert changes breaking local LAN access to Hub, RTL, and Mempool
2026-04-05 09:44:19 -05:00
copilot-swe-agent[bot]andnaturallaw777 6584b63c36 Revert commit 7c047a1: restore LAN access to Hub, RTL, and Mempool
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c92f1a7f-7c42-44f1-a86d-089383bafc94

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 14:43:49 +00:00
Sovran_Systems 88cac6c1e9 Merge pull request #74 from naturallaw777/copilot/fix-disko-command-error
Fix disko ESP mount failure on dual-NVMe by splitting destroy/format into sequential calls
2026-04-05 09:42:51 -05:00
copilot-swe-agent[bot] ef39040919 Initial plan 2026-04-05 14:42:36 +00:00
copilot-swe-agent[bot]andnaturallaw777 ca275c45de Fix disko mount failure by splitting destroy and format,mount into separate calls
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/0f9fe8d2-554e-4048-9dba-5a3c3c663410

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 14:41:16 +00:00
copilot-swe-agent[bot] fee6035de0 Initial plan 2026-04-05 14:40:22 +00:00
Sovran_Systems c42962f6da Merge pull request #73 from naturallaw777/copilot/remove-unnecessary-port-exposure
Security: restrict RTL, Hub, and Mempool to LAN-only access
2026-04-05 09:33:52 -05:00
copilot-swe-agent[bot]andnaturallaw777 7c047a16b7 Security: restrict RTL, Mempool ports to LAN-only; remove global firewall rules
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/1110322d-bc41-4d5d-9a4c-e5f7a5d2ef57

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 14:29:09 +00:00
copilot-swe-agent[bot] a3b34ef74b Initial plan 2026-04-05 14:27:55 +00:00
naturallaw777 8dc3066f06 syntax error 2026-04-05 09:27:38 -05:00
naturallaw777 04fd3c523b closed unused ports 2026-04-05 09:16:03 -05:00
Sovran_Systems 6f88d0726b Merge pull request #72 from naturallaw777/copilot/fix-disko-nix-partition-syntax-again
Fix disko partition syntax and remove broken Plymouth Spinner
2026-04-05 09:03:35 -05:00
copilot-swe-agent[bot]andnaturallaw777 53ea704e57 Fix disko.nix partition syntax and remove broken Spinner from Plymouth theme
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/487cd80b-c747-44b8-9479-d3f7f7cc3328

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 14:02:40 +00:00
copilot-swe-agent[bot] 4253518ceb Initial plan 2026-04-05 14:01:31 +00:00
Sovran_Systems 1c4382e655 Merge pull request #70 from naturallaw777/copilot/fix-iso-installer-blockers
[WIP] Fix confirmed blockers in ISO installer
2026-04-05 08:36:30 -05:00
copilot-swe-agent[bot]andnaturallaw777 5b1454adf6 Fix three installer blockers: disko --yes-wipe-all-disks, stdin=DEVNULL, nixos-install --no-root-password
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/477c45ee-0958-4ba8-9612-a3be1bff9c6d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 13:36:15 +00:00
copilot-swe-agent[bot] 6d3dbf497e Initial plan 2026-04-05 13:35:12 +00:00
Sovran_Systems 0526945114 Update README.md 2026-04-05 01:36:51 -05:00
Sovran_Systems 400a59f06a Merge pull request #69 from naturallaw777/copilot/fix-iso-installer-issues
[WIP] Fix issues in the ISO installer
2026-04-05 01:34:05 -05:00
copilot-swe-agent[bot]andnaturallaw777 48826590de Fix ISO installer: remove ports dialog, fix Plymouth paths/logo, add welcome page, fix pixelated icon, fix disko args
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/6b00bbbd-8ed5-4ef2-b2fc-bfbe6361e77c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 06:33:44 +00:00
Sovran_Systems 61cf06b4c7 Update README.md with new content 2026-04-05 01:32:15 -05:00
copilot-swe-agent[bot] f4a644dc05 Initial plan 2026-04-05 06:28:05 +00:00
Sovran_Systems fc847a17cd Update README.md 2026-04-05 01:09:30 -05:00
Sovran_Systems 6bb4aaf3ba Update README.md 2026-04-05 01:02:13 -05:00
Sovran_Systems beca9756ea Create README.md file for Sovran_SystemsOS 2026-04-05 00:48:49 -05:00
Sovran_Systems 54ed1db6cd Merge pull request #68 from naturallaw777/copilot/add-bitcoin-ibd-sync-indicator
feat: Bitcoin IBD sync progress bar in active Bitcoin tile
2026-04-05 00:45:49 -05:00
copilot-swe-agent[bot]andnaturallaw777 abaae7f360 feat: Bitcoin IBD sync progress indicator in Bitcoin tile
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/2c6f8fb7-5361-469b-b12b-ef846ffb669f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 05:33:05 +00:00
copilot-swe-agent[bot] 8ca1ea8e78 Initial plan 2026-04-05 05:26:49 +00:00
Sovran_Systems a0426b2fee Merge pull request #67 from naturallaw777/copilot/fix-disko-mode-and-disk-threshold
[WIP] Fix disko mode and 2 TB threshold issues
2026-04-05 00:21:44 -05:00
copilot-swe-agent[bot]andnaturallaw777 4fd8bd7534 Fix disko mode, 2 TB threshold, add interactive disk selection, fix data_path scoping
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a0f15fe6-f9a7-4f43-9f9d-5892b0f3aba4

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 05:21:18 +00:00
copilot-swe-agent[bot] b6046e63c5 Initial plan 2026-04-05 05:17:23 +00:00
Sovran_Systems 84d76f0436 Merge pull request #66 from naturallaw777/copilot/update-installer-for-desktop-only
Desktop Only: enforce 128 GB boot disk minimum, skip data disk logic
2026-04-05 00:08:54 -05:00
copilot-swe-agent[bot]andnaturallaw777 9664c59523 feat: enforce 128 GB minimum, skip data disk for Desktop Only role
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/2be6c138-feda-4c5d-9bd8-0e5f2f6416bc

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 05:07:55 +00:00
copilot-swe-agent[bot] 265f34b8aa Initial plan 2026-04-05 05:06:55 +00:00
Sovran_Systems 0d6e7e6381 Merge pull request #65 from naturallaw777/copilot/add-btcpay-web-feature
feat(node): add BTCPay Server web exposure as a toggleable feature
2026-04-04 23:59:15 -05:00
naturallaw777 4144198e4b changed to static icon 2026-04-04 23:58:53 -05:00
copilot-swe-agent[bot]andnaturallaw777 e5d3b9236c feat: add btcpay-web feature toggle for node role
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/3881717f-97fc-4b8a-8f01-794a0699e7b3

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 04:55:25 +00:00
copilot-swe-agent[bot] 53c2371c45 Initial plan 2026-04-05 04:53:41 +00:00
Sovran_Systems 48fb59b862 Merge pull request #64 from naturallaw777/copilot/make-backup-script-role-aware
[WIP] Update backup script to be role-aware
2026-04-04 23:43:35 -05:00
copilot-swe-agent[bot]andnaturallaw777 64744d1d93 Make backup script role-aware and add manual-backup docs
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a9c69b4d-1c8d-4ade-b444-33043e52fc63

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 04:43:04 +00:00
copilot-swe-agent[bot] a1d83e731a Initial plan 2026-04-05 04:39:42 +00:00
naturallaw777 f9ecdaec96 updated update icon 2026-04-04 23:34:23 -05:00
Sovran_Systems e8a7b2c7ab Merge pull request #63 from naturallaw777/copilot/replace-update-system-button-icon
Replace Update System sidebar button emoji with custom SVG icon
2026-04-04 23:26:14 -05:00
copilot-swe-agent[bot]andnaturallaw777 3e855af8d5 Replace Update System emoji with custom update.svg icon
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ff2815b6-a5d1-4f84-bffe-5c24d40760cd

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 04:25:28 +00:00
copilot-swe-agent[bot] 9672d30de4 Initial plan 2026-04-05 04:23:36 +00:00
Sovran_Systems 78d3559e7b Merge pull request #62 from naturallaw777/copilot/move-update-system-button
[WIP] Move Update System button to left sidebar
2026-04-04 23:10:25 -05:00
copilot-swe-agent[bot]andnaturallaw777 b8956ebf72 Move Update System button from header to sidebar
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/fb939db8-ba2c-4979-9b18-bebe2618d0b5

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 04:10:04 +00:00
copilot-swe-agent[bot] 4bda2f1aae Initial plan 2026-04-05 04:07:09 +00:00
Sovran_Systems 9897c2a991 Merge pull request #61 from naturallaw777/copilot/add-rdp-to-node-role
Add RDP to Bitcoin-only Node role
2026-04-04 23:04:42 -05:00
copilot-swe-agent[bot]andnaturallaw777 af31c60be8 Add RDP to Bitcoin-only Node role
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/17c88629-43c4-438a-9640-7abe3609c82d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 04:03:30 +00:00
copilot-swe-agent[bot] 2b89969a96 Initial plan 2026-04-05 04:02:35 +00:00
Sovran_Systems 88d0f8ffb8 Merge pull request #60 from naturallaw777/copilot/role-aware-service-filtering
[WIP] Refactor monitoredServices for role-aware service filtering
2026-04-04 22:55:55 -05:00
copilot-swe-agent[bot]andnaturallaw777 58966646c2 feat: role-aware hub — service filtering, onboarding, upgrade path
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/af4088da-8845-4f7f-914f-259fd33884ed

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 03:55:20 +00:00
copilot-swe-agent[bot] c28de5def9 Initial plan 2026-04-05 03:48:27 +00:00
Sovran_Systems 34d652acbb Merge pull request #59 from naturallaw777/copilot/add-manual-backup-feature
[WIP] Add manual backup feature to Tech Support section
2026-04-04 22:42:24 -05:00
copilot-swe-agent[bot]andnaturallaw777 cc72968583 Add Manual Backup improvements: lsblk drive filtering, UI instructions, CSS border fixes
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a43d270d-eb78-4ad3-b721-fe958883c305

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 03:41:53 +00:00
copilot-swe-agent[bot] 34db1439fa Initial plan 2026-04-05 03:36:18 +00:00
Sovran_Systems 980ea9a6a5 Merge pull request #58 from naturallaw777/copilot/add-manual-backup-button
[WIP] Add manual backup button in Hub sidebar for Sovran_SystemsOS
2026-04-04 22:24:23 -05:00
copilot-swe-agent[bot]andnaturallaw777 d864402de2 feat: Add Manual Backup button in Hub sidebar with drive detection and progress streaming
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/14dc5955-19b2-4e5b-965a-2795285a22fd

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 03:24:07 +00:00
copilot-swe-agent[bot] d59b878906 Initial plan 2026-04-05 03:18:02 +00:00
Sovran_Systems ac840b684f Merge pull request #57 from naturallaw777/copilot/remove-credentials-step-onboarding-wizard
Remove Credentials step from onboarding wizard (5 → 4 steps)
2026-04-04 22:06:37 -05:00
copilot-swe-agent[bot]andnaturallaw777 547ebdb000 Remove Credentials step from onboarding wizard (5 → 4 steps)
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9614a2f0-7aa6-486c-a8a3-f3a599cbbad5

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 03:05:21 +00:00
copilot-swe-agent[bot] 4675c0cb21 Initial plan 2026-04-05 03:01:55 +00:00
Sovran_Systems 149e35c1c4 Remove Credentials step from onboarding wizard (5 → 4 steps) 2026-04-04 21:58:42 -05:00
Sovran_Systems 1897ffddd9 Merge pull request #56 from naturallaw777/copilot/fix-onboarding-wizard-styling
[WIP] Fix onboarding wizard rendering issues by restoring missing CSS
2026-04-04 21:48:37 -05:00
copilot-swe-agent[bot]andnaturallaw777 87e40a631c Restore missing onboarding wizard CSS styles
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/efd0c45e-80b3-427c-af20-3f8bc07f8647

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 02:47:39 +00:00
copilot-swe-agent[bot] ca78bb4ed4 Initial plan 2026-04-05 02:45:46 +00:00
Sovran_Systems c9ce39b4b2 Merge pull request #55 from naturallaw777/copilot/remove-feature-manager-step
[WIP] Remove Feature Manager step from onboarding wizard
2026-04-04 21:40:58 -05:00
copilot-swe-agent[bot]andnaturallaw777 c7f48b2f4a Remove Feature Manager step from onboarding wizard (6 steps → 5 steps)
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c5126015-7ea2-439c-a541-43ed2a7c2460

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 02:40:29 +00:00
copilot-swe-agent[bot] 9509dd539b Initial plan 2026-04-05 02:36:32 +00:00
Sovran_Systems 7a5e6082eb Merge pull request #54 from naturallaw777/copilot/fix-icon-name-mismatches
Fix icon name mismatches for System Passwords and Element-Call tiles; add haven.svg
2026-04-04 21:31:39 -05:00
copilot-swe-agent[bot]andnaturallaw777 67b533146a Fix icon name mismatches and add haven.svg for Haven Relay tile
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f7fed319-711f-4ced-b732-6d832289bf4d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-05 02:30:55 +00:00
copilot-swe-agent[bot] 589879729d Initial plan 2026-04-05 02:29:21 +00:00
naturallaw777 5d7c5eb4a6 updated missing icons 2026-04-04 21:23:04 -05:00
naturallaw777 ae21a8ca39 updated logos 2026-04-04 21:08:29 -05:00
naturallaw777 369b63097e bigger logo 2026-04-04 18:58:17 -05:00
naturallaw777 25a84b8758 bigger logo 2026-04-04 18:55:16 -05:00
naturallaw777 2f30112c66 bigger logo 2026-04-04 18:48:46 -05:00
naturallaw777 9483f7c27a bigger logo 2026-04-04 18:43:12 -05:00
naturallaw777 33d55c4324 bigger logo 2026-04-04 18:39:14 -05:00
Sovran_Systems bd2299233d Merge pull request #52 from naturallaw777/copilot/refactor-split-css-js-files
[WIP] Refactor and split large CSS and JS files into smaller modules
2026-04-04 18:36:13 -05:00
copilot-swe-agent[bot]andnaturallaw777 815b195600 Split style.css and app.js into modular CSS/JS files under css/ and js/ directories
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/50712b31-5843-45c4-a8f1-3952656b636c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 23:35:27 +00:00
copilot-swe-agent[bot] 2493777a42 Initial plan 2026-04-04 23:19:54 +00:00
naturallaw777 f9a20ac39b bigger logo 2026-04-04 18:09:13 -05:00
Sovran_Systems a02f539729 Merge pull request #51 from naturallaw777/copilot/fix-bitcoin-node-modal-status
Fix Bitcoin node modals showing incorrect enabled state due to shared systemd unit
2026-04-04 17:59:42 -05:00
copilot-swe-agent[bot]andnaturallaw777 6b0da2f7cd Fix: Pass icon to disambiguate Bitcoin node modals sharing bitcoind.service unit
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ca5a66cc-4b7d-4d26-9a65-3d0c9de4a279

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 22:57:12 +00:00
copilot-swe-agent[bot] e40b0bd188 Initial plan 2026-04-04 22:53:05 +00:00
Sovran_Systems f10cb78022 Add missing service detail modal CSS styles (svc-detail, addon, domain, port, support, feature) 2026-04-04 17:27:35 -05:00
Sovran_Systems a875546133 Restore full style.css accidentally truncated in f86df9c; apply logo height fix (64px→36px) 2026-04-04 17:16:11 -05:00
Sovran_Systems 1d871133f7 Merge pull request #50 from naturallaw777/copilot/fix-dashboard-tile-service-status
Fix tile/modal status inconsistency and add BIP110/Bitcoin Core mutual-exclusivity messaging
2026-04-04 13:38:16 -05:00
copilot-swe-agent[bot]andnaturallaw777 1692ba0e9d Fix tile/modal status inconsistency and add BIP110/Bitcoin Core mutual-exclusivity messaging
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7238843b-8bbf-4f02-b932-defb5b6ace35

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 18:37:18 +00:00
copilot-swe-agent[bot] 1fd4e101e6 Initial plan 2026-04-04 18:28:29 +00:00
Sovran_Systems f86df9c173 fix: reduce header logo height from 64px to 36px to fit header bar 2026-04-04 13:12:58 -05:00
Sovran_Systems 89b55ce266 Merge pull request #49 from naturallaw777/copilot/update-logo-size-and-clarify-features
Larger logo, Bitcoin node mutual exclusivity UX, and uniform domain subdomain guidance
2026-04-04 11:55:01 -05:00
copilot-swe-agent[bot]andnaturallaw777 f5bff0b139 Make logo bigger, clarify Bitcoin node mutual exclusivity, and improve domain setup instructions
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/45129e42-f838-47b6-a33d-61c50a2ba927

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 16:48:15 +00:00
copilot-swe-agent[bot] b67e34127a Initial plan 2026-04-04 16:43:32 +00:00
Sovran_Systems 04b175df51 Merge pull request #48 from naturallaw777/copilot/research-caddy-and-zeus-tiles
Fix Caddy domain, Zeus emoji, Feature Manager in tiles, header centering, domain dialog parity
2026-04-04 11:29:44 -05:00
copilot-swe-agent[bot]andnaturallaw777 3a87297b41 Polish: clean up Unicode escapes and fix DDNS label wording
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/faca798f-6820-4db6-adc9-d5a5c9ac1ba1

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 16:28:23 +00:00
copilot-swe-agent[bot]andnaturallaw777 dd9ff2f4b2 Fix 5 issues: Caddy domain, Zeus emoji, Feature Manager in tiles, header centering, domain dialog content
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/faca798f-6820-4db6-adc9-d5a5c9ac1ba1

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 16:25:36 +00:00
Sovran_Systems d7cb97aa73 Merge pull request #47 from naturallaw777/copilot/add-composite-health-status
[WIP] Add composite health status to service detail API
2026-04-04 10:53:34 -05:00
copilot-swe-agent[bot]andnaturallaw777 7361047b48 Add composite health status, smart port language, remove banner, center layout, bigger logo
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/fbd178f9-a25d-4065-b3c1-79eecd3caade

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 15:52:44 +00:00
copilot-swe-agent[bot] cf176ea2db Initial plan 2026-04-04 15:46:48 +00:00
Sovran_Systems 2f467dfee2 Merge pull request #46 from naturallaw777/copilot/redesign-dashboard-service-tiles
[WIP] Redesign dashboard to simplify service tiles and add detail modal
2026-04-04 10:28:56 -05:00
copilot-swe-agent[bot]andnaturallaw777 03dd3eefb5 Redesign dashboard: simplify tiles to icon/name/status, add service detail modal, new /api/service-detail endpoint, SERVICE_DESCRIPTIONS dict, and updated CSS styles
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4f00183a-525f-4c71-91f8-c96c95ca1025

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 15:28:07 +00:00
copilot-swe-agent[bot] 13af3fb071 Initial plan 2026-04-04 15:20:54 +00:00
Sovran_Systems 470e47fefa Merge pull request #45 from naturallaw777/copilot/add-domain-health-status-to-dashboard
Add live domain health badges to hub tiles and Feature Manager
2026-04-04 09:50:50 -05:00
copilot-swe-agent[bot]andnaturallaw777 8002b180b1 Add domain health status to hub tiles and Feature Manager
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/52147672-b757-4524-971a-9e0dab981354

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 14:49:30 +00:00
copilot-swe-agent[bot] a3c75462c9 Initial plan 2026-04-04 14:42:09 +00:00
Sovran_Systems 1998fc0652 Delete .gitignore.txt 2026-04-04 09:31:46 -05:00
Sovran_Systems 6ee3d00802 Update .gitignore 2026-04-04 09:31:19 -05:00
Sovran_Systems cf46424f50 Delete path/to directory 2026-04-04 09:30:27 -05:00
Sovran_Systems f49a542ddf Update service data model to include requiresDomain and domain status fields. 2026-04-04 09:27:06 -05:00
Sovran_Systems b6be88d01f Merge pull request #44 from naturallaw777/copilot/update-tech-support-ssh-login-paths
Simplify tech support protected paths: replace per-app dirs with /home
2026-04-04 08:17:14 -05:00
copilot-swe-agent[bot]andnaturallaw777 2a105edf04 Update tech support protected paths: remove root/.lnd, sparrow, bisq; add /home
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c1303e8b-ff51-4951-b64c-2162d9e9a805

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 13:16:22 +00:00
copilot-swe-agent[bot] 159238f4f8 Initial plan 2026-04-04 13:15:17 +00:00
Sovran_Systems 868cdd9132 Merge pull request #43 from naturallaw777/copilot/feature-tech-support-tile-again
Feature: Tech Support tile with wallet privacy control
2026-04-03 20:40:09 -05:00
copilot-swe-agent[bot]andnaturallaw777 85396e804d Add NixOS tech-support module and security documentation
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7e7a94ca-202b-4eb5-aa3a-a36a1365574b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 01:31:56 +00:00
copilot-swe-agent[bot] 3407612ea9 Initial plan 2026-04-04 01:25:16 +00:00
Sovran_Systems d8b6785659 Merge pull request #42 from naturallaw777/copilot/feature-tech-support-tile
[WIP] Add tech support tile with user wallet privacy control
2026-04-03 20:04:46 -05:00
copilot-swe-agent[bot]andnaturallaw777 dd3a20ed00 feat: wallet privacy control and audit logging for tech support sessions
- Add dedicated `sovran-support` restricted user (non-root) for SSH sessions
- Apply POSIX ACLs via setfacl to block support user from wallet directories
  (LND, Sparrow, Bisq, nix-bitcoin-secrets) by default
- Graceful fallback to root authorized_keys if user creation fails (with UI warning)
- Add time-limited wallet unlock consent: POST /api/support/wallet-unlock
- Add wallet re-lock: POST /api/support/wallet-lock
- Add audit log: GET /api/support/audit-log (append-only, all events logged)
- Expand /api/support/status with wallet_protected, wallet_unlocked,
  wallet_unlocked_until, protected_paths, acl_applied fields
- Update frontend to show wallet protection status box with protected path list
- Show wallet unlock/re-lock controls with duration selector (30min/1h/2h)
- Show audit log viewer in support modal (toggleable)
- Add wallet unlock expiry auto-refresh timer in JS
- Add CSS styles for wallet protection box, unlock/lock buttons, audit log

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/70330ce3-1ed7-46b1-ac66-4cdc50de6017

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 01:02:58 +00:00
copilot-swe-agent[bot] 87529b0d3f Initial plan 2026-04-04 00:52:59 +00:00
Sovran_Systems 8d62ff0b1f Merge pull request #40 from naturallaw777/copilot/add-avahi-service-for-sovran-hub
Use Avahi hostName override for sovransystemsos.local mDNS without changing system hostname
2026-04-03 19:41:36 -05:00
copilot-swe-agent[bot]andnaturallaw777 ed1548ea81 Add Avahi mDNS hostName override and Caddy .local block for sovransystemsos.local LAN access
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/ca3945d7-a2cb-4121-bd89-a5e3fe31fc47

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 00:40:24 +00:00
copilot-swe-agent[bot] beada8f174 Initial plan 2026-04-04 00:39:05 +00:00
naturallaw777 3ec34cb12a bump 2026-04-03 19:31:57 -05:00
Sovran_Systems e8784cdedc Merge pull request #39 from naturallaw777/copilot/revert-hostname-mdns-caddy-changes
Revert hostName/mDNS/Caddy .local changes; restore flake-rebuild compatibility
2026-04-03 19:25:24 -05:00
copilot-swe-agent[bot]andnaturallaw777 0a323d7b3c Revert hostName/mDNS/Caddy .local block changes from PR #34
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/8d17fed2-7329-442e-bfa5-a96a38fb31e4

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-04 00:24:10 +00:00
copilot-swe-agent[bot] 74853431e1 Initial plan 2026-04-04 00:22:41 +00:00
Sovran_Systems df919975af Merge pull request #37 from naturallaw777/copilot/fix-onboarding-port-forwarding-colors
Fix low-contrast onboarding port forwarding info boxes in dark theme
2026-04-03 16:38:54 -05:00
copilot-swe-agent[bot]andnaturallaw777 72a756bfbf Fix dark theme contrast for onboarding port forwarding totals and warning boxes
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a017a6f3-2b07-4fa8-8815-84ae87f403bf

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 21:36:07 +00:00
copilot-swe-agent[bot] f0d22e698b Initial plan 2026-04-03 21:33:12 +00:00
Sovran_Systems 66b4d43fee Merge pull request #35 from naturallaw777/copilot/improve-port-forwarding-ui
Improve port forwarding panel readability: remove scroll cap, increase table font size
2026-04-03 16:12:09 -05:00
Sovran_Systems 5ecee06e58 Merge pull request #34 from naturallaw777/copilot/make-sovran-hub-accessible
feat: LAN discovery via mDNS — serve Hub at http://sovransystemsos.local
2026-04-03 16:11:34 -05:00
copilot-swe-agent[bot]andnaturallaw777 9d5e30ea83 Improve port forwarding panel UI: larger table font, no scroll cap on Step 3
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/7fc0a8b1-1f5b-489c-8e6a-8cf9ed628ccf

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 21:08:58 +00:00
copilot-swe-agent[bot]andnaturallaw777 08452e06cc feat: enable mDNS (Avahi) and local reverse proxy for sovransystemsos.local
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4159c571-2bfb-48fc-a6bc-e0765ef88ef6

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 21:08:21 +00:00
copilot-swe-agent[bot] ab5494f4ad Initial plan 2026-04-03 21:06:42 +00:00
copilot-swe-agent[bot] 2e9bb9e920 Initial plan 2026-04-03 21:06:27 +00:00
Sovran_Systems 9684bc3569 Merge pull request #31 from naturallaw777/copilot/update-onboarding-step-2
[WIP] Update onboarding wizard Step 2 for clarity
2026-04-03 15:54:16 -05:00
copilot-swe-agent[bot]andnaturallaw777 15e6cfb866 Update onboarding Step 2: clarify Njal.la sequence and display external IP
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4e4b917b-6246-4db3-9e2d-536cce11a19a

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 20:54:01 +00:00
copilot-swe-agent[bot] 21fc552f40 Initial plan 2026-04-03 20:51:06 +00:00
Sovran_Systems cfb6c3409f Update onboarding Step 2 description to clarify Njal.la account/domain/Dynamic record flow 2026-04-03 15:44:41 -05:00
Sovran_Systems a1247010ca Update onboarding Step 2 instructions to clarify Njal.la setup order 2026-04-03 15:43:04 -05:00
Sovran_Systems b0d1ca7a80 Merge pull request #30 from naturallaw777/copilot/simplify-port-forwarding-step
[WIP] Simplify port forwarding step to show required ports clearly
2026-04-03 15:32:59 -05:00
copilot-swe-agent[bot]andnaturallaw777 c7974c7aa9 simplify onboarding Step 3 port forwarding to clean static list
- Replace complex per-service/health-check UI with a clear, hardcoded
  table of required ports (80, 443, 22, 8448) and an optional Element
  Calling section (7881 TCP, 7882-7894 UDP, 5349 TCP, 3478 UDP,
  30000-40000 TCP/UDP).
- Add totals line: 4 openings without Element Calling, 9 with.
- Drop /api/ports/health fetch and all dynamic breakdowns (affected
  services loop, closed-port warnings, "View All Required Ports" table).
- Keep internal-IP display box, SSL-cert warning, and "How to set up
  port forwarding" collapsible section.
- Add prominent note that each port only needs to be forwarded once.
- Update Step 3 header description in onboarding.html to match.

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/523e0770-f144-4f47-932b-c0d40782a35b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 20:32:05 +00:00
copilot-swe-agent[bot] 8cf8fcdf82 Initial plan 2026-04-03 20:29:52 +00:00
Sovran_Systems 49c20d8e40 Merge pull request #29 from naturallaw777/copilot/add-sovran-systems-logo
Add Sovran Systems SVG logo to hub header and onboarding welcome page
2026-04-03 15:17:21 -05:00
copilot-swe-agent[bot]andnaturallaw777 777558182d Add Sovran Systems SVG logo to hub header and onboarding welcome page
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/fd3f0f95-4795-4d0b-8d16-fc00bd9d15b6

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 20:16:31 +00:00
copilot-swe-agent[bot] 091f3eb33d Initial plan 2026-04-03 20:13:23 +00:00
Sovran_Systems 41e6eab343 Merge pull request #28 from naturallaw777/copilot/fix-onboarding-wizard-center
Fix onboarding wizard: centering, njal.la domain flow, port forwarding guidance
2026-04-03 14:59:36 -05:00
copilot-swe-agent[bot]andnaturallaw777 125e6bef76 Fix onboarding wizard: centering, njal.la domain instructions, port forwarding guidance
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/a264d893-5e77-4b7b-98d5-23796530fe97

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 19:49:15 +00:00
copilot-swe-agent[bot] 0479b37982 Initial plan 2026-04-03 19:45:32 +00:00
Sovran_Systems 6c8bf8474e Merge pull request #27 from naturallaw777/copilot/add-first-boot-onboarding-wizard
[WIP] Add first-boot onboarding wizard for Sovran Hub
2026-04-03 14:16:20 -05:00
copilot-swe-agent[bot]andnaturallaw777 04d282f790 Add first-boot onboarding wizard (backend + frontend)
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d070c508-d5df-43c7-a0a6-a7be4c65fed7

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 19:13:26 +00:00
copilot-swe-agent[bot] 3488a888de Initial plan 2026-04-03 19:06:27 +00:00
Sovran_Systems 3c3f6bfdb4 Merge pull request #26 from naturallaw777/copilot/make-port-health-banner-subtle
Soften port health status banner: no flash, neutral text, calmer copy
2026-04-03 13:50:23 -05:00
copilot-swe-agent[bot]andnaturallaw777 11a2bc57a7 Make port health status banner more subtle for critical/warning states
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/153a5e43-7267-4f3c-aa97-ce6c80d78f82

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 18:49:49 +00:00
copilot-swe-agent[bot] 91cdda8961 Initial plan 2026-04-03 18:47:00 +00:00
Sovran_Systems d77a6a96f1 Merge pull request #25 from naturallaw777/copilot/add-global-system-status-banner
Add global port health status banner to Sovran_SystemsOS Hub
2026-04-03 13:32:43 -05:00
copilot-swe-agent[bot]andnaturallaw777 0d3e181458 feat: add global system status banner for port health
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/c41a2529-e172-4c84-90c0-1b5477ea4f9d

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 18:25:24 +00:00
copilot-swe-agent[bot] 4201ba2c6c Initial plan 2026-04-03 18:19:41 +00:00
Sovran_Systems fd918ad002 Merge pull request #24 from naturallaw777/copilot/refactor-sidebar-layout
[WIP] Refactor dashboard layout to include sidebar
2026-04-03 13:02:39 -05:00
copilot-swe-agent[bot]andnaturallaw777 02ae34dbd0 fix: collect all support services before rendering sidebar (code review fix)
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4304350a-bc4f-4698-82b5-8ee28f0ad960

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 18:02:06 +00:00
copilot-swe-agent[bot]andnaturallaw777 c6868b63bc refactor: sidebar layout for Tech Support and Feature Manager
- Add <aside class="sidebar"> with #sidebar-support and #sidebar-features to index.html
- Restyle .main-content as flex layout (sidebar left, tiles right)
- Body is now display:flex column with overflow:hidden for independent scroll panels
- Sidebar (270px fixed) with overflow-y:auto scrolls independently
- Tiles area (flex:1) scrolls independently
- New sidebar support button (.sidebar-support-btn) replaces support tile in main grid
- Feature Manager now renders into #sidebar-features instead of $tilesArea
- Compact sidebar overrides for .feature-card padding/font-size
- Remove 'support' and 'feature-manager' from CATEGORY_ORDER
- Responsive: sidebar becomes full-width above tiles at <=768px

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4304350a-bc4f-4698-82b5-8ee28f0ad960

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 18:00:14 +00:00
copilot-swe-agent[bot] 5a4383b6ec Initial plan 2026-04-03 17:53:29 +00:00
Sovran_Systems ac6b568bdc Merge pull request #23 from naturallaw777/copilot/fix-icon-display-issue
[WIP] Fix icon display issue due to mount order conflict
2026-04-03 12:47:46 -05:00
copilot-swe-agent[bot]andnaturallaw777 6400deddbf Fix hub icons: swap mount order and add system/support/zeus SVGs
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e3246ce1-14ce-4dad-98e9-74738a24ae30

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 17:47:12 +00:00
copilot-swe-agent[bot] 3a59974277 Initial plan 2026-04-03 17:45:02 +00:00
Sovran_Systems fd7dfb7eda Merge pull request #22 from naturallaw777/copilot/add-dynamic-port-status-detection
Add local-only dynamic port status detection and clearer port forwarding UX
2026-04-03 12:34:41 -05:00
copilot-swe-agent[bot]andnaturallaw777 7be3f59613 Fix unnecessary escaped single quotes in app.js string literals
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/cd52f6a2-250b-49e3-8558-aa2ae7512d1b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 17:31:36 +00:00
copilot-swe-agent[bot]andnaturallaw777 df5ad3afe2 Add dynamic port status detection and improved port forwarding instructions
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/cd52f6a2-250b-49e3-8558-aa2ae7512d1b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 17:29:02 +00:00
copilot-swe-agent[bot] 0b122d8669 Initial plan 2026-04-03 17:23:13 +00:00
Sovran_Systems b0d7db3102 Merge pull request #21 from naturallaw777/copilot/add-port-requirements-notification
[WIP] Add network port requirements notification for installation
2026-04-03 12:04:00 -05:00
copilot-swe-agent[bot]andnaturallaw777 b2fb7035e0 Add network port requirements UI, install notification, and tile port info
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/54981eb1-b1c5-4e1a-b587-730f41c59e01

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 17:03:42 +00:00
copilot-swe-agent[bot] ede46facf1 Initial plan 2026-04-03 16:51:31 +00:00
naturallaw777 d4f81339ef added awk command 2026-04-03 11:36:03 -05:00
Sovran_Systems 12be806f89 Merge pull request #19 from naturallaw777/copilot/fix-matrix-synapse-create-users
[WIP] Fix matrix-synapse-create-users to always write individual Hub credential files
2026-04-03 11:32:41 -05:00
copilot-swe-agent[bot]andnaturallaw777 0f4f53b9e5 fix: matrix-synapse-create-users always writes individual Hub credential files
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/4259c835-2875-4a48-86c9-1efccbeb6887

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 16:32:25 +00:00
copilot-swe-agent[bot] 13b34ca5b9 Initial plan 2026-04-03 16:28:25 +00:00
Sovran_Systems ed82bd9fe1 Merge pull request #17 from naturallaw777/copilot/fix-matrix-modal-credentials-structure
Fix Matrix-Synapse credentials modal: replace multiline blob with individual credential rows
2026-04-03 11:15:05 -05:00
copilot-swe-agent[bot]andnaturallaw777 b1386ba701 Fix Matrix credentials modal: write individual credential files and update hub config
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f4c4df17-1ef8-4b72-be8a-82472a5f4476

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 16:12:00 +00:00
copilot-swe-agent[bot] 9dd08dc2ae Initial plan 2026-04-03 16:09:01 +00:00
Sovran_Systems 6548773a76 Merge pull request #16 from naturallaw777/copilot/fix-css-theme-bug
[WIP] Fix CSS theme bug in support and feature manager styles
2026-04-03 10:59:35 -05:00
copilot-swe-agent[bot]andnaturallaw777 fc2c7e7928 Fix CSS media query, add Matrix user management UI and API endpoints
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/84f10dbb-7db4-4f3f-b9b4-0f20455ac3e0

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 15:58:33 +00:00
copilot-swe-agent[bot] e90fbccde0 Initial plan 2026-04-03 15:53:39 +00:00
Sovran_Systems 55dec88909 Merge pull request #15 from naturallaw777/copilot/fix-user-existence-check
[WIP] Fix user registration error on existing machines
2026-04-03 10:44:23 -05:00
copilot-swe-agent[bot]andnaturallaw777 570a767636 fix(synapse): tolerate existing users in matrix-synapse-create-users script
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/f76f46da-0836-4295-8e26-c656acc38e3f

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 15:44:00 +00:00
copilot-swe-agent[bot] 90ddd5812e Initial plan 2026-04-03 15:42:53 +00:00
Sovran_Systems 145083cfcc Merge pull request #14 from naturallaw777/copilot/fix-credentials-copy-button
Fix credential copy buttons on non-HTTPS (HTTP) contexts
2026-04-03 10:29:09 -05:00
copilot-swe-agent[bot]andnaturallaw777 8f6d294995 Fix copy buttons failing on non-HTTPS browsers with clipboard fallback
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5f3c4b7f-716c-46ef-9a2a-b97b7c1f9501

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 15:27:40 +00:00
copilot-swe-agent[bot] 1ea3723849 Initial plan 2026-04-03 15:26:58 +00:00
Sovran_Systems 0086a47938 Merge pull request #13 from naturallaw777/copilot/add-rtl-mempool-lan-proxies
[WIP] Add RTL and Mempool LAN reverse proxies to Caddy
2026-04-03 10:21:56 -05:00
copilot-swe-agent[bot]andnaturallaw777 e6cdb3b840 Add RTL and Mempool LAN reverse proxies, open firewall ports
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/d29c1b82-a70e-4092-88c7-b521a1b3cac3

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 15:21:01 +00:00
copilot-swe-agent[bot] dfe45bdbb2 Initial plan 2026-04-03 15:19:46 +00:00
Sovran_Systems e42bea8edf Merge pull request #12 from naturallaw777/copilot/fix-service-tiles-runtime-state
Fix Feature Manager: runtime state not reflected in service tiles, missing feature_manager config key, empty domain false positive
2026-04-03 09:55:39 -05:00
copilot-swe-agent[bot]andnaturallaw777 9cc237fb5b Fix all 4 Feature Manager bugs in server.py
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/77921fb1-4d4b-4d10-b982-b3768b858b86

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 14:52:14 +00:00
copilot-swe-agent[bot] ab60d2b504 Initial plan 2026-04-03 14:50:04 +00:00
Sovran_Systems 478a8b0189 Merge pull request #11 from naturallaw777/copilot/eliminate-hub-overrides
Eliminate hub-overrides.nix: write feature toggles directly into custom.nix
2026-04-03 09:30:43 -05:00
copilot-swe-agent[bot]andnaturallaw777 3c6106d06a Eliminate hub-overrides.nix: write feature toggles into custom.nix instead
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/db82f216-af3e-4d7f-a972-86c03f23e069

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 14:28:24 +00:00
copilot-swe-agent[bot] 8d05f43594 Initial plan 2026-04-03 14:24:14 +00:00
naturallaw777 f5180767b1 updated wiring for hub feature enable 2026-04-03 09:07:07 -05:00
naturallaw777 f3d75b9ba5 updated wiring for hub feature enable 2026-04-03 08:37:21 -05:00
naturallaw777 304df327e3 UX update for feature manager 2026-04-03 07:31:17 -05:00
naturallaw777 801b46b95f deeper fix for RDP regeneration 2026-04-03 07:16:01 -05:00
naturallaw777 bc7a9d96da deeper fix for RDP regeneration 2026-04-03 07:10:40 -05:00
naturallaw777 1f273d9229 fix for RDP regeneration 2026-04-03 07:08:09 -05:00
Sovran_Systems 60638cd1e3 Merge pull request #10 from naturallaw777/copilot/fix-rebuild-modal-race-condition
Fix stale rebuild modal race condition in Features Manager
2026-04-02 20:41:28 -05:00
copilot-swe-agent[bot]andnaturallaw777 c139496af9 fix: clear stale rebuild log before new rebuild and delay first poll
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/eee95839-bfd2-4733-9799-a034178bcdd6

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 01:40:43 +00:00
copilot-swe-agent[bot] e0447c551a Initial plan 2026-04-03 01:38:04 +00:00
Sovran_Systems 81ad77567a Merge pull request #9 from naturallaw777/copilot/overlay-runtime-feature-states
[WIP] Update /api/services endpoint to overlay feature states
2026-04-02 20:29:45 -05:00
copilot-swe-agent[bot]andnaturallaw777 cba66e86df Fix service tiles showing stale enabled state by overlaying runtime hub-overrides
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/e840f6c9-69a3-4ced-b6ef-128a0775321c

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-03 01:29:21 +00:00
copilot-swe-agent[bot] 12eb68abdf Initial plan 2026-04-03 01:26:13 +00:00
naturallaw777 0670f1248a fix for feature manager 2026-04-02 20:15:57 -05:00
naturallaw777 69c01d605f fix for feature manager 2026-04-02 20:03:13 -05:00
naturallaw777 1090aa056b fix for feature manager 2026-04-02 20:00:27 -05:00
naturallaw777 2378a278f2 reverted to old file 2026-04-02 19:49:51 -05:00
Sovran_Systems 280e7a4132 Merge pull request #8 from naturallaw777/copilot/fix-feature-manager-bugs
[WIP] Fix Feature Manager bugs in Sovran Hub
2026-04-02 18:59:55 -05:00
copilot-swe-agent[bot] 6a0a4e0489 Initial plan 2026-04-02 23:50:44 +00:00
naturallaw777 a54beaffad updated Feature Manager 2026-04-02 18:42:00 -05:00
Sovran_Systems 064ede9a75 Merge pull request #7 from naturallaw777/copilot/add-feature-manager-ui
Add Feature Manager to Sovran Hub dashboard
2026-04-02 18:30:03 -05:00
copilot-swe-agent[bot]andnaturallaw777 e43552373c fix: validate domain_name to prevent path injection; fix toggle revert logic
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9088415a-efc3-4dd1-9c22-877a543af47b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-02 23:27:14 +00:00
copilot-swe-agent[bot]andnaturallaw777 b9c8c20347 feat: add Feature Manager to Sovran Hub dashboard
- flake.nix: import /etc/nixos/hub-overrides.nix alongside custom.nix
- sovran-hub.nix: add hub-overrides-init service (seeds file if missing),
  sovran-hub-rebuild service (nixos-rebuild switch only), and
  feature_manager=true in generated config.json
- server.py: add FEATURE_REGISTRY with 6 features (rdp, haven,
  element-calling, mempool, bip110, bitcoin-core); add hub-overrides.nix
  read/write helpers; add /api/features, /api/features/toggle,
  /api/rebuild/status, /api/domains/set, /api/domains/set-email,
  /api/domains/status endpoints; update /api/config to expose feature_manager
- index.html: add domain setup modal, SSL email modal, feature confirm
  modal, and rebuild modal HTML
- app.js: add Feature Manager rendering with sub-category layout,
  feature toggle cards with sliding toggles, domain setup flow,
  SSL email collection, conflict confirmation, rebuild polling
- style.css: add Feature Manager styles (feature cards, toggle switch,
  domain badge, conflict warning, domain input fields)"

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/9088415a-efc3-4dd1-9c22-877a543af47b

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-02 23:24:17 +00:00
copilot-swe-agent[bot] 971b0797df Initial plan 2026-04-02 23:15:35 +00:00
naturallaw777 19c7e01b2a updated public key 2026-04-02 17:39:51 -05:00
naturallaw777 f8c15bdaa4 added public key 2026-04-02 17:25:21 -05:00
naturallaw777 71a25c12ab fixed service layout 2026-04-02 17:20:14 -05:00
naturallaw777 e436b2f7a6 added service feature 2026-04-02 17:08:20 -05:00
naturallaw777 c4307f358c fixed RDP layout 2026-04-02 16:48:01 -05:00
naturallaw777 b63ad15cc1 fixed RDP layout 2026-04-02 16:45:27 -05:00
naturallaw777 51c458c33a added RDP 2026-04-02 16:39:01 -05:00
naturallaw777 8546ff073a removed start stop toggles 2026-04-02 16:35:18 -05:00
naturallaw777 4d23197aa3 added qr code 2026-04-02 16:23:45 -05:00
naturallaw777 2fefbf47e4 added qr code 2026-04-02 16:17:39 -05:00
naturallaw777 d391905e92 added qr code 2026-04-02 16:10:44 -05:00
naturallaw777 13f38f6254 added lndconnet 2026-04-02 16:03:55 -05:00
naturallaw777 195f616ca8 frontend onion links 2026-04-02 15:55:57 -05:00
naturallaw777 78c1d2f2bc frontend link fix 2026-04-02 15:50:31 -05:00
naturallaw777 31539510d5 frontend link fix 2026-04-02 15:46:23 -05:00
naturallaw777 ad0f04c0bf frontend link fix 2026-04-02 15:43:32 -05:00
naturallaw777 b6dfbc4a56 frontend uses weblinks 2026-04-02 15:37:07 -05:00
naturallaw777 ae1f39f0c8 updated BIP110 2026-04-02 15:24:24 -05:00
naturallaw777 987d62ce4d updated security 2026-04-02 15:14:15 -05:00
naturallaw777 bb2c66a4dc added passwd fix for user account 2026-04-02 15:09:04 -05:00
naturallaw777 f1e79d6408 added password tile 2026-04-02 14:55:32 -05:00
naturallaw777 a7d3af4ddd fixed cache 2026-04-02 14:50:07 -05:00
naturallaw777 9f179295d8 fixed app.js 2026-04-02 14:25:04 -05:00
naturallaw777 64c32a7f53 added info dialog for each tile 2026-04-02 14:20:06 -05:00
naturallaw777 d9a5416012 fixed stale info 2026-04-02 14:07:47 -05:00
naturallaw777 9a61994dde fixed reboot menu 2026-04-02 14:01:08 -05:00
naturallaw777 868e6e3315 fixed the color of buttons 2026-04-02 13:54:03 -05:00
naturallaw777 bb7db0693a fixed updater 2026-04-02 13:42:24 -05:00
naturallaw777 a66e8e736f updated logging 2026-04-02 13:34:29 -05:00
naturallaw777 3e1f672c00 updated logging 2026-04-02 13:27:25 -05:00
naturallaw777 eb11231e34 updated logging 2026-04-02 13:21:33 -05:00
naturallaw777 150666d7c3 updated logging 2026-04-02 13:15:19 -05:00
naturallaw777 38733daffc updated logging 2026-04-02 13:09:07 -05:00
naturallaw777 08492cef94 added new systemd update unit 2026-04-02 12:54:32 -05:00
naturallaw777 ad688a1d29 fixed ssh access 2026-04-02 12:37:43 -05:00
naturallaw777 fb940b055d fixed for update button in web 2026-04-02 12:31:04 -05:00
naturallaw777 ea28f9b303 changed port 2026-04-02 12:15:53 -05:00
Sovran_Systems d60a51e033 Merge pull request #6 from naturallaw777/copilot/remove-gtk-app-add-web-app
Replace GTK4/Libadwaita desktop app with FastAPI web app (Sovran_SystemsOS Hub)
2026-04-02 12:04:10 -05:00
copilot-swe-agent[bot]andnaturallaw777 92a9d2cfa1 Add .gitignore to exclude __pycache__, remove pyc files
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5c173acb-776f-4cd2-bc89-bb7675e38677

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-02 17:02:28 +00:00
copilot-swe-agent[bot]andnaturallaw777 592faeecca Remove __pycache__ from tracking, add to .gitignore
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5c173acb-776f-4cd2-bc89-bb7675e38677

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-02 17:02:00 +00:00
copilot-swe-agent[bot]andnaturallaw777 42900608f6 Replace GTK4 desktop app with FastAPI web app (Sovran_SystemsOS Hub)
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/5c173acb-776f-4cd2-bc89-bb7675e38677

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-04-02 17:01:42 +00:00
copilot-swe-agent[bot] af1ad09e2e Initial plan 2026-04-02 16:50:57 +00:00
98 changed files with 4417 additions and 16859 deletions

No files matched your search

-539
View File
@@ -1,539 +0,0 @@
# Changelog
All notable changes to Sovran_SystemsOS will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [1.2.1] - 2026-10-18
### Added
- Mullvad: install the client on the Desktop Role only
- Password-model.nix: fix Nix string escaping in the generate fallback
- Password & keyring cleanup: one password, one truth, no prompts
- Update security details in SECURITY.md
- Enhance README formatting and emphasis on features
- Revise SECURITY.md for clarity on Hub operations
### Changed
- Update flake.lock (nixpkgs, Sovran_Bitcoin 1.1.1, Bisq2 2.1.14) and integration
### Fixed
- Fix README formatting and clarify service exposure
- Fix formatting and clarify server access instructions
[1.2.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.2.1
## [Unreleased]
### Added
- Mullvad: install the VPN app and daemon, as NixOS ships them, on the Desktop Role only
### Changed
- Updated `flake.lock` (`nixpkgs`, `nixpkgs-stable`, `nixvim`, `btc-clients` with Bisq2 `2.1.14`, and `Sovran_Bitcoin` `1.1.1` with RTL `0.15.13` and Nix-native `sovran-nwc` `2.0.0`)
- Updated fallback service version map (`app/sovran_systemsos_web/versions.json`)
- Fixed deprecated `pkgs.system` reference in `flake.nix` and guarded `config.networking.domain` in `modules/sovran-bitcoin-integration.nix` when `haven` and `nwc-wallets` are both enabled
---
## [1.2.0] - 2026-10-02
### Added
- Ssh: don't open port 22 for the loopback-only sshd
- Bitcoin: drop the stray UDP 3051 firewall rule
- Installer: raise generated password entropy from ~23 to ~33 bits
- Caddy: stop filtering the RTL and Mempool sites by client address
- Hub: serve the Hub on its own port instead of through Caddy
- Hub: answer the local network only, whichever way a client arrives
- Hub: make the login lockout that LOGIN_FAIL_MAX described
- Caddy: serve the Hub, RTL and Mempool sites to local clients only
- Docs, hub, installer: say Server + Desktop makes the home IP public
- Ddns: take the public IP from Njal.la only and give it to LiveKit
### Changed
- Updated nixpkgs and Sovran_Bitcoin update and the new Bisq 1.10.9
[1.2.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.2.0
## [1.1.7] - 2026-09-21
### Added
- Postgresql: drop per-database autovacuum ALTERs (rejected by Postgres)
- Synapse: performance tuning for 32 GB Server+Desktop hosts
- Nextcloud, postgresql: fix Nextcloud 35 DB warnings on 32 GB hosts
- Clean up flake.nix by removing comments and LiveKit override
### Changed
- Updated nixpkgs
- Updated to php85 and fixes
- Updated to proper syntax to prevent build errors.
- Updated flake lock which contains Bisq 1.10.8 and Bisq2 2.1.13
[1.1.7]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.7
## [1.1.6] - 2026-09-15
### Added
- Rename 'The Sovran Hub' to 'The Hub' in README
- Rename 'Sovran Hub' to 'The Hub' in README
### Changed
- Updated nix packages includes the new mempool version
### Documentation
- Update Sovran Hub screenshot to the v1.1.5 redesign
[1.1.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.6
## [1.1.5] - 2026-09-09
### Added
- Hub: don't claim the OS "keeps itself current" on the dashboard
- Hub: point RTL credentials at /rtl/ and bump dev version to 0.15.12
- Make dashboard cards uniform and symmetric; stop clipping update text
- Add boot splash; separate Systems Operational and Security icons
- Extend welcome dashboard background to the panel edges
- Add welcome dashboard as default view
- Monochrome updater glyph; fix oversized dialog header icon
- Merge Bitcoin categories into one; rename Self-Hosted Apps to Personal Apps
- Neutral graphite theme; branded updater icon
- Simplify Systems Operational, reword Domain Status, blue restart buttons
- Polish pass: sysops wording, QR size, NWC toolbar, logo, diagnostics placement
- Rework Update System dialog to the approved design; match rebuild dialog
- Redesign The Hub web UI: softer dark theme, sidebar nav, status widgets
- Nixpkgs update
- Switch LibreOffice from fresh to stable version
- Ui: simplify element-calling port modal — trim verbose steps and remove extra verification task
### Changed
- Updated nixpkgs
- Updated Sovran-Bitcoin
- Updated flake to build through the new sovran-bitcoin input
### Fixed
- Fix role fit-and-finish: Desktop-only router note, narrow-viewport layout
- Self-heal truncated/corrupt Nix downloads and keep failed updates retryable
[1.1.5]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.5
## [1.1.4] - 2026-09-02
### Added
- Element-calling: fix Nix string interpolation of LAN_CIDR echo
- Docs/hub: update Element Calling port guidance to the new port set
- Element-calling: fix Wi-Fi calls and tighten media/TURN ports
- Flake: pin LiveKit to 1.13.6 for rtc.advertise_internal_ip support
### Changed
- Updated nixpkgs
- Updated sovran-bitcon
- Dedupe NWC tooling — use Sovran_Bitcoin's sovran-nwc
- Update sovran-bitcoin flake input
- Extract bitcoin stack into Sovran_Bitcoin flake input
### Fixed
- Set lnurl domainFile for Hub-managed Lightning Address domain
- Correct disablewallet casing
- Fixed typo
[1.1.4]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.4
## [Unreleased]
### Changed
- Element Calling: pin LiveKit to 1.13.6 and advertise the LAN interface
(rtc.advertise_internal_ip) so Wi-Fi callers get a local ICE candidate and
calls no longer depend on router NAT hairpin
- Element Calling: use a single UDP media mux (7882); move the TURN relay to
40000-40099 and remove the unreachable 5349 TURN/TLS listener
- Hub: update Element Calling router-port guidance to the new port set
### Fixed
- Element Calling: TURN relay could not deliver media to the SFU on routers
without NAT loopback (turn.allow_restricted_peer_cidrs now permits the LAN
subnet derived from the primary interface)
## [1.1.3] - 2026-08-27
### Added
- Sparrow update
- Nixpkgs update with bisq and bisq2
- Albyhub: re-harvest hashes for fixed sovran tag
- Albyhub default.nix hash update
- Albyhub default.nix fix
- Albyhub: build from sovran fork tag (LND-only, no frontend)
- Vendor v1.24.0 LND-only, no-frontend build
- Ux: simplify domain and router setup guidance
### Fixed
- Evaluate local vendored package versions directly
- Use proxyVendor to preserve secp256k1-zkp cgo headers
[1.1.3]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.3
## [1.1.2] - 2026-08-21
### Added
- Nixpkgs update with Bisq update
- Unify public-IP detection into one privacy-first script
- Fix Element X discovery and harden federated calling
- Nixpkgs update
- Add tor-browser to the package list
### Changed
- Use pkgs.lndinit, drop local packages/lndinit
- Update RTL to 0.15.10
### Fixed
- Move system.activationScripts under the config attribute
- Reuse Hub external IP for LiveKit, drop egress service calls
- Derive Restart required from boot default vs running system
- Reconcile completed updates after polling stalls
- Migrate i2pd SAM settings for nixpkgs 26.11
- Order turn-setup after network-online to fix boot-time red dot
- Restore a Zeus-scannable LND REST connect QR
### Security
- Prevent LND admin macaroon exposure in curl argv
### Documentation
- Update desktop and Sovran Hub screenshots
[1.1.2]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.2
## [1.1.1] - 2026-08-15
### Added
- Update release-stable.sh
- Enhance release script with preflight checks and metadata
- Migrate dock/folder/mime entries from brave to brave-origin on upgrade
- Skip Brave Origin startup dialog in Hub launcher
- Switch default browser to Brave Origin (brave-origin)
- Add Bitcoin Core Tor IBD gossip control
- Replace Bitcoin Knots with Bitcoin Core
### Fixed
- Persistent browser profile so logout survives window reopen
- Recover from expired sessions and preserve logout
- Prevent Bitcoin Core switch from hanging the Hub UI
### Documentation
- Update README ISO download links to v1.1.0
[1.1.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.1
## [Unreleased]
### Changed
- Replaced Brave Browser with Brave Origin (the new `brave-origin` nixpkgs
package) as the desktop's default browser: installed package, dock
favorite, Browsers app-folder entry, `xdg.mime.defaultApplications`, and
the `$BROWSER` session variable now all point at Brave Origin, and the
Sovran Hub launcher runs it.
- On installs upgrading from the old Brave package, the desktop theme init
now migrates `brave-browser.desktop` → `brave-origin.desktop` in the dock
favorites, the Browsers app folder, and the user's `mimeapps.list`, so
the dock icon and default-browser handler survive the switch without a
manual re-pin.
### Fixed
- Zeus Connect QR in the Hub was invalid: the LND-only `lndconnect`
wrapper used flags Zeus/`lndconnect` do not understand (`--cert`,
`--macaroon`), read a non-existent onion path (`free/lnd.onion`),
and collided with LND's P2P hidden service. The wrapper now matches
the nix-bitcoin contract — dedicated `lnd-rest` onion on port 8080,
`--nocert` over Tor, admin macaroon in the URI — so scanning the Hub
QR in Zeus works again.
- The Hub launcher now uses a persistent per-user browser profile
(`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp`
profile that was deleted on exit. The throwaway profile wiped the
`hub_manual_logout` marker cookie on every close, so `/auto-login`
silently logged the user straight back in after they signed out and
reopened the Hub window. The persistent profile makes explicit logout
stick (until the next password login) while still skipping Brave Origin's
one-time startup dialog.
- The Hub now redirects to the login page when an API request finds an
expired browser session, instead of leaving the dashboard tile grid in a
loading state while `/api/services` continues returning 401 every five
seconds.
- Explicitly logging out now suppresses the desktop launcher's local
auto-login after the Hub window is closed and reopened. A successful
password login clears that preference.
- Sessions are now persisted to `/var/lib/secrets/hub-sessions.json` so the
browser login survives the Hub service restart that `nixos-rebuild switch`
performs during activation. Previously the in-memory session store was
wiped by that restart, the `/api/rebuild/status` poll started returning
401, and the rebuild modal spun forever showing "Applying changes…" while
the switch result was never displayed.
- Rebuild and update modals now bail out and reload the page after sustained
polling failures instead of hanging indefinitely.
- Rebuild/update scripts stream `nixos-rebuild` output into the live log
(it was buffered until completion, making long rebuilds look frozen).
- The update modal now reconciles persisted update state after a page/RDP
reconnect, bounds every status request with a timeout, prevents overlapping
async polls, and reports an explicit "status unavailable" state with a
Retry Status action instead of spinning forever. Verbose Nix logs are
rendered incrementally and bounded so they cannot stall the browser UI.
- Successful staged updates now record the exact NixOS generation. The Hub
keeps showing "Restart required" until that generation is active, then
clears the marker after reboot (with log-based recovery for older updates).
- Pending-reboot state is now derived from NixOS itself — the boot default
(`/nix/var/nix/profiles/system`) versus the running `/run/current-system`
— instead of from Hub-written marker files. Updates performed from a
terminal or support session (which never touch the Hub's status files)
previously left a stale `REBOOT_REQUIRED` marker the Hub could never
clear, pinning the "Restart required" badge forever even after many
reboots. The marker self-heals to `IDLE` on the next status read whenever
boot default and running system agree, and recording the informational
`.generation` marker can no longer fail an otherwise successful update.
---
## [1.1.0] - 2026-08-11
### Added
- Speed up Hub service status loading
- Update Documentation
- Njalla.nix: fail on ImportError; fix redundant except clause
- Correctly escape DDNS placeholder in Nix string
- Security hardening: fix all 8 blocking findings for PR #419
- Security hardening: fix DDNS injection, Nix injection, reboot auth, support key, sudo rules
- Harden btcpay and nbxplorer config handling
- Add provenance headers and fix flake checks for PR409
- Restore upstream nix-bitcoin fetchNodeModules for mempool and RTL packages
- Refactor Nix build configuration for mempool
- Update npm dependencies and patch handling in default.nix
- Update npmDepsHash for backend and frontend packages
- Mempool: use postPatch to copy lock file for npmDeps fetcher
- Mempool: use preBuild cd instead of postUnpack so npmDeps fetcher finds lock files
- Mempool: fix npmDeps fetcher — postUnpack instead of sourceRoot, postPatch instead of patches
- Mempool: switch backend and frontend to buildNpmPackage with placeholder npmDepsHash
- Update npmDepsHash with correct hash value
- RTL: switch to buildNpmPackage with placeholder npmDepsHash
- Add bitcoind.rpc.users.btcpayserver for NBXplorer
- Restore original flake.nix with nixosModules.Sovran_SystemsOS export
- Add vendored RTL package and fix rtl.nix to use it
- Vendor mempool packages and wire mempool module to vendored pkgs
- Refactor onion service configuration for LND
- Add missing bitcoind-rpc-public-whitelist.nix
- Remove joinmarket-ob-watcher from onion-services defaults
- Tailor: make bitcoin modules truly Sovran-only (lnd-only) and delete stubs.nix
- Vendor: replace nix-bitcoin flake input with minimal vendored modules (nixpkgs-only)
- Remove duplicate 1.0.6 release notes from CHANGELOG
### Changed
- Clean mempool module comment typo
- Move vendor/nix-bitcoin to modules/bitcoin, remove overlays
- Nix flake update - drop nix-bitcoin
### Fixed
- Correct RTL and Mempool Hub versions
- Fix DDNS URL validation: replace ${IP} temporarily for validator, keep placeholder for storage
- Fix all 8 security hardening blockers for PR #423
- Fix Nix interpolation in DDNS runner
- Fix IP validation in DDNS and document journalctl sudo rule
- Report configured BTCPay Server version
- Fix Matrix SIGPIPE and RTL v0.15.8 config schema regressions
- Add NBXplorer cookie auth and WorkingDirectory for BTCPay service
- Fix BTCPay startup regression: add home dirs to service users
- Address btcpay hardening review feedback
- Register bitcoin-HMAC-btcpayserver as managed secret owned by bitcoind user
- Fix RTL: use fetchNodeModules instead of npm ci in buildPhase
- Fix PostgreSQL ensureUsers: use ensureDBOwnership instead of ensureClauses
- Fix lnd macaroons: replace invalid 'enable' with 'user'
- Fix postgresql ensurePermissions -> ensureClauses for nixpkgs unstable
- Fix btcpayserver.nix to use pkgs.stable overlay
- Fix btcpayserver syntax error and pin to nixpkgs-stable (2.4.2)
- Fix mempool package: remove fetchNodeModules dependency
- Fix typo in onion-addresses service check
- Fix formatting of extraGroups in btcpayserver.nix
- Add missing semicolon after extraGroups in btcpayserver.nix
- Remove services.clightning.enable assignment that fails on f13ff45
- Remove clightning and clightning-rest from stubs to avoid duplicate on f13ff45
- Add stubs for nixpkgs-unstable 2026-08 where services.clightning removed
- Keep ISO artifacts out of the repo and auto-update README on release
[1.1.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.0
## [1.0.6] - 2026-08-07
### Added
- Iso: fix replaceStrings length mismatch in cleanVersion
- Promote virtual machine trial option in README
- Improve installer VM compatibility
- Trim redundant dev-vs-stable Gitea explanations in README
- Add Arean.Ai to AI tools used for development
- Prefix OS version badge with 'v' (v1.0.5)
- Match OS version badge styling to service modal version badges
- Place OS version badge inline right after the Hub title
- Center OS version badge under the Hub title
- Strengthen entropy section: add DYOR emphasis and hardware wallet verification note
- Add 'About Bitcoin wallet entropy' section to README
- README: balance intro between Bitcoin sovereignty and sovereign computing
### Changed
- Removed temp patch file
### Fixed
- Resolve tag range detection and improve release diagnostics
- Add token scope diagnostics and un-silence gh release errors
- Harden error handling and sanitize exception details across security endpoints (CWE-209)
- Sanitize exception handling in verify-integrity and security-reset (CWE-209)
- Sanitize api_security_reset errors to prevent exception information exposure (CWE-209)
- Use canonical prefix containment check for CodeQL path-injection
- Pass sanitized abs_path to os.chown to resolve CodeQL path injection at 4338
- Add CodeQL-recognized path sanitization for domain_name
- Remove domain substring check for CodeQL incomplete-url
- Use sentinel for njalla header check (CodeQL incomplete-url-substring)
- Prevent reflected XSS in lnurl-qr print endpoint
- Hash root password instead of storing in clear text (CWE-312)
- Separate web auth hash from system password file
- Fix Matrix Hub admin API credentials
- Fix CWE-78: replace subprocess call with Synapse Admin API in create-user endpoint
### Documentation
- Add SECURITY.md detailing security policy and best practices
- Versioned CDN downloads + add CDN upload script
- Correct active development workflow
- Make repo references mirror-neutral for Gitea readers
- Clarify GitHub is the dev mirror of the Gitea stable repo
[1.0.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.6
## [1.0.5] - 2026-08-04
### Added
- LND Update to v0.21.1-beta
- Nixpkgs Update
- Nixpkgs Update
- Update LND REST Zeus Connect instructions and design to match NWC exactly
- UI: Align LND REST and NWC Zeus connection instructions styling
- Make Zeus LND REST instructions coherent with NWC Zeus connect guide
- Clarify Zeus NWC wallet setup
- Make Manual Backup match the system role (Desktop Only scope)
- Reload Caddy immediately when a domain (or ACME email) is saved
- Unify Njal.la + router port-forwarding guidance across onboarding and feature enable modals
- Enhance Lightning Wallet Connect modal with professional benefits grid and refined messaging
### Fixed
- Resolve 'vdev' version badge and align it under the Hub title
- Align release script with Gitea staging-dev and stable branch workflow
- Auto-detect git remotes and clean up temp files in release-stable.sh
### Documentation
- Write real v1.0.4 changelog and auto-generate release notes in release script
[1.0.5]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.5
## [Unreleased]
### Fixed
- Report the version of the configured BTCPay Server package in the Hub instead of the different package version from unstable nixpkgs.
- Installer VM compatibility: legacy BIOS VM boots now install with GRUB, UEFI VM installs avoid depending on NVRAM boot-entry writes, VM users get clearer disk/resource guidance, and the internet check falls back to HTTPS when ICMP is blocked.
- **Manual Backup now matches the system role**: on the Desktop Only role the Hub's
"What gets backed up" list no longer shows Node / Server + Desktop items
(nix-bitcoin secrets, `/var/lib` system service data, and the database/blockchain
caveats), and the backup script skips those stages entirely — Desktop Only backups
now mirror only the NixOS configuration (`/etc/nixos`) and home directory (`/home`).
The free-space estimate, stage numbering, backup manifest, and completion message
are all role-aware; Node and Server + Desktop backups are unchanged.
---
## [1.0.4] - 2026-07-29
### Added
- **Lightning Wallet Connections (NWC)** — Hub-managed Nostr Wallet Connect powered by Alby Hub + LND:
- Create, view, and delete wallet connections directly from the Sovran Hub with a tabbed modal UI
- Downloadable/printable LNURL QR codes for connecting external wallets
- Channel liquidity guide and onboarding guidance for new LND nodes
- Unique-hostname enforcement with conflict validation and UI guidance
- Official NWC branding and logo
- **Version visibility across the Hub**:
- Sovran_SystemsOS version badge displayed under the Hub title
- Version numbers shown on all Hub service tiles and next to service modal titles
- Deployed PHP app versions surfaced in service titles
- Build-time version reference file (`VERSION`) so version lookups are instantaneous and consistent
- **Automated stable release workflow** (`scripts/release-stable.sh`) with versioned ISO naming from the `VERSION` file
- `CONTRIBUTING.md` and expanded project documentation
### Changed
- **Manual Backup overhauled**: replaced tar+DB+LND archive approach with a reliable ext4 + rsync workflow, including
mount checks, path safety, atomic completion markers, stale-marker cleanup, and behavioral test coverage
- **Port-forwarding UX simplified**: removed onboarding Step 4 and the misleading local "ready" status;
Njal.la DDNS now runs automatically when the feature is enabled
- README restructured for clarity, links, and accuracy; added router/ISP port-forwarding requirements
for Server + Desktop mode; acknowledged LiveKit and Alby Hub
- Updated nixpkgs and Bitcoin clients
- Repository cleanup: removed unused `.github`, `.tests`, `nix/`, and `docs/ai` directories
### Fixed
- NWC wallet certificate path wiring (now uses the nix-bitcoin LND cert path)
- Deterministic LND/Alby Hub port collision
- Alby Hub executable resolution and v1.23.0 patches regenerated against exact upstream source
- Manual Backup exit-code failures (bash/gawk in service PATH, tar tolerance, flock, browser-cache exclusions)
- rsync destination-directory failures in backups (auto `mkdir -p`, mount check, 19 behavioral tests)
- `sovran-hosts-update` converted to `writeShellApplication` with explicit runtime inputs
- Incorrect `lib.mkIf` usage in the NWC wallets module
- Duplicate systemd LND strings
- `git fetch` tag-clobber errors against Gitea (now uses `--force`)
### Security
- Hardened Lightning Wallet Connections (NWC): restricted `ReadOnlyPaths` for the unlock password,
fixed an Authorization-header bug, eliminated stack-trace exposure flagged by CodeQL,
and tightened credential access and amount validation
[1.0.4]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.4
## [1.0.3] - 2026-07-29
### Added
- Incremental stable updates
## [1.0.2] - 2026-07-29
### Added
- Incremental stable updates
## [1.0.1] - 2026-07-29
### Added
- Incremental stable updates
## [1.0.0] - 2026-07-29
### Added
- Initial stable release of Sovran_SystemsOS
- Full Bitcoin self-custody desktop environment
- Integrated Sparrow Wallet, Bisq, and Bisq 2
- Comprehensive Sovran Hub for service management
- NixOS-based operating system with privacy and sovereignty focus
- Support for Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and more
- Server + Desktop hybrid mode with Matrix, Nextcloud, VaultWarden, and other self-hosted services
- Automated installer with graphical GNOME desktop
- Tor integration and onion services for all major components
- Remote desktop (GNOME Remote Desktop) support
- Role-based system configuration (desktop, server, server+desktop)
- Detailed service credential management in the Sovran Hub
### Changed
- Moved from development/main branch to dedicated `stable` branch for production releases
### Security
- All services run with least-privilege principles where possible
- Secrets stored in `/var/lib/secrets/`
- Strong emphasis on user-controlled keys and self-sovereignty
---
## [Unreleased]
### Added
- (Nothing yet)
[1.0.3]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.3
[1.0.2]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.2
[1.0.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.1
[1.0.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.0
-112
View File
@@ -1,112 +0,0 @@
# Contributing to Sovran Systems OS
First off, thank you for considering contributing to Sovran_SystemsOS! 🎉
## Development and Release Repositories
The GitHub repository is the primary location for Sovran_SystemsOS development
and collaboration and serves as the project's connection to the GitHub
ecosystem. Most work is contributed through feature branches and pull requests
that target GitHub `main`. Development may also begin in a local branch or on
`staging-dev` at the project's self-hosted Gitea instance:
https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS.
GitHub `main` and Gitea `staging-dev` are kept synchronized as the active
development line, although one may briefly lead the other while changes are
being integrated. The canonical, release-ready code is maintained on Gitea's
[`stable` branch](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable),
in keeping with the self-sovereignty ethos of Sovran_SystemsOS.
The workflow is:
1. Most development and project collaboration — including issues, pull
requests, and reviews — happens through GitHub, with completed work
integrated into `main`.
2. Work may also originate locally or on Gitea `staging-dev`. Accepted changes
are synchronized between GitHub `main` and Gitea `staging-dev`.
3. When changes are complete and tested, they are promoted to Gitea `stable`,
which is the code used by released Sovran_SystemsOS builds.
Please note: GitHub `main` and Gitea `staging-dev` may contain new features and
code not yet in `stable`, and that code may not be fully tested.
Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used Copilot and Arena.ai to work through significant coding challenges and troubleshooting hurdles. We will continue to use AI to help keep Sovran_SystemsOS stable and maintained.
## How Can I Contribute?
### 🐛 Reporting Bugs
- Open an [Issue](https://github.com/naturallaw777/Sovran_SystemsOS/issues)
- Use a clear and descriptive title
- Describe the steps to reproduce the bug
- Include your OS version, hardware specs, and any relevant logs
### 💡 Suggesting Features
- Open an [Issue](https://github.com/naturallaw777/Sovran_SystemsOS/issues) with the tag `enhancement`
- Explain the feature and why it would be useful
- Be as detailed as possible
### 🔧 Submitting Code Changes
#### 1. Fork the Repository
Click the "Fork" button at the top right of the GitHub repository.
#### 2. Clone Your Fork
```bash
git clone https://github.com/YOUR_USERNAME/Sovran_SystemsOS.git
cd Sovran_SystemsOS
```
#### 3. Create a Feature Branch
```bash
git checkout -b feature/your-feature-name
```
#### 4. Make Your Changes
- Write clean, well-commented code
- Follow the existing code style and structure
- Test your changes thoroughly
#### 5. Commit Your Changes
```bash
git add .
git commit -m "Brief description of your changes"
```
#### 6. Push to Your Fork
```bash
git push origin feature/your-feature-name
```
#### 7. Open a Pull Request
- Go to the original repository on GitHub
- Click "New Pull Request"
- Select your fork and branch
- Provide a clear title and description of your changes
- Reference any related Issues (e.g., "Closes #12")
## Pull Request Guidelines
- **One PR per feature/fix.** Do not bundle unrelated changes.
- **Keep PRs small and focused.** Smaller PRs are easier to review and merge.
- **Write meaningful commit messages.**
- **Do not push directly to `main`.** Always use a feature branch and open a PR.
- **Be patient.** PRs will be reviewed as soon as possible.
## Security-Sensitive Changes
- Report vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md).
- Pin upstream revisions and hashes; do not use floating source references.
- Update [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) when importing code.
- Add regression tests and describe any threat-model change in the pull request.
## Code Style
- Follow the existing patterns in the codebase
- Comment your code where necessary
- Keep functions small and focused
## Code of Conduct
By participating in this project, you agree to be respectful and constructive.
We are building something meaningful — let's do it together with integrity.
+145 -875
View File
File diff suppressed because it is too large. Load diff
-149
View File
@@ -1,149 +0,0 @@
# Security Policy
## Supported versions
| Release | Supported |
|---|:---:|
| Latest stable release | Yes |
| `main` / `staging-dev` | Development only |
| Older than `1.0.0` | No |
Install the newest stable point release to receive security fixes.
## Report a vulnerability
**Do not open a public issue or pull request.** Report privately through:
- [GitHub Private Vulnerability Reporting](https://github.com/naturallaw777/Sovran_SystemsOS/security/advisories/new)
- Email: [support@sovransystems.com](mailto:support@sovransystems.com)
Include the affected version, impact, reproduction steps, and a minimal proof of
concept. Never send wallet recovery words, private keys, or live credentials.
We aim to acknowledge reports within two business days. Please allow reasonable
time for a fix and coordinated disclosure.
## Security model
### Local-first operation
The Hub and core data run on operator-owned hardware. The Hub is for a trusted
local network and must not be port-forwarded to the internet. It uses HTTP, so
authentication does not encrypt local network traffic.
The Hub is served on port 8937 and is not fronted by Caddy. Server + Desktop and
Bitcoin Node Only open that port in the firewall, so local devices reach the Hub
at `http://sovransystemsos.local:8937`. On Desktop Only the Hub is not published
at all: reachable only from the machine itself, on localhost, with no TCP port
open in the firewall (UDP 5353 for mDNS only).
`sovran_systemsOS.hub.directPort = true` in `custom.nix` opens port 8937 if you
want to reach a Desktop Only Hub from another device.
The Hub checks every client before showing a login page. It runs as root, so it
answers only loopback, private, VPN and link-local addresses and turns everyone
else away. The check goes by the address a connection comes from, so it is a
second lock and not a reason to forward port 8937: don't. Addresses outside the
local ranges go in `sovran_systemsOS.hub.extraLanNetworks` in `custom.nix`;
`sovran_systemsOS.hub.lanOnly = false` turns the check off.
### Public services and your home IP address
Publishing public services on Server + Desktop points a DDNS record at your
home's public IP address, which anyone can look up, and lists your service
hostnames in Certificate Transparency logs. Desktop publishes nothing. Node
publishes nothing unless *Put BTCPay Server Online* or *Lightning Wallet
Connections* is on. See
[Server + Desktop and your home IP address](README.md#server--desktop-and-your-home-ip-address)
for what this means and the alternatives.
### Bitcoin stack
Bitcoin and Lightning modules live in the standalone
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository,
consumed as a flake input and bridged by
`modules/sovran-bitcoin-integration.nix`. The `nix-bitcoin.*` namespace and
`/etc/nix-bitcoin-secrets` path remain only for upgrade compatibility.
### Supply chain and integrity
`flake.lock` pins flake inputs, and fetched source archives use fixed hashes.
Builds still depend on pinned Nixpkgs and other inputs, upstream source
archives, and any configured binary cache.
The Hub integrity check verifies Nix store contents against a build from local
`/etc/nixos`. It does not authenticate the release publisher or protect against
an attacker who already controls root.
### Access and service isolation
- Firewall enabled by default
- SSH Remote Access and remote desktop disabled by default
- Separate service users and systemd sandboxing where supported
- Administrative service ports bound to loopback where practical
- Tor enforced for supported Bitcoin traffic and onion services
- Public web services exposed only when enabled by the operator (this makes
your home IP address public)
SSH Remote Access is the only supported administrative port that faces the
network, and it is off by default. Enabling it listens on all interfaces and
opens TCP port 22; the Hub then asks you to forward port 22, which puts SSH on
the public internet. Key authentication only. Turn it off when you are done.
Tor reduces network exposure for configured Bitcoin services. It is not a
guarantee against every IP leak, application bug, or traffic-analysis attack.
### Mullvad VPN (Desktop Only)
The Mullvad VPN client — daemon and app — is installed on the Desktop Role and
on no other role: Server + Desktop and Bitcoin Node keep the one network
configuration their services need. `modules/mullvad.nix` is a role gate around
the stock NixOS service, with nothing configured by this system on top of it.
Two behaviours are the app's own, and worth knowing before you connect:
- While the tunnel is up, Mullvad's kill switch blocks everything outside it,
the local network included, so Remote Desktop and other local devices stop
answering until you switch local network sharing on in the app.
- Name resolution follows the tunnel for as long as the client is enabled: the
NixOS service turns on systemd-resolved and points NetworkManager at it.
Neither is set or restricted here; both are visible and changeable in the app.
The firewall rules, the Hub's exposure checks, the Bitcoin stack and Tor are
untouched by this module.
### Restricted support access
Tech Support runs over SSH, and the Hub refuses to start a session until SSH
Remote Access is enabled (see above). Support uses a per-session SSH key on the
non-root `sovran-support` account.
Sessions expire after 24 hours and have a small allowlist of `sudo` commands.
Wallet paths receive deny ACLs unless the operator explicitly removes them.
Disabling support removes the key and reapplies the ACLs but leaves SSH
enabled.
Support events are written to `/var/log/sovran-support-audit.log`. This is a
local audit log, not a cryptographically tamper-evident record.
## Out of scope
Sovran_SystemsOS cannot protect against:
- Compromised root or administrator credentials
- Stolen recovery words, private keys, or backups
- Malicious or compromised hardware, firmware, or build infrastructure
- Services the operator deliberately exposes or weakens
- Physical access without appropriate disk and firmware protections
## Operator basics
- Verify downloads and stop if the checksum does not match.
- Apply stable security updates promptly.
- Use unique passwords and keep SSH/RDP off when not needed.
- Prefer a well-reviewed hardware signer for meaningful Bitcoin balances.
- Keep tested, offline backups in separate secure locations.
- Never share recovery words or private keys with support.
- Disable support access when the session ends and review the audit log.
No software can provide absolute security. Review your configuration and threat
model before storing important funds or data.
-36
View File
@@ -1,36 +0,0 @@
# Third-Party Notices
## nix-bitcoin
The Bitcoin and Lightning modules were originally adapted from
[fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
They now live in the standalone
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
and are consumed by Sovran_SystemsOS as a flake input. The current Sovran build
does not import or fetch nix-bitcoin.
MIT License
Copyright (c) 2019 nix-bitcoin developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Other packaged applications retain their own upstream licenses and copyright
notices.
-1
View File
@@ -1 +0,0 @@
1.2.1
+244
View File
@@ -0,0 +1,244 @@
<svg width="256" height="256" version="1.1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<image width="256" height="256" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAABmJLR0QA/wD/AP+gvaeTAAAgAElE
QVR4nO2dd3gU1frHv+9sTy9AKh2RIqh0kHJFpVoQO1IURa6CCFak6CrFdr1KU1HUa8NKEREQRQUR
ASlSlR5KEkpIQtr2Ob8/Aj9Isrszuzu7s7s5n+fJo+zOnHmTmfOdU94CcDgcDofD4XA4HA6Hw+Fw
OJyohtQ2gBM4zAytNTWtPkiozxjqChDqMiCVIKYyJtQBiakA6QGYiGAEAMaQAJDmfAsuIpSc/9wK
wALABrCzBJxlEM4SEwtE4AwJKICDHTOeO3mCzHCq9CtzFIILQITAvoLGdjqrqSiiLQmsFYDGYKwR
iBqBIRuANsQmOUE4AcZyQJQDRocZw98Cox2GohOHyAwxxPZw/IALQBjCzK31lpTidgKJnRmoDYAr
AbQCEKOyaXKpAKM9ANtBwC4RtNlkSN9Ko7c61DaMUxUuAGEAm52SYIOxEyN0B3DN+R+TymYpjYMI
O0XgdzBa79SzXxJG5xWobVRthwuACrCvoKnIz7iKNHQ9MdwEoCsAQW27VGAvMXwH4CeDIWMtHyGE
Hi4AIaL4rQbJBtF1M0Q2gAg3MCBZbZvCjEIAq0FshdVh+C55Qk6x2gbVBrgABBE2PzPGZsN1IAxj
wC0A9GrbFCHYGPAjMfraCMtSGldYorZB0QoXAIVh5tZ6S2rxLQLYEAb0Ayq33Th+YyXQChHiQpM+
cxmfJigLFwCFsM7Nag6RjWSE+wHUU9ueaISAIgb6WhCdcwyPndqttj3RABeAAGCzmxmsKL8dJIwC
WE/wv2eoYABbC9B7xrPJ35B5j11tgyIV/sD6AZudkmAjw/0M9CSAbLXtqeWcAvCO3SHMTnz8RKHa
xkQaXAB8wDInrTEjzXhieABArNr2cKpQBtBCIrxuHJu7X21jIgUuADKwzclsIQLPA7gDgEZtezhe
cYHwBYFe5EIgDRcAL1jeSG8EreZZgI1E6H3tOYEhErAIgjDJOObEQbWNCVe4ALjBMjejIUSaAsII
ADq17eEEhAOgD5nApseMyTuutjHhBheAS2CvpcVajZqnADwDvn8fbdgZ4R2TaJ3KHYsuwgUAAGMg
65ysYSD2MoAMte3hBJU8IvaCoSB/AQ9Z5gKAijmZ1xDwJoAOatvCCSVsM2PCYzHjcjeqbYma1FoB
YPObJFoc1heJYSxqZyQeB2Bg9KndSeNrqw9BrRQA69ysGxljb4M78XAqyWdgj8Y8mr9IbUNCTa0S
gLJ5ddM1Lv2rIDZMbVs44QcDlkMr/Dvm4RO5atsSKmqNAFTMzbpTYOwdHofPkaCQMRodMy73G7UN
CQVRLwDslTrx1hjDfwD2kNq2cCIIRp8YNfZHaMyZMrVNCSZRLQAVs7M7EYmfAWimti2cCISQwxiG
xjya97vapgSLqBQAZoZgTc2aDLDnwF14OYHhALHnjWPyXyYCU9sYpYk6AagM1TV+xIBBatvCiSq+
t2m0w5IeOVaktiFKElUCYHsru63oEhcDaKq2LZyo5KAAYbDh0RO71DZEKaLGAaZiduY9okvcAN75
OcGjmQhxk2Vu1gi1DVGKiB8BMDMEW2rmSwx4Wm1bOLUJesk4NndypK8LRLQAsNnNDFaq+BDAPWrb
Eu6cLQdmrBFRZgNeGSggOVKKjIUxBFpkcNAwevyERW1b/CViBaBkblaqjrElAHqobUu4s3Q3g/kH
EcXnH9N6cYTXbyb0aBKxtz+c2Ohk4s3x406eUdsQf4jIJ8A6u35TRq4VAJqrbUs4Y3EAz60S8fWO
mqNUIuCBzgIm9iZoo2YlSDUOkQsDjePz9qltiK9EnACUz81sJ4j4AYQ6atsSzhwrAh76WsQ/p71P
Ua+7jDB3sAATz3sUKKddAvWNG5P7l9qG+EJECUD5vPSOgiisApCiti3hzM48YOSXLhSUyzu+bSbw
v7s1SOHrAgHBgGIQDYgZm/uH2rbIJWIEwDI3oydAy8EQr7Yt4czvRxhGfSWiwscCWk1Tgc+GapDO
/7qBUk4Mg4zj8n5S2xA5RMTszzonvT8YreKd3zt/5TI89LXvnR8ADp0Fhi904Zw1one1woFYRlhu
m5t5i9qGyCHsBcA6J70/g7AUgEltW8KZAwXA8M9FlAdQJGv/GWDsYgaHi4tAgBhEhq+s8zL6qW2I
FGEtABVzMq9hEL4GL6vtlTI78PA3LpRYA2/rt8MMk1dwAVAAPRNpsWVO9r/UNsQbYSsAFbOzuhBh
JXgJLkmeXCbiYIH0cckxQPO60sd9tYNh0U4uAgpgAsTllnmZ3dU2xBNhKQD22VlXErHv+ZxfmqW7
GVb9I91Z4w3Ax/do8PlQDZqkSq/9vrCa4WQJFwEFiIWI5eVzM9upbYg7wk4ArHOzmrvAfgLf6pOk
2AK8uFo6tb1WAObfIaBNBpAaCyy4S0CcxKSqxMrw3A9cABQiUWBYaZ2XHXaJacJKAErmZqUyxpZz
Jx95vP6riMIK6eOevpbQrdHFt36TFOCVG6Vv/ep9DCtljC44sqjHRHFV6ex0GZOw0BE2AsA+bGTU
gS0DcJnatkQCJ84BX2yX7py9mxFGdal5mwe2ItzWVvr2v/ozg7PW189RjKZaCIvZh43CpuxcWAgA
YyBrmf19MHRT25ZIYdY6EQ6JjhmnB2YMEEAepvxTrifJqMAjhQzf7uajAMUgdLeV2T9m5vDoe2Fh
hG1u5ssAhqhtR6RQUA4s3SXdKR/rSchI8Px9cgwwoaf0I/DBZi4ASsKAO6ypWdPVtgMIAwGomJ15
D0/m4Rtf/sUk3/7JMcDQ9tK39552hOxE78fsOcmw7QQXAWVhz1pmZw1X2wpVM+aez+H3npo2RCIi
YxjSzvtWXpeGJCvCTycAD3UV8Nwq74qybA9Du+yICR2JDIi9ZZuVts3w2Kndqpmg1oWL32qQbHA5
/wTP4ac6pTag6ywXyry4EdeLI2x6zPN6AsdvDlid+k7JE3KK1bi4KlMAZoZgcDk/Ae/8YUG8Aejf
0nvPPl3G8LdEbgGOX1xm0Km3KKjKRSuLdmCgGtfmuKdfC+lX+4YjXACCATHcZE3NVGUdLOQDuvNJ
PX4HENE5aDYdQ8RHzXWoTzCeXwWyOYEr/+OC1en5+FuuIMwadPGdYXEAh88yxBsIqbFALA/ZCgQn
I6F7zNgTm0J50ZAuArJ5deOsovAZIrzzA5XRd3K88MKZ9+8ScN1lle8AgxZok0n485hnUfv7VNXv
Sm0MAxdcXDxMiQEapRBapxOuymToUJ/QMJkvGshES0z8jL1S52p6pqA0VBcN6RTAKmrnIUo8/d4c
JCDBGNkP9x9Hq/77qkzvv8/RIoBdogH14qjK36CwAth2guGTLSKeWMbQa56I69524ZWfRew9Fdmj
pRDR1BqjfyOUFwyZAFTMybgNINX3PZWiZxPC1yMEyT30cGZ7tb39xhLhVzYnaox6GiZ779iHzgJv
b2AY8J6IQR+K+GantA9DLeeBijmZd4XqYiERgPI3GmYIoKjb77+8LvD1CA3qJ6ltiX/knqvaebNk
iFlBNQFIjZU/Cvorl+HJZSL+Nc+Fhdt45iFPEPBW2Zv10kJxrZAIgEbrnMuA5FBcK9RkJACfDBFQ
Ly7ypgMF5YDrkrdxSoz072CxV+20SX6EteSeAyatENH3XYb1fGfBHSkajXZWKC4UdAGwzs26kYEN
DvZ11KRRCmHuYIJGdcdq33AxVHHsEUi6M9pcVUVCr/H/+ofPMgxbKGLSChEWPxKZRjl3hSKxaFAf
WTa/SSJj7J1gXiNc6NSAMOaayFIAgwYQLunPLlF6BKAVqopEoPN5xoCF2xhu+cCFAzLSmtUmRIa3
it5oFNQJZlCfWKvd+hqArGBeI5wYcw1FVF79RFPVDn+mXHoEoNdUPcfmxW/AF/afAW77n4gNOXxK
cAmZBo09qFGDQROAijmZ1wB4MFjthyMGLTC2e+SsBbSqtsyULyMHYPX8AWdliIZcSqwMIxaKPAvR
JRDh4Yq52Z2D1X5QBICZIRCjNxBBlYeUYnBbATER4ubUttq+/z+nvd8uIqButRzNueeUtckhAuOW
MPxykIvAeQRi4izGgtOXguIJaE3NHAGwjsFoO9yJ0QG9mhFW/i3/AW6VTljxoPrrBzvzvU/oU2Iq
RzmX4s112F8cLoYxixi+HqFB63Tl249AOlvnZtwL5H+qdMOKP3XslTrxAGYo3W4kcWkCTjmEwzCp
xArsOen9mBb1alq6ebwGK0cJMPcV0LUhVVlUDIQKB/DIIhElvFTZeehl9lqa4jUyFBcAm0k/GUCG
0u1GEm0i8K219hCr4hPgjsvr1fxMIKBlGuG+joTPhwlY/6gGj/ciJClQyO1oEcOUlVwAzpNlNWqe
UbpRRQXAMjejISM8pmSbkchldX17DYbDI75URuLPDjIyAmUmAON6CPhtrAbje1LAEYLL9jD8zp2F
LvBkxeysbCUbVHYNgNFUAGGT8lgtYvWVP3ILdfoiFw4XwwurK9OCeWNibwEJMu/E6TKGtYe8tycQ
0LWhfEvjDcD4ngJubs0wbomI3RLTC2+YfxCx6iFNxDlaBQETkTgZwMNKNaiYAFhn12/K4IqaYJ9A
qRNLKLfLe3P58n7TaQjbc0XsOen9rMvqMtzfUV6H/XgLJHP/t8mQTiHujiaphCUjNXh2eWUgkD8c
KABW72foLyNpSfRDD1jfSnvN+Mipw0q0ppimMojPIwri/JXCl6Gvr491zybSx3y2VYQoo7+V2oCP
t0gfeHNrGYZ5QCcAr94k4JYr/O/Ab2/gIYTn0TFRM1mpxhQRAOvcrOYgdo8SbUULugB85KUY0FL6
th0sAFbI2Iqc/weTXGnXCMBAiZyBUggEvH6zgM4N/Dt/Zx5kVUCuFTCMsL6ZebkSTSkiAIyxF6Fy
ivFwQ6+RP9z1dWDcJgNoJqN64n/Xeg+5zT0HLNgo/Wa9oTkhPSHw4bdWAF6/RVPDl0Auq/fxxcDz
aJgWU5VoKGABsMxJawzgdgVsiSp82Q/3p2vdeaX0WYfPMizwkmHuhdWiLEeeBzsrN/fOTpRXsMQd
aw5wAfh/GO62vpUmYzLonYAFgEHzOIAgDng57rj7apIs8Q1U1hA8Uliz46w9xGS9UTs1qEweqiTD
2vt33p6T0r4KtQgNnNpHA20kIAEofqtBMgH3BWoEx3cSjIS720nfPqsTGLdEhN1V9fPujQmDZCzK
PdNb+b23RikkawpTHasTyClS3JyIhREbde6/2RKJ3LwT0N01uFyPAIgLpA2O/zzSjRBvkD5uVz4w
48eqr06NAPznZgEDvCzu9WtBaB+kcmBSCUg94W40U4uJ1enFUYE04LcAsNnNDAAbE8jFOYGREgOM
6iKvI320pTJb76VoBWDWIHJbFShOD5j7Bs/zxt88iiUWZe2IdIhhHDO39tvf0u87bEX57ajlPv/h
wOiuAhqnyBOBF1Yz/FRtIU2nIcy9VcDIagt9T/UWgprcxF8X4RKbsnZEAZmWlKLb/D3Zf4knesjv
czmKYdAC0/qTrKKdThEYs0jEusNVRUAjAM/dIODlgQJ0GkKvpoTh7YPrdeetEKk3qqck4wBE/ife
8UsArHOzmgPo4e9FOcrSvTFhqES58AvYnMDor0W3W2p3X034eAjh9ZuDXwX4lJ+1b5JN3B3YDdda
Z2X7VXDHvxEAY6MQHmHsnPNMvkHAZTJX1i2OShH4fHtNEejakFBH8ajzmuT7KQCJCoQZRyHEBHGk
Pyf6LADM3FrPAB70E2YYtcD8OzWyIwCdIvDs9yJe/lmERGCh4jhcDFuP+3fRFC4AnrifzW/vcyyO
zwJgSS2+BYCb1BActWmSAswbLEDrw119ZwPDk9+xoKT28sS2XEKpH4t5eg3QzMdcC7WINIsj70Zf
T/J9CsDYvT6fwwkZPZoQpvbx7bYu2inixgWhy8v/zQ7/3PmuygqsEEm0IwBD/DhHPmx2SgIR+vp6
EU5oGdGBMMzHVfyDBcDgD10+JTP1h7wSYOku/67RqQHPCOINxmgAm1fXJ8c8n/6iVpgGgWf8iQhe
6Cfgtra+dZhSG/DIYhFTVzFUBKlU18yfRL+rCfVvoawtUUiMxaW7yZcTfHtCiN3p0/GckHGqlFXZ
WxcIePVGwq1tfMxPyIBPtojoO9+FzceUHQ0s2cWwfK9/bXZuALRO5/N/KQSCT31UtgCcr1F2g88W
cYKOUwTGLmEY9aVYpVSXRgBeu0nAwFa+d5zjxcCQT0X851cWcP0/oDLl+HOr/G/ovo58+C8HBvRn
85vIKPReiey/qlHnuAVAgDleOcHgP7+K+PMYwx9HGR5dIlbJ71fp7+9fOi6nCMxdL+KmBYEl9Txc
CAz/3OXXyj9QGZLcl+cDlIvBarPI3g2QL6siG+CXOZygsisfeG/jxWH16n0MTy8Xq8TNawXgzVsE
PCQzcKg6/5xmGPSBCy//XDOsWIqdecCdH7lwttyvS0OvAWYO0ChWcKRWIFB/2YfKOYh9BQ0I1/tv
EScYuETgme/FGkkyFu9keGypWCUdGBEw6XoBz/Xxz83XKVb6DNy4wIWdefLOWb2P4e5PXCjws/MD
lTb7kzugViOiLzPL69uyDrKcyuwCIKDEAxzlWbKbYa+H9ODL9zI89HVNB5+RnQiv3lgZ9OMP+88A
g//nwuZjno9xuBjeXMfw72/EgHYThneorDjE8RFCnYq66bLyLskSACLWLzCLOErjEIE31npfVPvl
IMOIha4akXd3XElYeC8hxY88/ykxwFu3CejkIbvv4bMMgz5keHOdvLTknri1DeGFIOYjiHYEJsia
BsgTAMifU3BCw/I9TFZp7rwSuPX179iAsOR+34bXPZsQVo4S0Ody92/lRTtF3PS+dNESKe7vGJqI
xKiGQdZLW1IASuZmpTKGqwO3iKMkH8ko5nFhB8BT2rCGyYTF92nQvbH3nmbSAdP6C/joHgFp8TWP
LawAHv5GxBPLmOxyaO7QCMBzfQQ831fgi36B00nOdqCkAOhF1k3OcZzQcbgQ+CtXWgDGdBfQTiKn
X4IR+OgewWMZsaapwOL7BAxr7z7pyJoDDP3eFbHyn8De+unxwOdDNRjZifd8hdDYnJauUgfJKdFw
jQLGcBRk2W5ph5r0BMLD3eR1Jo0APN9XQKNUhmmrL/oRDG5LmNFfgMlNkGmxBZi6SsR3ewL3Fuzb
onJhMpE7mSsKY7gGwCpvx0gKAOMCEHb8clD6mPE9CEYfK/CM6EBokiJgzGIRIzsJGN/TvYCsOcAw
8XsRZ8p8a786KTGViUdvbs3f+sGBJPuu10eEmVvrrVTkZxkHTjAos0NykS0rEbhdRuUgd/RoQvhj
nMZt0k67C3jpJxH/28ICSiJCBAy6gvDcDYJfFYc5MmHowsyt9WTe43FlxqsAWOoUtycGnoMljNhz
UrqU96AryKekINVx1/kPFwKPLg58hb9JSmWkYo8m/K0fAkwVdc9eCeBPTwd4FQCBiZ0YT/0XVuw/
I90Bb7lC2TXbRTtFPLcqsBX+GB3wUFcBY7oTdHxJOWRoXEJn+CsAjKitz6VrOUElp9D79xkJQPO6
ylyrzA5MXiHi293+PwREwOA2Aib2JviWqoKjBIzQ1tv33peJmPeTOaGnsMJ7Z2ydpsyI7Xgx8OCX
Luw7438brdIIL/QldGzg3iabExAZ3O4ycBSjjbcvPQ7G2FfQAGiluDmcgCixev++mQJv/y3HGQZ9
6H/nN+mA8T0FfPuA4LHzrz/CMOA9F/rMd2H9ET7MDCJtvAUGeRwB2PIym0EDvkYbZkj51/tbcusC
S3YxPP2d/2m7+rUgmPt6Lit2pJBh2mqGnw9e/EWGLRRxaxvC8324L0AQiLWl1G8MHD/k7kuPAiBq
qC3xBYCwQ8pFVgjAgf6TrQzPr/IviCc1FpjeX0B/D4k7yuzA7HUiPtxcM8MQY5UhzBtyRLxxC6Fr
Q77wrCQucrUB4FYAPA4NiFjLoFnE8RupyjgVdv9E+50NDFNX+tf5B7Qk/DRa47Hz//APw3Vvi3h3
o/f0YidLGIZ+JuLNdaxGjgOO/wiMeZzKe1sEbBwEWzgBUieWAC8jM39y+/93LcPs33zvcYlG4OWB
gtvy4gBwpqwyD6AvcQIuEXhznYiNRwmzbyXUi+OjgcAhj33Z844s83wSRz0aJnvvTP+c9u0V/sFm
/zr/1VmE5Q9qPHb+FX8z9HnX5XeQ0MajDDe97znhCccHCI08feXFJYN5PImjHs3reH8jHiuSPwr4
dKuIaT/61vk1AjC+J+GbEQLqJ7k/5tnvRTyySERRhU9N1+BUKcPdn1YmO+UEhG8jAGaGFkBW0Mzh
+M0VGSRZHmuZDMedVf8wPLfKN5/+eAPw3p0CxvcUoPHy6njqWgEd68tv1xslVobhnwUeblzLaXB+
W78Gbm+jNTWtPuSFCnNCjElXWSPPG9/srFofoDp7TzE8/q1vC34NkwmL7tOgdzPpOXlKDPD5MA1G
dFBm/u4QgXFLGNYe4iLgJzprfqbbp8a9jjPykPGNEw5c39y7M31+CfDxFvdD+8IKYNRXviXr7NqQ
sOwBwScXY61QGfQzc4D/CUgvxeGqTDK67QQXAb8g1tDdx+6nAKTh5b/DmBtbuc/OcynzfmduvQbH
LBJl5RK8QJ/LCR8N8d9BZ0g7wgd3EeIUKCljcQCjvhZxsjTwtmobDHAr324FQABSg2sOJxAyEyoT
dHqj2OK+FNewDuR1/n4pN7cmzBssveYgRY8mhC+Ga/zKQlyds+XAE8vEgPIR1EbIQ592PwJg4KUY
whxPOfwuZeluhmXVUnYNaEl45UbppJv3tiO8OUiZ4TsAXJEOfDVcg9TYwNv6/QjDp1u5p5BPCO77
tFsB8KQWnPChV1PClZnSnXPichF/n6oqAre3JTzf13Pa7XvbEab3Vz4zb7M6wMf3aDxmKfaFl9Yw
nPBhKlPbIZAPIwAuAGHPhVJfUlQ4gAe+FJFfUvXzER0I0/pRjU5+Q3PCC/2Cl5O/dTow/w4hoIxF
QOXv9aZEYRTORTz1afe3gRgvAxYBdG5QWUFHirwS4J5PxRo1+oa2F6oM86/OIsy+NfDOKUW3RoQJ
vQK/yJLdDMeLFTCoFuCjAPAw4Ejh+T6CrEw7OYUMIz53ocRadTpwc2vC/DsIbTIq6wOEKjnHw90I
XQKM+nOJ4GsBMvGU29ODHwAU2LThhIIkEzB3sEbWYt2ek8B9X7AaItC7GeHbkRokhDAWXyDgxX6B
rzMs3sUCqkFYi3C78uJpHKbAMg0nVHRuADzXR15P2naC4Z5PWQ0/fTVKcTWvC9wUYE2AM2XAjjyu
ANIw+QLA+Agg4hjWnmS73u45ydyuCajBv7sGrjwbjypgSJRDILd92v02IHEBiETMfQXcfbW8DvXP
aYY7P3Kp7lXXMo2QLVnC0jvbZdRJrO0w36YA7tWCE94QATP6CxjgIUa/OocLgbs+9s01OBhcKyPA
yBuHCrgASEM+rQFwIhSNAMwaRBjYSl6nOlrEcMdHLuQUqteJrs4KTAByz4G7BvuJBwFgAdSA4aiN
TkOYPUjAbW3lday8EuDuT9QbCWQGOAWwOit/ON5gNnefeloE5AIQQZRYWQ13X40AvHaTgDtkFgk9
WQoM/UydhUFfqxi7w+JDeHNthAD5AkDEBSCSmL0euOsThs3HqoqAQMCrNwoYLnN34Eghw/CFIs5Z
QzueVuLtLTfCsbbCPIzqPf3Z3KoFJ/w4WsTw8Z8iSqyVKbVXVUudRQS80FfAsPbyRGDvKYbxS0Pr
XFMYYO5AAIjR8UUA75D8EQD4CCBieGMtg91V+f92FzB2sYilu2uKwIv95E8HfjnIMOu30HWoE8WB
XcuohWJhy1GMDwLAoIAmc4LN8WJg+d6qnccpVibMWLSzpgi8PFDAjTJ3B+b8JtaYUgSLvacCOz/R
xDu/FIxgcfe5p12As8E0hqMM720U4XQTC+MSgae+E/HNzpoLg28MEmQF4YisMpeAvzUC5cIY8Ofx
wISmMY9dlYQY3CaL95QQhAtAmGNxAEt3e/5eZMDT34n45WDVzqUTgHdu95zT/1IOFwL/2xxcBdh7
igW8/XhZXT4CkMJTn/aQEETgAhDmrPy7ZlRfdfQaoEW9mp0jyQS8c7tG1vbbnPUM5UFcEfpie+DT
jGY8gZ0kzBcBICb6UWGOE0qqL/S548EuAjIS3H/XOh0Y31N676zECny1IzhrAQXlqDFN8YfOPIm9
NOTDFED0cDAnPCi3V9bO84ZRCzzY2fvQ+IHOkJXr/7MgJd2Y9qMYsANPejxwOZ8CSCL6tAbAGBeA
AAnm+vm6wxe3/jwxqA0hSaKUuE5DmNZfOuf3wQJg/xkfDJTBsj0M38oYxUhxbTPpGgkcQGDuF/Y9
5QQ8FlRragE2Z/Ceyg050sfcIzMsuHMDeUPonw8oJ2lbjjM8s1yZUcVgGTkROQDTUI67z90KgPHs
qeMAeHhFAHirzRcom4967zzZiUDbDPkdY2h76bWAbQrF3K87zDDi88CH/kBlLoGODbgAyMBhqpuX
5+4Lt+vAZIbTMhcnwDzXFed4p9QWnEnAOSuTLP/dr6Vvab17X1YZPnwhpNakAwyXPBkJRiBFYjoh
hcPF8PaGSg9Dl0JLCnJjHDg4RnfC7aTR80YQYzkANQqKOVGOSwROlxGCsRKw7zQk/fS7ui0D6ZlY
PTBvcPCiaX49BMz8SVR0HaFZHeD2tsq1F+Uc8fSFZwEgygnqSlYUc7qs8o0XDPad9t6uQECH+kG5
tE+UWBlW7QM++lPEnpPKtz/lBg10AdYsrEX4IQBeTuJ4Z1d+8JQzp8j7yKJBMiHRGPqhcYmVYf8Z
YNdJYO1BERuPBi9Jx4CWhH81DU7bUQljOZ6+8igAjLE9BD7H8gdfk1TanQzHitx/VycOiLmkWMep
Uu9tV9/XL7MDf8m0p9TqfdIisso1iBIrnf8vkF8C7D8TuDuvXBokAy8P5A8P8RsAABVFSURBVM+l
LwgC7fH0nUcBEEjYxXiiNb9Yf8S3v9uBAqDnPPcb+5/eK6B744sP/Jky7203Sq767x25lXkClEWd
58KoBebeGtoCJtGAyFw7PX3nceXHUJB7EEAYZI6PLI4WMezKV669rGr58k6VeX/7pcVX/Xduifvj
Ig2dALx1u4C2mWpbEmEQSo1jT+V4+tqjAJAZIsA8Dh047vlmh3JtEQGZCVU7vNQIoE5s1ePzoqCE
tk4A5gwW0DvA9OG1EobdRJ6HbBJ7P7RLaXuimXNWho+2KDfcNmqr7sdbHJCMzKte3FMqYjDcSTIB
nw7VoF8L3vn9gpjH4T/gfRcAxLCT8b+7bN7eULkwphTVt7nkeBdWPyeSl3Fa1CO8czuhEU/44Tck
kpesERICIArCJmK8/LIcduYBCzYq29sM1e6OXYZvQZDcD0KKVgAe7EJ4vJcAPd/rDwgXsY3evvcq
ACZd2jarPb8CQIyiVkUZpTZg/FKX2/RcgWCt5i9v1EoPx2zVzok1BMcjMVh0a0SYfD2hdTofeipA
eczZ/L+8HeB1DYBGb3UA2KKoSVGGQwTGLBJxuFD5tsvsqOI3HyOjYmORpWpnb5Ds4cAw48pMwsdD
BCwcKvDOrxQMG8nsPahPRlIo+h1gPZWyKZq4kIZ73eHgvGEZqxxdXIjr1wqVgTne1hnyq237ycn9
pxY6AbjhcsKQdlTF14GjGL9LHSApAMTwO18IrMk5K/DQVy5sCnLmhOPFqJLYo34SYc9Jz4JzrLjq
v69IJ+gEBD27r1wMWqB7Y8J1lxH6tSCk8Mll0CAlBMDmpD/0OiaCVxKuwj2fiAHns5dDTiFDm0ti
+xsmw2twTXUnpHgD0LEBYUOOOusA6fGVcfvts4FODSodeZSoBciRxGUwGDdJHSR5KxIfP1FomZO5
DUAHRcyKEro1JlyVFfzrJFaLw2+TQVjxt+fOfLSIobACVd6swzpcTA/mFIEKBbP8mnSVW48mHaFO
LENaHCE1FmiUAjRJJcS7rUrPCTqETTT6sKQbmFwtXgkuAFWYcj0BKgRLtc3w/j1jwK8HGQZfUhq8
fwtC/5A40vC5YtjAaJWcw2QN65kIWY1xgk+H+iQ5hP5Rwfx9nMhEhLhSznGyBMCUkbcJvFpQWGDQ
Ap0lSnv9coDhnIIeiZwIg6Eg5mz+NjmHypoC0J1wWebgJwB3BWQYRxEGtiKsPeT5LW91At/sEPFA
Z3nrtkUV0lGDBMb35yMFolWVwXzSyF+PJfY9GHEBCAP6Xg5MXek9NuDjLcCIjpW+A1I8+Z2INRLT
hh5NCJ8M4QIQCTDGVsg9VvbWnlG0fQuADyzDgEQj4ebW3jvj0SJWo0S4O/48xiQ7PwAMkVlngKM6
VhOs38s9WLYA0LjCEkb40T+bOEpzX0fpijiz1omo8JJ/3yUCL6yWHimmxROub+6jgRxVINAKGlco
Ow2MT849JLIvfTeJEwxapxOuv8y7AuSVVIqAJz7awrBbRsbesd0JOg0fAUQCIsGnPuqTABgtjmUA
LD5ZxAkaj/ciaCTu4PubGHa6qQlzpJDhv79Kv/2zE4G7+fA/UqgwWZyyh/+AryOAZwpKCSRrf5ET
fFqmkeRKv1MExix2VQkgqgxiYiiT4RE46XoBOu4EHil8R0+d8imPp8+3VoS40NdzOMFjQk9Cw2Tv
b+jjxcC4pSIcLgbGgInfi14Dii7QrwVhQEv+9o8UBMLnPp/j6wkmfeYyAEGo9cLxB5MOeGmg9ILg
rwcZnl7OMHMNw2IZuwOpscC0fvzVH0Gc1OsyZG//XcDnO0yjtzqI8Imv53GCR7dGhPs7Sb+pl+xi
eG+j9LxfpyHMGyygbpwS1nFCAtEH5xP4+IR/Ei9q5iOS8kzVAiZfp1za7MnXE7pIuBtzwgpGRB/6
c6JfAmAcd/wQQOv8OZcTHDTnc+e3Sgus447qIuC+jrzzRxQMPxvHnDjoz6kBTPLYu/6fywkGsXrg
/bsEZCT4d/6DXQRMvp53/kiDCVjg77l+C4BRn/E1gBP+ns8JDhkJwNL7BbT0YSRABIzvKZzPccCJ
MHJNBcmL/T3ZbwE4v+Awz9/zOcEjLZ7wxTAB7bKlO7ROAP5zE2F8T975IxHGMIvMe/zO8RTQPo/V
qX8HQFkgbXCCQ6IR+OxeAX0u99yxsxOBRfdpcFtbvt0XkRBKbS79e4E0EdCdT56QU8xA7wfSBid4
mHTA/NsFTO9PNWoG9m9JWDFKw6vtRjDEMD95Qk6x9JFe2gjUCMsb6Y2gFQ7Al9wCnJBzvBiY8K2I
/WcYJvYWMKQdH/JHOA6IaGZ6LC+gxPSKPAWWOZmfARiiRFuc4OFwMVQ4CIlGtS3hBA772PRo/ohA
W1Fk8keiYAa8lyDiqI9Owzt/lOAiEmYo0ZBi40DHn7M3CGlXdVWqPQ6H4x7x9F+/6TqMU6Rcn2Lz
9lMlSVPSr75mjUYqQJ3D4fiNyyWi8NDBF5VqT9GVoPffX5dzXGzZUMk2ORzORRpo9h4eObJXU6Xa
U3Tl3pKz/q4/zqZvtDNeD4rDURq9YEVayobblWxT0fH62GmTNnWM37BeyTY5HE4lneI2/PrwtGe3
K9mm4hP2VqacW1N1BXxHgMNRkBRNgesy01HF63IoLgBDzOaCDrF/cO9ADkdBOsZveGu42Xxa6XaD
smRvNRQ80thwkMcIcDgK0Nh4sNRqODs+GG0HRQDMZrPYJmbrBJJXnozD4XiAIOLKmC1jzGZzUDpT
UB3Cn35q4e4/y65pHcxrcDjRTKf433a88urQq4LVflC9dlon7umboilwBfMaHE60kqorcDbR7u0f
zGsEVQBGTJmR2y1h3fRgXoPDiVa6xq1/ZfTMmfnBvEZIYkLHPbnk+K7ydtmhuBaHEw1cGbc1583X
BjcO9nVC4rjfIm7HwFhNKU8jrhAxRgHxMRd/BD/volLtAJAsTMKRT4K2RGwRu3NgKK4Vstv22qQ3
Xl5RNPiZUF0vWkmME/DF9CwY9Bdv3eiXTuLAcd/SwtVJ0mDhi5nQai+2M9ychxOn5flwEQHXto/B
dR1j0bKRHolxGjhdDGeKXNi814Jl68qQk+9znQoOgP7JS994euZjj4fiWiEL3Xtq5oSJ7eI37Q/V
9aKV4QMSq3R+fxl5U1KVzu8LSfEazH4iDVNG1kHXNiYkxWtABOi0hMy6WgzqFY93J6VjaP/EgO2s
bXSI++PvUHV+IIQCAABXxuztkabP8zuDaW2nW1sTBvWKD7id3h1i0LdLrF/nxhgFvP5YPbRu4j3g
S6shjLwpEcMHcBGQS5ou39E86dC1obxmSAVguNl8ukvs2ge0xEMFfKVrGxOmjqwT8Fy7V7sYPDM8
1e92hg9IQONMnfSB5xk2IBFNsuQfX1vRkhNdY38bPWry5FOhvG7Is3eMnznx024Jv6wO9XUjFa2W
8OAtSZg2um5AQ3+DjvDI7cl47oE60Pk59E9L0WLwtVVHIIwB7y4pxh3P5uLB6fnYvs9a5XuNAIy+
Ndlvu2sLPRJ/Xv7YS0/5Vd8vEFRJ30PGE/3bxPyVq8a1I4n2LYx4Z2I6hvRN8HuFngjo1NqE+c+m
4/be8QGNIK5tHwOtpmoD3/1Whi9+LMHZcy4cznPg+XcLUFpe1Wu1fQsjkhM0/l84ymlt2nFSNOTe
osa1VREAs9kstjZs6pihz7Wpcf1wx6gnzH82Ha+Nq4cmPgy3qxMfK2DB5Ay8PKYuGqQHPgzv1S6m
xmcr/6ga81VmEfHrtooqnwkC0OMqU8DXj0bS9Hn2dqadnYLl6y+Fagn8Rs+cmd8tZs1gk1DB/QOq
odcRLquvr/H5vqN2n7b7YgyC2/n67kM2n7fodFpCs2o2uUTgUG7NdvYcrqnrLRvxLFHVMQoW1j1x
zZ0jZ0w5rpYNqmbwHPvS1BXXJq56lYhrgDcYA5avL8P4/55Ccan/LwrGgMW/lOKJWadRWuFbO/Xr
aVE932t+gRNOZ817d+JMzUXeRhl8IfBSiBiuTfzhzbEvTvlWTTtUr+bz1MwJE6c8ndLz99J/8ZTi
bjhw3I53lxRj6z9W6YO9sPeIDfOXFGPXQf9mXfXdTCEKit3v5hQU1fy8fprqj1pY0S3+1/Wh3O/3
RFjcFW3Mke5Xi7EHt5d3DLrvcyTAGPD7TguWry/D5j0WMD8HSC6RYe32CixfX4atfwcmIAmxNQeL
Nrt7w+yOmp/HGAVoteR2xFDbuDJ22zGtKaeX2nYAKk8BLmA2m8UuFbvbXmb6p0htW8KB0goRU985
g027/e/8AFBQ7MIL7xUE3PkBwGRwIwBuOjoA2D24eZgMPGCgqWn/uQ5xO9qotehXnbAQAAC48y1z
WbuYzZ3TuadgWGJ044Pg7k0PAHYPb3l3IlKbqKc76eiU8GenoWZzidq2XCCs7si/p089cE3izwMS
tOfCQh05fuJh1CLU4gFAkrZIvC5xzQ0PmSeFVTxMWAkAAIx9cfKaPokr7+Lhw+GFu+G+J49Cvc79
5xXW2qnrMUI565mwcuhD0yatVduW6oSdAADAmOnPfNMn+YcHTYKFi0CYYLXV7LyeOrqnzy222nc7
TUIF65P63f0TZjz7udq2uCMsBQAAxk174oO+ScsfMZBFbVM4AMrcaLHehxGAzcHgqGU7AHqy4bqk
5Y8/9uIzH6ltiyfCVgAA4LEZT75zffKKqTx6UH3yztT0+EtJdO/fX8fN58dO1q7kIDpyoG/y8slP
zHjqTbVt8UZYCwAAPDnj8el9k7+dqiceNqAmx0/VFOHMulq3wUXZ9Wq6lxytRdmB9IIV/ZKXTX58
xuMz1bZFirAXAKBSBAakfDeWxw2oR5lFRH61ko8GHbl18XUX/7//WO3Y3TUKFtY38bsnI6HzAxEi
AADw2PQn5vVJ/HZ4nFDCRUAlNuysuR7Tr2tclX9rtYR/ta+abYgxYN1f0b+WEyuUsT6Jyx56fOaT
r6tti1wiRgCAymQi/ZOW38X9BNThl60VNT4bfG08eneIAVFl+PHTQ1NQL7nqGsDuwzacLozudZwk
bZHYJ3n57RNmPr1AbVt8ISxiAXzhkRnPfv3uVHb615J/rc63Z9WMmeUEjb1HbFi3vQI9r76YF0Aj
AFNG1sHTwxh0WqqxJsAYsGBpcYgtDS1punxH97g1A8dOn/yj2rb4SkSNAC7w0LRJa3vp17RqZtwX
3U9WGDLv6yK3b3O9rmbnByrDmHcdit4F3MbGg6W9U36+YuzMyOv8QIQKAACMfm3qoV6JGxpeFbcl
R21bahNnil14fNZpHMmTXtX//vcyzPqyMARWqUOb2G0neiT+1iDc3Ht9IaITtS3+9Vdbvz6t5mQL
5/octzeqr7Y9oaBxhg52J0N+gfP/f9bvqEBJuW/LIk2z9LDYqrazblsFKqzSa6ylFSJW/F6Oc+Ui
EuM0qJN08TGyOxg277Vi9ldFWPRzaUDRjOEKEcM1Cb+uj43dd+UEszmiVzejJjzjtSmzZv5S1Gei
RYyJmt8pUtBpCcnxApwuoKjUFZWd/gJGwcKuTfzhzXBI5qEEUdVZ5k2Z1u/30t5L8+3ZPAEdR3HS
9Hn2nvG/3PXI9ElL1bZFKaJKAABg/qRJGXtsnf/cVXFVltq2cKKHVqYdp9qbdnZUM4FnMIjYRUBP
jJ45Mz8lfnuDXgmrf+AxBJxA0ZIT1yatXl43YVtmtHV+IApHAJcya/Irw/4o67nglD2T+wtwfCZd
n2fvHLv2gfEzJ36qti3BIqoFAAAWmM0pByqa/by5tMeVatvCiRyuivvzcKukfd1CXasv1ES9AFzg
1WffeG1d6Q1PlLvia83vzPGdBG2JeE3CmlefnjH+WbVtCQW1qjO8Pcl8xX7HFd/+Vdaxidq2cMKP
NrHbTrSK29H332bzXrVtCRW1SgAu8PqkN8x/lPWcfNZRJ+JiITjKk6w56+qasPY/T82cMFFtW0JN
rRQAAPjYbK63v6LptxtKu3dh0bcZwpEBEUP72I1/N9PtvG70zJn5atujBrVWAC4we+rrI/8qvWrW
EVuzOOmjOdFCE+OBkrYxW8c+NuOZT9S2RU0iOhZACVauW739ln5NXmmlP5pQ5ErtaBHd1MDiRA3J
2gJXz8Q1C2Jj/+kxcdqUHWrboza1fgRwKQvN5jr7rdkfbyrt3s8qmvjfJorQC1Z0jN28sWHisUHR
vrXnC/whd8O8qS91zLE0/N9fFZ1bORlfJ4xktOTE1bGbdjcyHr3vkWnPblXbnnCDC4AX3ps8vct+
e7MPtpd1bOmKvORJtRotOXFF7PbDzXT7Hhwzc/IvatsTrnABkMHcSTNuOORo8e6usqsacSEIb7Tk
RNuY7Yea6PeN4h1fGi4APjBv8vTu+Y6Gr20vb9+5Qozlf7swwiRYWJvYLTub6XL+PWrGlI1q2xMp
8IfYDz6aPjnraFn9OTvLO9x81lm31u+kqEmStkhsE7vtt7ra3PsfnT71iNr2RBpcAALgq0fMcUcS
U186ZGt+70Hr5cmM8T9nKCBiaGb8p6iJ4cCnyYazE0ebzTXzlXNkwZ9YhZj73IzrTtszpu4tb9ud
jwqCQ5K2SGxt2rU5Q3tsEp/fKwMXAIWZbzbHnHMkTc21NRr6T0WrbDvj2ckCQS9Y0cK093i28din
dbRFL95vNlvVtima4AIQRBaYzSnFjqQJufbsIX+XX9HExkxqmxQR6MiO5qZ/TmUZjn6bgjxzbfXT
DwVcAELER9MnZ+VXpD170pE9MMfSrME5VyJ3Ob6ERM05sYlx/9E0Xf73SZQ/k3f60MAFQAV+MZu1
O+zGu4vFOkPy7VmdD1ubp9Q2j0OBXKivP1aRrT++M8Vw6pOrNWXvXms28ySOIYYLQBjw3owZaaVW
w20ljqQbCxxp7Y5Ym9aLNj8DvWBFtv54SYYuf2+S7uxP8fbS/41+beohte2q7UTVQxYtfGg2G0ts
sfeUs/j+hc6kNoXOutn5jqzYSAlQMgoWlqHLLU/VnTmerCneHas/932CYPmSL+CFHxHxQHEAs9ks
1LXrelUgpm+5K75duRjfoMSZWLfYlRxf6KyjC/UUQktOpGgLHEnawtIETcmZWKH0WIy2bGuSULEq
T2P/zWw28xLuEQAXgCjgK7NZf86hbVdBpvZ2UahvF/UZdhjS7aIh1S4akmzMFOdgWp2LaTROptcB
gJUZtIxpCACIXMxINicA6MhuF8gl6sjpMJClzKCxFenIVqiH7aResOfrBfF4DLNsTdQ5t91pNtvV
/L05HA6Hw+FwOBwOh8PhcDgcDocjwf8BnHZ+mE3sgIkAAAAASUVORK5CYII=
"/>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="120" fill="none"><g clip-path="url(#a)"><path fill="#472459" d="M127.2 65.67c-5.13 5.11-12.23 3.88-17.35-1.25l-41.4-41.6 19.4-19.47a11.35 11.35 0 0 1 18.4 3.37l21.58 56.22a2.5 2.5 0 0 1-.57 2.67l-13.63 13.57 13.58-13.51Z"/><path fill="url(#b)" d="m109.85 64.42-54.16-54.4a13.13 13.13 0 0 0-18.57-.05L3.87 43.07a13.13 13.13 0 0 0-.05 18.57l54.16 54.4a13.13 13.13 0 0 0 18.57.04l10.7-10.64 3.12-3.12-10-10.03a19.04 19.04 0 0 1-23.83-2.56l-6.8-6.85a2.46 2.46 0 0 1 0-3.5l3.35-3.32-8.4-8.46a3.67 3.67 0 0 1-.34-4.9 3.57 3.57 0 0 1 5.29-.23l8.51 8.56 6.68-6.63-8.41-8.46a3.67 3.67 0 0 1-.33-4.9 3.58 3.58 0 0 1 5.3-.24l8.5 8.57 3.36-3.34a2.47 2.47 0 0 1 3.5.01l6.8 6.84a19.03 19.03 0 0 1 2.41 23.86l10 10.03 5.69-5.67 8.16-8.12 17.4-17.31c-5.15 5.11-12.25 3.88-17.36-1.25Z"/></g><defs><linearGradient id="b" x1="63.6" x2="63.6" y1="6.15" y2="119.91" gradientUnits="userSpaceOnUse"><stop stop-color="#FFCA4A"/><stop offset="1" stop-color="#F7931A"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 0h128v119.91H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

+1 -21
View File
@@ -1,4 +1,4 @@
"""Load the Nix-generated config and build-time versions for Sovran_SystemsOS_Hub."""
"""Load the Nix-generated config for Sovran_SystemsOS_Hub."""
import json
import os
@@ -18,23 +18,3 @@ def load_config() -> dict:
return json.load(fh)
except (FileNotFoundError, json.JSONDecodeError):
return {"refresh_interval": 5, "command_method": "systemctl", "services": []}
def load_versions() -> dict:
"""Read the Nix-generated or fallback build-time package versions."""
path = os.environ.get("SOVRAN_HUB_VERSIONS")
if not path:
# Candidate 1: parent folder (same as config.json, typical for Nix layout)
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
candidate1 = os.path.join(parent_dir, "versions.json")
if os.path.exists(candidate1):
path = candidate1
else:
# Candidate 2: local package folder (dev folder structure)
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "versions.json")
try:
with open(path, "r") as fh:
return json.load(fh)
except (FileNotFoundError, json.JSONDecodeError):
return {}
-177
View File
@@ -1,177 +0,0 @@
#!/usr/bin/env python3
"""Sovran DDNS update runner (sovran-ddns-update.service).
For every stored Njal.la update URL this asks Njal.la to point the record at
the address the request came from ("&auto"), then reads back the address
Njal.la says it recorded. That address is saved to /var/lib/secrets/external-ip,
where livekit-turn-setup and the Hub read it.
Njal.la is the only party involved. It has to learn the address to publish
it, so nothing else -- no STUN server, no public resolver, no "what is my IP"
service -- is ever asked for it.
Kept from the previous runner:
* every URL goes through _validate_ddns_url() (https, njal.la only, /update/)
* curl is run directly: no shell, no redirects
* the update key is never printed or logged
The module is installed next to security_helpers.py (/etc/sovran/) and run as a
script by the service; it is also importable as
sovran_systemsos_web.ddns_update so the tests can exercise it.
"""
import ipaddress
import json
import os
import subprocess
import sys
import tempfile
try:
from .security_helpers import _validate_ddns_url # imported as part of the Hub package
except ImportError: # run as a script from /etc/sovran, next to security_helpers.py
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from security_helpers import _validate_ddns_url
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
IP_FILE = "/var/lib/secrets/external-ip"
_CGNAT = ipaddress.ip_network("100.64.0.0/10")
def is_public_ipv4(value) -> bool:
"""True for a globally routable IPv4 literal (not private, loopback, CGNAT ...)."""
try:
ip = ipaddress.ip_address(str(value).strip())
except ValueError:
return False
if ip.version != 4 or ip in _CGNAT:
return False
# is_global alone is not enough: CPython reports multicast as global.
return ip.is_global and not (
ip.is_multicast or ip.is_reserved or ip.is_loopback
or ip.is_link_local or ip.is_unspecified or ip.is_private
)
def normalise_url(raw: str) -> str:
"""Return the URL to call for a stored (or freshly pasted) update URL.
* Older Hubs stored "...&a=${IP}": the address was looked up locally and
substituted. Njal.la can use the address the request came from, so that
placeholder becomes "&auto".
* "&quiet" is dropped: the reply is how we learn the address Njal.la recorded.
"""
return raw.replace("&a=${IP}", "&auto").replace("&quiet", "")
def parse_reply(body: str):
"""Return the public IPv4 address Njal.la says it recorded, or None.
A successful update replies with JSON of the form
{"status": 200, "message": "record updated", "value": {"A": "203.0.113.7", ...}}
"""
try:
data = json.loads(body)
except (TypeError, ValueError):
return None
if not isinstance(data, dict) or str(data.get("status")) != "200":
return None
value = data.get("value")
ip = value.get("A") if isinstance(value, dict) else None
return str(ip).strip() if is_public_ipv4(ip) else None
def read_ip_file(path: str = None):
"""The address recorded by the last successful update, or None."""
try:
with open(path or IP_FILE) as f:
return f.read().strip() or None
except OSError:
return None
def write_ip_file(ip: str, path: str = None) -> None:
"""Replace the file atomically so a path watcher never sees a partial write."""
path = path or IP_FILE
directory = os.path.dirname(path)
os.makedirs(directory, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".external-ip-")
try:
with os.fdopen(fd, "w") as f:
f.write(ip)
os.chmod(tmp, 0o644)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def update_all(urls, *, run=None, validate=_validate_ddns_url):
"""Call every update URL once; return the address Njal.la reported, or None.
Nothing secret is printed: URLs are only ever referred to by position.
"""
run = run or subprocess.run # resolved per call so tests can substitute it
reported = None
seen = set()
todo = []
for raw in urls:
url = normalise_url(raw)
if url not in seen: # an old "&a=${IP}" entry and its "&auto" twin are one record
seen.add(url)
todo.append(url)
for number, url in enumerate(todo, 1):
try:
validate(url)
proc = run(
["curl", "--silent", "--ipv4", "--max-time", "15", "--fail", "--no-location", url],
capture_output=True, text=True, timeout=20, check=False,
)
except Exception:
print(f"DDNS update {number}/{len(todo)}: skipped (invalid URL or curl unavailable)")
continue
if proc.returncode != 0:
print(f"DDNS update {number}/{len(todo)}: failed (curl exit {proc.returncode})")
continue
ip = parse_reply(proc.stdout)
if ip is None:
print(f"DDNS update {number}/{len(todo)}: Njal.la did not report a public IPv4 address")
continue
print(f"DDNS update {number}/{len(todo)}: ok")
if reported is None:
reported = ip
elif ip != reported:
print("DDNS: Njal.la reported different addresses for different records; using the first")
return reported
def main() -> int:
try:
with open(URLS_FILE) as f:
urls = json.load(f)
if not isinstance(urls, list):
raise ValueError("not a list")
except Exception:
return 0 # no URLs configured -- nothing to do
urls = [u for u in urls if isinstance(u, str)]
if not urls:
return 0
ip = update_all(urls)
if ip is None:
print("DDNS: no address reported by Njal.la; keeping the last known one")
return 0
previous = read_ip_file()
if ip == previous:
print(f"DDNS: public IP unchanged ({ip})")
return 0
write_ip_file(ip)
print(f"DDNS: public IP is now {ip} (was {previous or 'unknown'})")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,29 +1,12 @@
#!/usr/bin/env bash
# ── Sovran Hub External Backup Script ────────────────────────────
# Backs up Sovran_SystemsOS data to an external USB hard drive using rsync.
# Backs up Sovran_SystemsOS data to an external USB hard drive.
# Designed for the Hub web UI (no GUI dependencies).
#
# On Server + Desktop and Node systems, your Sovran Pro already backs up
# your data automatically to its internal second drive (BTCEcoandBackup at
# /run/media/Second_Drive); this script stores a copy in a third location.
# Desktop Only systems have no internal second drive, so the external copy
# is the second location.
#
# What gets mirrored depends on the system role:
# - Node / Server + Desktop: /etc/nixos, /etc/nix-bitcoin-secrets,
# /home, and /var/lib (minus databases, blockchain data, logs, caches).
# - Desktop Only: /etc/nixos and /home. Desktop Only runs no server or
# Bitcoin services, so there are no nix-bitcoin secrets or system
# service data to back up.
#
# 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).
# Your Sovran Pro already backs up your data automatically to its
# internal second drive (BTCEcoandBackup at /run/media/Second_Drive).
# This script creates an additional copy on an external USB drive —
# storing your data in a third location for maximum protection.
#
# Usage:
# BACKUP_TARGET=/run/media/<user>/<drive> bash sovran-hub-backup.sh
@@ -34,28 +17,18 @@ 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=("$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"
INTERNAL_MOUNTS=("/run/media/Second_Drive" "/boot/efi" "/")
# ── Logging helpers ──────────────────────────────────────────────
log() {
local msg
msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
echo "$msg" | tee -a "$BACKUP_LOG"
}
@@ -64,47 +37,16 @@ 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
@@ -117,18 +59,22 @@ 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
# Filter out internal mount points
is_internal "$mountpoint" && continue
if mountpoint -q "$mountpoint" 2>/dev/null; then
@@ -138,16 +84,14 @@ find_external_drive() {
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'),
@@ -155,10 +99,24 @@ 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
@@ -170,10 +128,16 @@ 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 \
@@ -185,6 +149,7 @@ 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"
@@ -196,119 +161,6 @@ 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"
@@ -317,30 +169,6 @@ 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)"
@@ -352,23 +180,11 @@ case "$ROLE" in
esac
log "Detected role: $ROLE_LABEL"
# Backup scope depends on the role. Desktop Only systems run no server or
# Bitcoin services, so only the NixOS configuration and home directory are
# mirrored (2 stages). Node and Server + Desktop systems also mirror the
# nix-bitcoin secrets and /var/lib system service data (4 stages).
if [[ "$ROLE" == "desktop" ]]; then
TOTAL_STAGES=2
HOME_STAGE_NUM=2
log "Desktop Only role: backing up the NixOS configuration (/etc/nixos) and home directory (/home) only."
else
TOTAL_STAGES=4
HOME_STAGE_NUM=3
fi
# ── Detect target drive ──────────────────────────────────────────
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
@@ -377,261 +193,129 @@ 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 ext4-formatted USB drive and try again."
fail "No external USB drive detected. " \
"Please plug in an exFAT-formatted USB drive (≥500 GB) and try again."
fi
log "Detected external drive: $TARGET"
fi
validate_target_mount "$TARGET"
# ── Verify mount point ───────────────────────────────────────────
# ── Set up stable backup destination ────────────────────────────
# Subsequent runs update the same mirror, transferring only new or changed files.
[[ -d "$TARGET" ]] || fail "Target path '$TARGET' does not exist."
mountpoint -q "$TARGET" || fail "Target path '$TARGET' is not a mount point."
BACKUP_DIR="${TARGET}/${BACKUP_SUBPATH}"
mkdir -p -- "$BACKUP_DIR"
# ── Check free disk space (require ≥ 10 GB) ──────────────────────
# 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"
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)."
# 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"
# ── Create timestamped backup directory ─────────────────────────
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
BACKUP_DIR="${TARGET}/Sovran_SystemsOS_Backup/${TIMESTAMP}"
mkdir -p "$BACKUP_DIR"
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')
# nix-bitcoin secrets and /var/lib system service data exist only on the
# Node and Server + Desktop roles — they are skipped entirely on Desktop Only.
SECRETS_BYTES=0
VAR_LIB_BYTES=0
if [[ "$ROLE" != "desktop" ]]; then
SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets)
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')
fi
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: NixOS configuration ────────────────────────────────
# ── Stage 1/4: NixOS configuration ──────────────────────────────
log ""
log "── Stage 1/${TOTAL_STAGES}: NixOS configuration (/etc/nixos) ──────────────"
log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────"
if [[ -d /etc/nixos ]]; then
sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/etc/nixos/"
rsync -a --info=progress2 /etc/nixos/ "$BACKUP_DIR/nixos/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 1 failed while copying /etc/nixos"
log "Stage 1 complete."
else
log "WARNING: /etc/nixos not found — skipping."
fi
# ── Stage 2: Secrets ────────────────────────────────────────────
# Only applies to the Node and Server + Desktop roles. Desktop Only systems
# run no nix-bitcoin services, so there are no secrets to back up and this
# stage does not exist for them.
# ── Stage 2/4: Secrets ──────────────────────────────────────────
if [[ "$ROLE" != "desktop" ]]; then
log ""
log "── Stage 2/${TOTAL_STAGES}: Secrets (/etc/nix-bitcoin-secrets) ───────────"
log "── Stage 2/4: Secrets ───────────────────────────────────────"
mkdir -p "$BACKUP_DIR/secrets"
if [[ "$ROLE" == "desktop" ]]; then
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
else
if [[ -e /etc/nix-bitcoin-secrets ]]; then
sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/"
rsync -a --info=progress2 /etc/nix-bitcoin-secrets "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
log "WARNING: Could not copy /etc/nix-bitcoin-secrets — continuing."
else
log " (not found: /etc/nix-bitcoin-secrets — skipping)"
fi
log "Stage 2 complete."
fi
# ── Home directory ──────────────────────────────────────────────
# Stage 2/2 on Desktop Only, stage 3/4 on Node and Server + Desktop.
# 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 "Stage 2 complete."
# ── Stage 3/4: Home directory ────────────────────────────────────
log ""
log "── Stage ${HOME_STAGE_NUM}/${TOTAL_STAGES}: Home directory (/home) ───────────────────────"
log "── Stage 3/4: Home directory (/home) ───────────────────────"
if [[ -d /home ]]; then
sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \
rsync -a --info=progress2 \
--exclude='.cache/' \
--exclude='.local/share/Trash/' \
--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='.config/BraveSoftware/Brave-Origin/*/Cache/' \
--exclude='.config/BraveSoftware/Brave-Origin/*/Code Cache/' \
--exclude='.local/share/baloo/' \
--exclude='.thumbnails/' \
--exclude='.xsession-errors' \
--exclude='.xsession-errors.old'
log "Stage ${HOME_STAGE_NUM} complete."
--exclude='*/Trash/' \
/home/ "$BACKUP_DIR/home/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 3 failed while copying /home"
log "Stage 3 complete."
else
log "WARNING: /home not found — skipping."
fi
# ── Stage 4: System data ────────────────────────────────────────
# Only applies to the Node and Server + Desktop roles — Desktop Only systems
# run no server services, so /var/lib holds no service data worth mirroring.
# 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.
# ── Stage 4/4: System data ───────────────────────────────────────
if [[ "$ROLE" != "desktop" ]]; then
log ""
log "── Stage 4/${TOTAL_STAGES}: System data (/var/lib) ───────────────────────"
log "── Stage 4/4: System data (/var/lib) ────────────────────────"
if [[ "$ROLE" == "desktop" ]]; then
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/' \
rsync -a --info=progress2 \
--filter='- /lnd/***' \
--exclude='logs/' \
--exclude='log/' \
--exclude='*/logs/' \
--exclude='*/cache/' \
--exclude='*/tmp/'
log "Stage 4 complete."
--exclude='*/log/' \
/var/lib/ "$BACKUP_DIR/var-lib/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 4 failed while copying /var/lib for Desktop Only role"
log "Stage 4 complete (Desktop Only role excludes /var/lib/lnd)."
else
log "WARNING: /var/lib not found — skipping."
fi
elif [[ -d /var/lib ]]; then
rsync -a --info=progress2 \
--exclude='logs/' \
--exclude='log/' \
--exclude='*/logs/' \
--exclude='*/log/' \
/var/lib/ "$BACKUP_DIR/var-lib/" 2>&1 | tee -a "$BACKUP_LOG" || \
fail "Stage 4 failed while copying /var/lib"
log "Stage 4 complete."
else
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 "Updated: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
echo "Generated: $(date)"
echo "Hostname: $(hostname)"
echo "Role: $ROLE_LABEL"
echo "Target: $TARGET"
echo ""
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/"
if [[ "$ROLE" != "desktop" ]]; then
echo "- /var/lib → current/var/lib/"
fi
echo ""
echo "Exclusions:"
if [[ "$ROLE" != "desktop" ]]; then
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"
fi
echo "- Browser disk caches, thumbnail caches, trash directories, X session error logs"
echo ""
echo "Important limitations:"
if [[ "$ROLE" != "desktop" ]]; then
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."
fi
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/"
if [[ "$ROLE" != "desktop" ]]; then
echo " sudo rsync -aAXH --numeric-ids current/var/lib/ /var/lib/"
fi
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
if [[ "$ROLE" != "desktop" ]]; then
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."
fi
} > "$MANIFEST_FILE"
log "Manifest written to $MANIFEST_FILE"
echo "Contents:"
find "$BACKUP_DIR" -mindepth 1 -maxdepth 2 | sort
} > "$BACKUP_DIR/BACKUP_MANIFEST.txt"
log "Manifest written to $BACKUP_DIR/BACKUP_MANIFEST.txt"
# ── 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
if [[ "$ROLE" == "desktop" ]]; then
log "All Finished! Your data is now backed up to a second, external location."
else
log "All Finished! Your data is now backed up to a third location."
fi
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"
@@ -1,526 +0,0 @@
"""Sovran Hub — pure security validation helpers.
This module contains the dependency-light security helper functions used by
the Hub server. Keeping them here allows tests to import and exercise the
exact production implementations rather than maintaining separate copies.
All functions in this module depend only on the Python standard library.
"""
from __future__ import annotations
import base64
import ipaddress
import json
import os
import re
import tempfile
import threading
import time
import urllib.parse
# ── Nix string escaping ────────────────────────────────────────────────────────
def _nix_escape(value: str) -> str:
"""Escape *value* for use inside a Nix double-quoted string literal.
Handles backslashes, double-quotes, newlines, carriage returns, tabs, and
Nix-specific anti-quotation sequences (``${...}``). The returned value is
safe to embed as ``"<returned_value>"`` in generated Nix source.
"""
value = value.replace("\\", "\\\\")
value = value.replace('"', '\\"')
value = value.replace("\n", "\\n")
value = value.replace("\r", "\\r")
value = value.replace("\t", "\\t")
value = value.replace("${", "\\${")
return value
# ── Nostr npub validation (NIP-19 / Bech32) ───────────────────────────────────
# Fast pre-filter: "npub1" followed by exactly 58 lower-case bech32 characters.
NPUB_RE = re.compile(r"^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
_BECH32_GENERATOR = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
def _bech32_polymod(values: list[int]) -> int:
chk = 1
for value in values:
top = chk >> 25
chk = ((chk & 0x1FFFFFF) << 5) ^ value
for i in range(5):
if (top >> i) & 1:
chk ^= _BECH32_GENERATOR[i]
return chk
def _bech32_hrp_expand(hrp: str) -> list[int]:
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]:
values = _bech32_hrp_expand(hrp) + data
polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
return [(polymod >> (5 * (5 - i))) & 31 for i in range(6)]
def _bech32_convertbits_decode(data: list[int]) -> list[int] | None:
"""Convert a 5-bit integer sequence to 8-bit bytes, stripping padding."""
acc = 0
bits = 0
ret: list[int] = []
for value in data:
acc = (acc << 5) | value
bits += 5
while bits >= 8:
bits -= 8
ret.append((acc >> bits) & 0xFF)
if bits >= 5 or ((acc << (8 - bits)) & 0xFF):
return None # invalid padding
return ret
def _bech32_decode(bech: str) -> tuple[str, bytes] | None:
"""Decode a bech32 string. Returns ``(hrp, payload_bytes)`` or ``None``.
Verifies:
- Lowercase-only (mixed case rejected per BIP-173).
- Only valid bech32 charset characters.
- Valid checksum.
- Exactly one separator (``1``).
- Minimum data part length (≥ 8 chars = 6 checksum + ≥ 2 data).
"""
if bech != bech.lower():
return None # mixed case
sep = bech.rfind("1")
if sep < 1 or sep + 7 > len(bech):
return None
hrp = bech[:sep]
data_part = bech[sep + 1:]
if any(c not in _BECH32_CHARSET for c in data_part):
return None
decoded = [_BECH32_CHARSET.index(c) for c in data_part]
if _bech32_polymod(_bech32_hrp_expand(hrp) + decoded) != 1:
return None # bad checksum
converted = _bech32_convertbits_decode(decoded[:-6])
if converted is None:
return None
return hrp, bytes(converted)
def _validate_npub(value: str) -> bool:
"""Return ``True`` iff *value* is a valid NIP-19 Nostr npub.
Checks:
- Lowercase ``npub`` HRP.
- Valid bech32 charset (no uppercase, no invalid chars).
- Valid bech32 checksum.
- Exactly 32 decoded payload bytes (256-bit public key).
- Retains the original regex as a fast pre-filter.
"""
if not NPUB_RE.fullmatch(value):
return False
result = _bech32_decode(value)
if result is None:
return False
hrp, payload = result
return hrp == "npub" and len(payload) == 32
# ── DDNS URL validation ────────────────────────────────────────────────────────
_DDNS_URL_MAX_LEN = 2048
_DDNS_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
# Allowlist: only the official Njal.la provider hostnames are accepted for
# DDNS update URLs. Any other host would allow SSRF against the Hub's
# internal network.
_DDNS_ALLOWED_HOSTNAMES: frozenset[str] = frozenset(["njal.la", "www.njal.la"])
def _validate_ddns_url(url: str) -> str:
"""Validate *url* as a safe DDNS update URL and return it normalised.
Rules:
- Must be a valid URL parseable by urllib.parse.
- Scheme must be ``https`` (case-insensitive).
- No userinfo (credentials must not be embedded in the URL).
- No fragment.
- No control characters.
- Must not exceed ``_DDNS_URL_MAX_LEN`` bytes.
- Hostname must be the exact Njal.la provider hostname (njal.la or www.njal.la).
- Port must be absent or the default HTTPS port 443.
- No percent-encoded null bytes.
Raises ``ValueError`` with a safe (non-secret) message on failure.
"""
if not url:
raise ValueError("DDNS URL must not be empty")
if len(url) > _DDNS_URL_MAX_LEN:
raise ValueError("DDNS URL exceeds maximum length")
if _DDNS_CONTROL_RE.search(url):
raise ValueError("DDNS URL contains control characters")
try:
parsed = urllib.parse.urlparse(url)
except Exception:
raise ValueError("DDNS URL could not be parsed")
if parsed.scheme.lower() != "https":
raise ValueError("DDNS URL must use the https scheme")
if parsed.username or parsed.password:
raise ValueError("DDNS URL must not contain credentials")
if parsed.fragment:
raise ValueError("DDNS URL must not contain a fragment")
if parsed.port is not None and parsed.port != 443:
raise ValueError("DDNS URL must use the default HTTPS port")
hostname = parsed.hostname or ""
if not hostname:
raise ValueError("DDNS URL must contain a hostname")
# Reject raw IP addresses
try:
ipaddress.ip_address(hostname)
raise ValueError("DDNS URL hostname must not be a raw IP address")
except ValueError as exc:
if "raw IP" in str(exc):
raise
# Allowlist: only Njal.la
if hostname.lower() not in _DDNS_ALLOWED_HOSTNAMES:
raise ValueError(
f"DDNS URL hostname is not an allowed Njal.la host "
f"(got {hostname!r})"
)
if "%00" in url.lower():
raise ValueError("DDNS URL must not contain encoded null bytes")
# Reject any remaining $ expressions — after ${IP} substitution there
# must be none. Callers that store ${IP} placeholder URLs must substitute
# before calling this function.
if "$" in url:
raise ValueError("DDNS URL must not contain $ expressions")
# Require the exact /update/ path used by Njal.la
if parsed.path != "/update/":
raise ValueError("DDNS URL path must be exactly /update/")
return url
# ── SSH public-key validation ─────────────────────────────────────────────────
_SSH_PUBKEY_ALGORITHMS = frozenset([
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com",
])
def _validate_ssh_pubkey(key: str) -> str:
"""Validate *key* as a single OpenSSH public key and return it normalised.
Accepts only single-line keys with a supported algorithm, valid base64
payload, and an optional comment. Rejects options, multiple lines,
control characters, and unsupported algorithms.
Raises ``ValueError`` with a safe message on failure.
"""
key = key.strip()
if not key:
raise ValueError("SSH public key must not be empty")
if _DDNS_CONTROL_RE.search(key):
raise ValueError("SSH public key contains control characters")
if "\n" in key or "\r" in key:
raise ValueError("SSH public key must be a single line")
parts = key.split()
if len(parts) < 2:
raise ValueError("SSH public key is malformed")
algo, b64 = parts[0], parts[1]
if algo not in _SSH_PUBKEY_ALGORITHMS:
raise ValueError(f"Unsupported SSH key algorithm: {algo!r}")
try:
decoded = base64.b64decode(b64, validate=True)
except Exception:
raise ValueError("SSH public key payload is not valid base64")
if len(decoded) < 20:
raise ValueError("SSH public key payload is too short")
return key
# ── Login throttling ──────────────────────────────────────────────────────────
#
# Delays applied after each failed login, and the lockout that follows once an
# address has tripped LOGIN_FAIL_MAX inside the window.
#
# LOGIN_FAIL_WINDOW has to be longer than the time it takes to reach
# LOGIN_FAIL_MAX failures under the ramping delay: with a 2s ramp capped at
# LOGIN_FAIL_MAX_DELAY, 10 attempts take about 80 seconds, so a 60 second
# window would silently expire the earliest failures and the counter could
# never reach the limit. 900s (15 minutes) keeps the whole ramp inside it.
LOGIN_FAIL_DELAY = 2.0 # base delay; the nth failure waits n x this
LOGIN_FAIL_MAX_DELAY = 10.0 # ceiling for a single delay
LOGIN_FAIL_WINDOW = 900.0 # rolling window failures are counted in
LOGIN_FAIL_MAX = 10 # failures in the window that trigger a lockout
LOGIN_LOCKOUT_SECONDS = 300.0 # how long the lockout lasts
# Cap on how many addresses are tracked, so a distributed sweep cannot grow
# the table without bound.
_LOGIN_THROTTLE_MAX_IPS = 4096
class LoginThrottle:
"""Per-address failed-login tracking with a ramping delay and a lockout.
The delay ramps so a script hammering the login form slows down as it goes,
and once LOGIN_FAIL_MAX failures land inside the window the address is
refused outright for LOGIN_LOCKOUT_SECONDS. A successful login clears the
address so a legitimate user who fumbles a password is not penalised later.
``sleep`` and ``clock`` are injectable so tests run without waiting.
"""
def __init__(
self,
fail_delay=LOGIN_FAIL_DELAY,
max_delay=LOGIN_FAIL_MAX_DELAY,
window=LOGIN_FAIL_WINDOW,
max_failures=LOGIN_FAIL_MAX,
lockout=LOGIN_LOCKOUT_SECONDS,
max_tracked_ips=_LOGIN_THROTTLE_MAX_IPS,
sleep=None,
clock=None,
):
self._fail_delay = float(fail_delay)
self._max_delay = float(max_delay)
self._window = float(window)
self._max_failures = int(max_failures)
self._lockout = float(lockout)
self._max_tracked_ips = int(max_tracked_ips)
self._sleep = sleep if sleep is not None else time.sleep
self._clock = clock if clock is not None else time.monotonic
self._lock = threading.Lock()
self._failures: dict[str, list[float]] = {}
# ── internals ────────────────────────────────────────────────────────────
def _prune(self, ip, now):
"""Drop timestamps outside the window; return what is left."""
keep = [t for t in self._failures.get(ip, ()) if now - t < self._window]
if keep:
self._failures[ip] = keep
else:
self._failures.pop(ip, None)
return keep
def _evict(self, now):
"""Forget addresses that can no longer affect anything."""
horizon = max(self._window, self._lockout)
for ip in [i for i, ts in self._failures.items()
if ts and now - max(ts) > horizon]:
self._failures.pop(ip, None)
while len(self._failures) > self._max_tracked_ips:
oldest = min(self._failures, key=lambda i: max(self._failures[i]))
self._failures.pop(oldest, None)
# ── public API ───────────────────────────────────────────────────────────
def delay_for(self, count):
"""Return the delay owed after *count* failures in the current window."""
if count <= 0:
return 0.0
return min(self._fail_delay * count, self._max_delay)
def failure_count(self, ip):
"""Return the failures currently counted against *ip*."""
with self._lock:
return len(self._prune(ip, self._clock()))
def is_locked_out(self, ip):
"""Return True while *ip* is inside a lockout."""
now = self._clock()
with self._lock:
failures = self._prune(ip, now)
if len(failures) < self._max_failures:
return False
return (now - failures[-1]) < self._lockout
def remaining_lockout(self, ip):
"""Return the seconds left in *ip*'s lockout, or 0.0 if not locked out."""
now = self._clock()
with self._lock:
failures = self._prune(ip, now)
if len(failures) < self._max_failures:
return 0.0
return max(0.0, self._lockout - (now - failures[-1]))
def record_failure(self, ip):
"""Record a failure for *ip* and serve out the delay it has earned.
Returns the delay that was applied. The lock is never held across the
sleep, so one slow client cannot stall every other login.
"""
now = self._clock()
with self._lock:
failures = list(self._prune(ip, now))
failures.append(now)
self._failures[ip] = failures
count = len(failures)
self._evict(now)
delay = self.delay_for(count)
if delay > 0:
self._sleep(delay)
return delay
def clear(self, ip):
"""Forget *ip*, e.g. after a successful login."""
with self._lock:
self._failures.pop(ip, None)
def tracked_addresses(self):
"""Return how many addresses are currently being tracked."""
with self._lock:
return len(self._failures)
# ── Local-network client policy ───────────────────────────────────────────────
#
# The Hub runs as root: it can display stored credentials, reboot the machine
# and rebuild the system. It answers this computer and the local network and
# nobody else. Whether a packet may reach its port is the firewall's and the
# router's business; this is the second lock, applied by the application itself
# so that a port forward, a firewall mistake, or a machine that has a public
# address does not put the login page in front of the internet.
#
# The Hub listens on IPv4 only (see sovran-hub.nix), so IPv6 clients never
# reach it directly and the IPv6 ranges below only matter if that bind is ever
# widened. Global IPv6 addresses (2000::/3) are deliberately not listed: a
# global address belonging to a laptop on the LAN cannot be told apart from a
# stranger's by the address alone, and allowing the range would let the whole
# IPv6 internet through.
LAN_ONLY_IPV4 = (
"127.0.0.0/8", # this computer
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"100.64.0.0/10", # Tailscale and other VPN/CGNAT ranges
"169.254.0.0/16", # link-local
)
LAN_ONLY_IPV6 = (
"::1/128",
"fc00::/7", # unique-local (covers fd00::/8)
"fe80::/10", # link-local
)
class LanPolicy:
"""Decides whether a client address counts as local.
``extra_networks`` are CIDR blocks an operator has declared local in
addition to the built-in ranges, of either address family. ``enabled=False``
turns the check off entirely; it is the one explicit way to do that.
"""
def __init__(self, extra_networks=(), enabled=True):
self.enabled = bool(enabled)
nets = [ipaddress.ip_network(c, strict=False)
for c in LAN_ONLY_IPV4 + LAN_ONLY_IPV6]
for cidr in (extra_networks or ()):
try:
net = ipaddress.ip_network(cidr, strict=False)
except ValueError:
# A malformed entry must never widen the policy. Ignore it and
# stay at the strictest interpretation.
continue
if net.prefixlen == 0:
# 0.0.0.0/0 and ::/0 are "everyone". That is lan_only = false,
# and it should be asked for by name, not arrive as a "network".
continue
nets.append(net)
self._nets = tuple(nets)
def allows(self, ip):
"""Return True if *ip* may reach the service."""
if not self.enabled:
return True
if not ip:
return False
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return False
# A dual-stack socket reports IPv4 clients as ::ffff:a.b.c.d. The
# address that matters is the IPv4 one inside it.
if addr.version == 6 and addr.ipv4_mapped is not None:
addr = addr.ipv4_mapped
return any(addr.version == n.version and addr in n for n in self._nets)
@property
def networks(self):
return self._nets
# ── Persistent Hub session store ─────────────────────────────────────────────
def load_session_store(path: str) -> dict[str, float]:
"""Load persisted Hub sessions from *path*.
Returns a mapping of session token → expiry timestamp (epoch seconds).
Expired entries are discarded. A missing, unreadable or malformed file
yields an empty mapping — losing sessions is a UX inconvenience (the user
must log in again), never a fatal error.
Persistence exists so that authenticated sessions survive a restart of
the Hub service itself. ``nixos-rebuild switch`` restarts
``sovran-hub-web.service`` during activation (its unit definition changes
with every feature toggle), and without persistence the in-progress
rebuild/update status polling loses authentication and the UI hangs.
"""
try:
with open(path, "r") as f:
data = json.load(f)
except (OSError, ValueError):
return {}
if not isinstance(data, dict):
return {}
now = time.time()
sessions: dict[str, float] = {}
for token, expiry in data.items():
if not isinstance(token, str) or not token:
continue
if isinstance(expiry, bool) or not isinstance(expiry, (int, float)):
continue
if expiry > now:
sessions[token] = float(expiry)
return sessions
def save_session_store(path: str, sessions: dict[str, float]) -> bool:
"""Atomically persist *sessions* (token → expiry) to *path* with mode 0600.
Writes to a temp file in the same directory and renames it into place so
the store is never left partially written. Returns True on success,
False otherwise (persistence is best-effort).
"""
directory = os.path.dirname(path) or "."
fd = None
tmp_path = None
try:
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".hub_sessions_tmp")
with os.fdopen(fd, "w") as f:
fd = None # os.fdopen takes ownership of the descriptor
json.dump(sessions, f)
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, path)
return True
except OSError:
if fd is not None:
try:
os.close(fd)
except OSError:
pass
if tmp_path is not None:
try:
os.unlink(tmp_path)
except OSError:
pass
return False
File diff suppressed because it is too large. Load diff
+61 -117
View File
@@ -1,11 +1,6 @@
/* Sovran_SystemsOS Hub — Web UI Stylesheet
The Hub redesign — softer dark theme, GNOME 50 / libadwaita surfaces,
Sovran green reserved for status and actions.
Design tokens are defined once here. Legacy variable names from the
previous theme are aliased to the new values so every stylesheet
(support, security, domain-setup, onboarding, …) re-skins without
needing per-rule edits. */
Dark theme — near-black with green accents matching the Sovran Hub icon
v8 — Black-forward, green used for accents/borders/highlights only */
*, *::before, *::after {
box-sizing: border-box;
@@ -14,48 +9,22 @@
}
:root {
color-scheme: dark;
/* ── The Hub palette ─────────────────────────────────────────── */
--bg: #17191d;
--surface: #1c1f24;
--card: #23272c;
--card-hover: #292e34;
--elevated: #26292e;
--inset: #121417;
--border: rgba(255, 255, 255, 0.07);
--border-strong: rgba(255, 255, 255, 0.14);
--text: #e9edec;
--text-2: #a9b0b3;
--text-3: #7a8388;
--accent: #3ecf8e;
--accent-strong: #42f39a;
--accent-deep: #1aa45d;
--accent-dim: rgba(66, 243, 154, 0.12);
--amber: #e9b64a;
--red: #f66151;
--blue: #78aeed;
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--radius-card: 20px;
--radius-dialog: 26px;
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.25);
--shadow-hover: 0 16px 36px rgba(0, 0, 0, 0.38);
--shadow-pop: 0 30px 80px rgba(0, 0, 0, 0.5);
/* ── Legacy aliases (previous theme) — keep every old sheet working */
--bg-color: var(--bg);
--surface-color: var(--surface);
--card-color: var(--card);
--border-color: var(--border);
--text-primary: var(--text);
--text-secondary: var(--text-2);
--text-dim: var(--text-3);
--accent-color: var(--accent);
--green: var(--accent);
--yellow: var(--amber);
--red: var(--red);
--grey: var(--text-3);
--radius-btn: 12px;
--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: #5E7A6A;
--radius-card: 18px;
--radius-btn: 8px;
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.5);
}
html, body {
@@ -63,27 +32,19 @@ html, body {
}
body {
font-family: Inter, Cantarell, 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 15px;
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;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
button { font-family: inherit; }
::selection { background: rgba(66, 243, 154, 0.25); }
/* ── Scrollbars ─────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.10); border-radius: 99px; border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.18); background-clip: padding-box; }
::-webkit-scrollbar-track { background: transparent; }
/* ── Login page ─────────────────────────────────────────────────── */
/* ── Login page ──────────────────────────────────────────────────── */
.login-wrapper {
display: flex;
@@ -91,103 +52,86 @@ button { font-family: inherit; }
justify-content: center;
min-height: 100vh;
padding: 24px;
position: relative;
z-index: 1;
}
.login-card {
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%;
max-width: 402px;
text-align: center;
background: var(--elevated);
border: 1px solid var(--border-strong);
border-radius: 28px;
padding: 46px 42px 38px;
box-shadow: var(--shadow-pop);
max-width: 400px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
.login-header {
text-align: center;
margin-bottom: 26px;
margin-bottom: 32px;
}
.login-logo {
width: 78px;
height: 78px;
margin-bottom: 18px;
filter: drop-shadow(0 8px 24px rgba(28, 196, 120, 0.3));
height: 64px;
margin-bottom: 16px;
}
.login-title {
font-size: 1.3rem;
font-weight: 800;
letter-spacing: -0.01em;
color: var(--text);
}
.login-title em { font-style: normal; color: var(--accent); }
.login-sub {
color: var(--text-2);
font-size: 0.85rem;
margin: 7px 0 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
}
.login-form {
display: flex;
flex-direction: column;
gap: 14px;
gap: 16px;
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 650;
color: var(--text-2);
font-size: 0.82rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 6px;
text-align: left;
}
.form-group input {
width: 100%;
padding: 11px 16px;
border: 1px solid var(--border-strong);
border-radius: 14px;
background: var(--inset);
color: var(--text);
font: inherit;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
background-color: var(--card-color);
color: var(--text-primary);
font-size: 0.92rem;
outline: 0;
transition: border-color 0.15s, box-shadow 0.15s;
}
.form-group input:focus {
border-color: rgba(66, 243, 154, 0.55);
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.13);
outline: none;
border-color: var(--accent-color);
}
.btn-login {
width: 100%;
padding: 12px;
border-radius: 99px;
background: linear-gradient(180deg, #3fd68e, #1ea263);
color: #04220f;
font-size: 0.92rem;
border-radius: var(--radius-btn);
background-color: var(--accent-color);
color: #0A1A10;
font-size: 0.95rem;
font-weight: 700;
margin-top: 8px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
}
.btn-login:hover:not(:disabled) {
filter: brightness(1.08);
.btn-login:hover {
opacity: 0.88;
}
.login-error {
background: rgba(246, 97, 81, 0.10);
border: 1px solid rgba(246, 97, 81, 0.35);
color: #f8a39b;
background-color: rgba(224, 27, 36, 0.12);
border: 1px solid var(--red);
color: #f87171;
padding: 10px 14px;
border-radius: 12px;
border-radius: 8px;
font-size: 0.85rem;
display: none;
}
+24 -124
View File
@@ -5,134 +5,34 @@ button {
cursor: pointer;
border: none;
outline: none;
transition: background 0.16s, border-color 0.16s, color 0.16s, transform 0.16s, filter 0.16s, opacity 0.15s;
transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s;
}
button:disabled {
opacity: 0.55;
opacity: 0.45;
cursor: default;
pointer-events: none;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 99px;
padding: 8px 18px;
font-size: 0.84rem;
font-weight: 700;
letter-spacing: 0.01em;
cursor: pointer;
border: 1px solid transparent;
white-space: nowrap;
padding: 7px 16px;
border-radius: var(--radius-btn);
font-size: 0.88rem;
font-weight: 600;
}
.btn svg { width: 15px; height: 15px; }
.btn:active { transform: scale(0.97); }
.btn-primary {
background: linear-gradient(180deg, #3fd68e, #1ea263);
color: #04220f;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
background-color: var(--accent-color);
color: #0A1A10;
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
transform: translateY(-1px);
opacity: 0.88;
}
.btn-primary:active:not(:disabled) { transform: scale(0.97); }
/* Ghost — the default secondary action (legacy: btn-close-modal / btn-save) */
.btn-ghost,
.btn-close-modal,
.btn-save {
background: transparent;
border-color: var(--border-strong);
color: var(--text-2);
}
.btn-ghost:hover:not(:disabled),
.btn-close-modal:hover:not(:disabled),
.btn-save:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.04);
}
/* Blue — restart / retry actions (amber reads as an error) */
.btn-amber,
.btn-reboot,
.btn-restart-amber,
.btn-warning {
background: rgba(120, 174, 237, 0.10);
border-color: rgba(120, 174, 237, 0.35);
color: #a5cbf2;
}
.btn-amber:hover:not(:disabled),
.btn-reboot:hover:not(:disabled),
.btn-restart-amber:hover:not(:disabled),
.btn-warning:hover:not(:disabled) {
background: rgba(120, 174, 237, 0.18);
border-color: rgba(120, 174, 237, 0.55);
color: #c2dcf8;
}
.btn-danger {
background: rgba(246, 97, 81, 0.10);
border-color: rgba(246, 97, 81, 0.35);
color: #f8a39b;
}
.btn-danger:hover:not(:disabled) {
background: rgba(246, 97, 81, 0.18);
border-color: rgba(246, 97, 81, 0.5);
}
.btn-sm { padding: 6px 14px; font-size: 0.77rem; }
/* ── Header / topbar buttons ────────────────────────────────────── */
.btn-header-reboot {
background: rgba(120, 174, 237, 0.10);
border: 1px solid rgba(120, 174, 237, 0.35);
color: #a5cbf2;
font-size: 0.77rem;
font-weight: 700;
padding: 6px 14px;
border-radius: 99px;
}
.btn-header-reboot:hover:not(:disabled) {
background: rgba(120, 174, 237, 0.18);
border-color: rgba(120, 174, 237, 0.55);
}
.btn-logout {
background: transparent;
border: 1px solid var(--border-strong);
color: var(--text-2);
font-size: 0.77rem;
font-weight: 700;
padding: 6px 14px;
border-radius: 99px;
}
.btn-logout:hover {
color: var(--text);
border-color: rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.04);
}
/* ── Update System button (sidebar) ─────────────────────────────── */
/* Update System button: uses accent green by default */
.btn-update {
background: transparent;
border: 1px solid var(--border-strong);
color: var(--text-2);
background-color: #4A9474;
color: #E0F2EA;
position: relative;
display: flex;
align-items: center;
@@ -140,22 +40,24 @@ button:disabled {
}
.btn-update:hover:not(:disabled) {
color: var(--text);
border-color: rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.04);
opacity: 0.88;
}
/* Update System button: brighter green when updates are available */
.btn-update.has-updates {
background: rgba(66, 243, 154, 0.12);
border-color: rgba(66, 243, 154, 0.4);
color: var(--accent);
background-color: #5EAD8A;
color: #0A1A10;
}
.btn-update.has-updates:hover:not(:disabled) {
background-color: #78C8A2;
}
.update-badge {
display: none;
width: 10px;
height: 10px;
background-color: var(--amber);
background-color: var(--yellow);
border-radius: 50%;
animation: pulse-badge 1.4s ease-in-out infinite;
}
@@ -169,11 +71,9 @@ button:disabled {
50% { opacity: 0.5; transform: scale(1.35); }
}
/* ── Icon buttons ───────────────────────────────────────────────── */
.btn-icon {
background: none;
color: var(--text-2);
color: var(--text-secondary);
padding: 6px;
border-radius: 50%;
font-size: 1.1rem;
@@ -181,6 +81,6 @@ button:disabled {
}
.btn-icon:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.06);
color: var(--text);
background-color: var(--border-color);
color: var(--text-primary);
}
@@ -18,7 +18,7 @@ domain-field-label {
domain-field-input {
width: 100%;
background-color: var(--inset);
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: var(--inset);
background-color: #0c0f0e;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
@@ -171,67 +171,3 @@ domain-field-actions {
.port-req-status {
font-weight: 600;
}
/* ── Port-forwarding instructions ─────────────────────────────────── */
.port-req-steps {
margin: 4px 0 12px;
padding-left: 20px;
font-size: 0.84rem;
color: var(--text-secondary);
line-height: 1.65;
}
.port-req-steps li {
margin-bottom: 5px;
}
.port-proto-badge {
display: inline-block;
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
padding: 2px 7px;
border-radius: 5px;
white-space: nowrap;
}
.port-proto-badge--tcp {
color: var(--accent);
background: rgba(66, 243, 154, 0.12);
border: 1px solid rgba(66, 243, 154, 0.4);
}
.port-proto-badge--udp {
color: var(--blue);
background: rgba(106, 169, 224, 0.12);
border: 1px solid rgba(106, 169, 224, 0.4);
}
.port-proto-badge--both {
color: var(--amber);
background: rgba(233, 182, 74, 0.12);
border: 1px solid rgba(233, 182, 74, 0.45);
}
.port-proto-note {
display: block;
margin-top: 3px;
font-size: 0.68rem;
color: var(--text-dim);
text-transform: none;
letter-spacing: 0;
}
/* ── Wallet Connections unique-hostname warning ───────────────────── */
.domain-nwc-warning {
margin-bottom: 14px;
padding: 10px 14px;
background: rgba(255, 180, 0, 0.10);
border: 1px solid var(--warning-color, var(--amber));
border-radius: 8px;
font-size: 0.88rem;
line-height: 1.6;
}
+87 -140
View File
@@ -1,196 +1,143 @@
/* ── Sidebar: Feature Manager + Preferences ─────────────────────── */
/* ── Feature Manager styles ──────────────────────────────────────── */
.category-section.autolaunch-section,
.category-section.feature-manager-section {
margin-bottom: 0;
.feature-manager-section {
margin-bottom: 32px;
}
/* The old "Preferences" header + hr becomes a small sidebar label.
features.js renders: .section-header + hr.section-divider + cards */
.autolaunch-section .section-header,
.feature-manager-section .section-header {
font-size: 0.66rem;
font-weight: 750;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
margin: 0;
padding: 14px 10px 7px;
display: block;
.feature-subcategory {
margin-bottom: 16px;
}
.autolaunch-section .section-divider,
.feature-manager-section .section-divider {
background: none;
height: 0;
margin: 0;
}
.feature-cards-wrap { display: flex; flex-direction: column; }
.feature-subcategory { display: flex; flex-direction: column; }
.feature-subcategory-header {
font-size: 0.66rem;
font-weight: 750;
letter-spacing: 0.14em;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
color: var(--text-3);
padding: 12px 10px 6px;
letter-spacing: 0.06em;
color: var(--text-dim);
margin-bottom: 8px;
padding-left: 4px;
}
/* Feature card — sidebar preference row with a switch */
.feature-card {
.feature-cards-wrap {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
padding: 9px 10px;
border-radius: 10px;
transition: background 0.15s;
}
.feature-card:hover { background: rgba(255, 255, 255, 0.05); }
.feature-card {
background-color: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 14px 16px;
}
.feature-card-top {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
gap: 12px;
margin-bottom: 8px;
}
.feature-card-info { min-width: 0; flex: 1; }
.feature-card-info {
flex: 1;
min-width: 0;
}
.feature-card-name {
font-size: 0.84rem;
font-weight: 600;
color: var(--text);
line-height: 1.3;
font-size: 0.9rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.feature-card-desc {
margin-top: 2px;
font-size: 0.68rem;
color: var(--text-3);
line-height: 1.45;
}
.feature-card-status {
font-size: 0.66rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 99px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
color: var(--text-2);
}
.feature-card-status.status-on {
color: var(--accent);
background: var(--accent-dim);
border-color: rgba(66, 243, 154, 0.3);
}
.feature-conflict-warning {
margin-top: 8px;
font-size: 0.68rem;
color: var(--amber);
font-size: 0.78rem;
color: var(--text-secondary);
line-height: 1.5;
}
/* Domain badges inside feature cards */
.feature-domain-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.64rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 99px;
.feature-card-status {
font-size: 0.72rem;
color: var(--text-dim);
margin-top: 6px;
}
.feature-domain-badge.configured {
color: var(--accent);
background: var(--accent-dim);
}
.feature-domain-badge.not-configured {
color: var(--amber);
background: rgba(233, 182, 74, 0.10);
}
.feature-domain-icon { font-size: 0.8rem; }
.feature-domain-label {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.66rem;
font-weight: 650;
margin-top: 5px;
color: var(--text-3);
}
.feature-domain-label--ok { color: var(--accent); }
.feature-domain-label--warn { color: var(--amber); }
.feature-domain-label--error { color: var(--red); }
.feature-domain-label--checking { color: var(--blue); }
/* ── Toggle switch (libadwaita style) ───────────────────────────── */
.feature-toggle {
position: relative;
display: inline-block;
width: 46px;
height: 26px;
width: 44px;
height: 24px;
flex-shrink: 0;
margin-left: auto;
margin-top: 2px;
cursor: pointer;
}
.feature-toggle-input {
position: absolute;
inset: 0;
opacity: 0;
margin: 0;
cursor: pointer;
width: 0;
height: 0;
position: absolute;
}
.feature-toggle-slider {
position: absolute;
inset: 0;
border-radius: 99px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid var(--border-strong);
transition: 0.2s;
pointer-events: none;
background-color: var(--border-color);
border-radius: 24px;
transition: background-color 0.2s;
}
.feature-toggle-slider::after {
.feature-toggle-slider::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
width: 18px;
height: 18px;
left: 3px;
top: 3px;
background-color: #fff;
border-radius: 50%;
background: #cfd8d2;
transition: 0.2s cubic-bezier(0.3, 0.8, 0.4, 1.2);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
transition: transform 0.2s;
}
.feature-toggle-input:checked + .feature-toggle-slider {
background: var(--accent);
border-color: transparent;
.feature-toggle.active .feature-toggle-slider {
background-color: var(--green);
}
.feature-toggle-input:checked + .feature-toggle-slider::after {
left: 23px;
background: #fff;
.feature-toggle.active .feature-toggle-slider::before {
transform: translateX(20px);
}
.feature-toggle-input:focus-visible + .feature-toggle-slider {
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.3);
.feature-domain-badge {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
font-size: 0.78rem;
}
.feature-domain-icon {
flex-shrink: 0;
}
.feature-domain-label {
color: var(--text-secondary);
}
.feature-domain-label--checking {
color: var(--text-dim);
font-style: italic;
}
.feature-domain-label--ok {
color: var(--green);
font-weight: 600;
}
.feature-domain-label--warn {
color: var(--yellow);
font-weight: 600;
}
.feature-domain-label--error {
color: var(--red);
font-weight: 600;
}
+69 -83
View File
@@ -1,107 +1,93 @@
/* ── Topbar (replaces the old header-bar + ip-bar) ──────────────── */
/* ── Header bar ─────────────────────────────────────────────────── */
.topbar {
.header-bar {
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;
align-items: center;
gap: 14px;
padding: 13px 26px;
background: var(--surface);
border-bottom: 1px solid var(--border);
justify-content: space-between;
gap: 16px;
position: sticky;
top: 0;
z-index: 100;
}
.header-logo {
height: 80px;
width: auto;
display: block;
flex-shrink: 0;
position: relative;
z-index: 5;
}
.page-title {
font-size: 1.02rem;
font-weight: 800;
letter-spacing: -0.01em;
white-space: nowrap;
.header-bar .title {
font-size: 1.15rem;
font-weight: 700;
color: var(--text-primary);
}
/* ── Search ─────────────────────────────────────────────────────── */
.search-box {
.header-buttons {
display: flex;
align-items: center;
gap: 9px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 99px;
padding: 8px 16px;
flex: 1;
max-width: 400px;
min-width: 140px;
margin-left: 18px;
color: var(--text-3);
transition: border-color 0.15s, box-shadow 0.15s;
gap: 10px;
}
.search-box:focus-within {
border-color: rgba(66, 243, 154, 0.35);
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.09);
}
.search-box svg { width: 15px; height: 15px; flex-shrink: 0; }
.search-box input {
background: none;
border: 0;
outline: 0;
color: var(--text);
width: 100%;
font: inherit;
font-size: 0.87rem;
}
.search-box input::placeholder { color: var(--text-3); }
.top-actions {
display: flex;
gap: 9px;
margin-left: auto;
order: 5;
}
/* ── Legacy badges (still used inside dialogs) ──────────────────── */
.role-badge {
display: inline-flex;
background-color: var(--accent-color);
color: #0A1A10;
font-size: 0.72rem;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
letter-spacing: 0.03em;
}
/* ── IP bar ─────────────────────────────────────────────────────── */
.ip-bar {
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;
justify-content: center;
gap: 7px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-strong);
color: var(--text-2);
font-size: 0.72rem;
font-weight: 750;
padding: 6px 12px;
border-radius: 99px;
letter-spacing: 0.02em;
gap: 32px;
font-size: 0.82rem;
color: var(--text-secondary);
}
.os-version-badge {
background: rgba(255, 255, 255, 0.05);
color: var(--text-2);
font-size: 0.66rem;
.ip-bar .ip-label {
color: var(--text-dim);
margin-right: 6px;
}
.ip-bar .ip-value {
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
color: var(--accent-color);
font-weight: 600;
padding: 2px 9px;
border-radius: 99px;
border: 1px solid var(--border);
letter-spacing: 0.02em;
display: inline-flex;
align-items: center;
white-space: nowrap;
flex-shrink: 0;
}
@media (max-width: 480px) {
.btn-header-reboot {
padding: 5px 10px;
font-size: 0.72rem;
.ip-separator {
color: var(--border-color);
}
.btn-logout {
padding: 5px 10px;
font-size: 0.72rem;
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);
}
+80 -573
View File
@@ -1,533 +1,138 @@
/* ── App shell: sidebar + main column ───────────────────────────── */
/* ── Main content ───────────────────────────────────────────────── */
.app {
.main-content {
display: flex;
height: 100vh;
position: relative;
z-index: 1;
}
.main {
align-items: flex-start;
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.content {
flex: 1;
overflow-y: auto;
padding: 26px 30px 70px;
min-width: 0;
overflow: hidden;
max-width: 1400px;
width: 100%;
margin-left: auto;
margin-right: auto;
}
/* ── Sidebar ────────────────────────────────────────────────────── */
.sidebar {
width: 250px;
width: 270px;
flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border);
height: 100%;
overflow-y: auto;
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;
padding: 20px 14px 14px;
overflow-y: auto;
gap: 0;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding: 2px 8px 20px;
}
/* ── Sidebar: Tech Support button ───────────────────────────────── */
.brand-logo {
width: 42px;
height: 42px;
flex-shrink: 0;
filter: drop-shadow(0 4px 14px rgba(28, 196, 120, 0.25));
}
.brand-text { display: flex; flex-direction: column; min-width: 0; }
.brand-title {
font-size: 1.02rem;
font-weight: 800;
letter-spacing: -0.01em;
line-height: 1.2;
}
.brand-title em { font-style: normal; color: var(--accent); }
.brand-sub {
font-size: 0.7rem;
color: var(--text-3);
font-family: var(--mono);
margin-top: 3px;
}
.nav-label {
font-size: 0.66rem;
font-weight: 750;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-3);
padding: 14px 10px 7px;
}
/* Category nav (dashboard.js) */
.nav-item {
display: flex;
align-items: center;
gap: 11px;
width: 100%;
padding: 8px 10px;
border-radius: 10px;
border: 0;
background: none;
color: var(--text-2);
font-size: 0.87rem;
font-weight: 600;
cursor: pointer;
text-align: left;
transition: background 0.15s, color 0.15s;
}
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; }
.nav-item:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text);
}
.nav-item.active {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
box-shadow: inset 2.5px 0 0 var(--accent);
}
.nav-text {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-count {
margin-left: auto;
font-size: 0.67rem;
font-weight: 750;
flex-shrink: 0;
color: var(--text-3);
background: rgba(255, 255, 255, 0.05);
padding: 2px 8px;
border-radius: 99px;
}
.nav-item.active .nav-count {
background: rgba(255, 255, 255, 0.10);
color: var(--text-2);
}
/* System items (tiles.js renders these as .sidebar-support-btn) */
.sidebar-support-btn {
display: flex;
align-items: center;
gap: 11px;
gap: 10px;
width: 100%;
padding: 8px 10px;
border-radius: 10px;
border: 0;
background: none;
color: var(--text-2);
font-size: 0.87rem;
font-weight: 600;
background-color: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 12px 14px;
color: var(--text-primary);
cursor: pointer;
transition: border-style 0.15s, border-color 0.15s, background-color 0.15s;
text-align: left;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.sidebar-support-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text);
border-color: var(--accent-color);
border-style: solid;
background-color: #162320;
}
.sidebar-support-btn + .sidebar-support-btn {
margin-top: 2px;
margin-top: 8px;
}
.sidebar-support-icon {
font-size: 1rem;
width: 17px;
height: 17px;
display: grid;
place-items: center;
font-size: 1.5rem;
flex-shrink: 0;
}
.sidebar-support-icon svg { width: 17px; height: 17px; }
.sidebar-support-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
gap: 2px;
}
.sidebar-support-title {
font-size: 0.87rem;
font-weight: 600;
line-height: 1.3;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.88rem;
font-weight: 700;
color: var(--text-primary);
}
.sidebar-support-hint {
font-size: 0.68rem;
font-weight: 650;
color: var(--text-3);
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.72rem;
color: var(--accent-color);
font-weight: 600;
}
.sidebar-divider {
border: none;
border-top: 1px solid var(--border);
margin: 14px 0 0;
border-top: 1px solid var(--border-color);
margin: 16px 0;
}
.sidebar-spacer { flex: 1; min-height: 18px; }
/* ── Upgrade modal ──────────────────────────────────────────────── */
/* ── Welcome dashboard (default view) ───────────────────────────── */
.welcome {
position: relative;
/* Full-bleed: cancel .content's padding so the glow reaches
every edge of the panel (sidebar border, topbar, viewport). */
margin: -26px -30px -70px;
padding: 70px 38px 70px;
overflow: hidden;
min-height: calc(100% + 96px);
.upgrade-dialog {
max-width: 480px;
}
/* Soft drifting glow — calm, GPU-cheap (transforms only) */
.welcome-bg {
position: absolute;
inset: 0;
pointer-events: none;
.upgrade-info-box {
background-color: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 14px;
}
.orb {
position: absolute;
border-radius: 50%;
will-change: transform;
}
.orb-a {
width: 560px; height: 560px;
left: -140px; top: -180px;
background: radial-gradient(circle, rgba(62, 207, 142, 0.10), transparent 70%);
animation: orb-drift-a 26s ease-in-out infinite alternate;
}
.orb-b {
width: 680px; height: 680px;
right: -200px; top: 20%;
background: radial-gradient(circle, rgba(120, 174, 237, 0.07), transparent 70%);
animation: orb-drift-b 34s ease-in-out infinite alternate;
}
.orb-c {
width: 460px; height: 460px;
left: 34%; bottom: -200px;
background: radial-gradient(circle, rgba(66, 243, 154, 0.06), transparent 70%);
animation: orb-drift-c 42s ease-in-out infinite alternate;
}
@keyframes orb-drift-a { to { transform: translate(70px, 50px) scale(1.14); } }
@keyframes orb-drift-b { to { transform: translate(-60px, -70px) scale(1.10); } }
@keyframes orb-drift-c { to { transform: translate(50px, -40px) scale(1.18); } }
@media (prefers-reduced-motion: reduce) {
.orb { animation: none; }
.welcome-inner { animation: none; }
}
/* ── Boot splash ────────────────────────────────────────────────── */
/* Covers the shell while the first services/config data loads; the
inline script in index.html lifts it (window.__liftAppSplash). */
#app-splash {
position: fixed;
inset: 0;
z-index: 2000;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.45s ease;
}
#app-splash.done { opacity: 0; pointer-events: none; }
.splash-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 24px;
}
.splash-ring {
position: relative;
width: 96px;
height: 96px;
display: grid;
place-items: center;
.upgrade-info-title {
font-size: 0.88rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.splash-ring img { width: 60px; height: 60px; opacity: 0.95; }
.splash-ring-arc {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.10);
border-top-color: var(--accent);
animation: splash-spin 1s linear infinite;
.upgrade-info-list {
padding-left: 20px;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.7;
margin: 0;
}
@keyframes splash-spin { to { transform: rotate(360deg); } }
.splash-title {
font-size: 1.02rem;
font-weight: 700;
color: var(--text);
.upgrade-info-list a {
color: var(--accent-color);
}
.splash-sub {
font-size: 0.84rem;
color: var(--text-3);
.upgrade-rebuild-note {
font-style: italic;
color: var(--text-dim);
font-size: 0.82rem;
}
@media (prefers-reduced-motion: reduce) {
.splash-ring-arc { animation-duration: 2.5s; }
}
.welcome-inner {
position: relative;
max-width: 1040px;
margin: 0 auto;
animation: welcome-in 0.5s ease both;
}
@keyframes welcome-in {
from { opacity: 0; transform: translateY(14px); }
}
.welcome-greeting {
font-size: 0.98rem;
font-weight: 650;
color: var(--text-2);
}
.welcome-title {
margin-top: 6px;
font-size: clamp(1.7rem, 2.8vw, 2.25rem);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.18;
}
.welcome-title em { font-style: normal; color: var(--accent); }
.welcome-meta {
margin-top: 12px;
font-size: 0.8rem;
color: var(--text-3);
font-family: var(--mono);
}
.welcome-meta-sep { margin: 0 8px; opacity: 0.6; }
.welcome-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 1fr; /* both rows share the taller height → uniform cards */
gap: 16px;
margin-top: 34px;
}
/* Three cards (Desktop-only role, no Bitcoin card) → one symmetric row */
@media (min-width: 681px) {
.welcome-cards:not(:has(#w-btc)) {
grid-template-columns: repeat(3, 1fr);
}
}
/* wrappers whose children become grid items */
.welcome-dyn { display: contents; }
.w-network .net-row {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 6px;
font-family: var(--mono);
}
.w-network .net-k {
font-size: 0.6rem;
font-weight: 800;
letter-spacing: 0.08em;
color: var(--text-3);
width: 30px;
flex-shrink: 0;
}
.w-network .ip-value {
font-size: 0.8rem;
font-weight: 600;
color: var(--text);
}
.w-network .sub { margin-top: 8px; }
.welcome-browse {
margin-top: 26px;
}
.widget {
background: var(--card);
border: 1px solid var(--border);
border-radius: 20px;
padding: 17px 19px;
display: flex;
gap: 14px;
align-items: center;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03), var(--shadow-card);
transition: border-color 0.18s;
position: relative;
overflow: hidden;
min-width: 0;
min-height: 88px; /* uniform card height everywhere */
}
.widget:hover { border-color: var(--border-strong); }
.widget.clickable:hover { transform: translateY(-1px); }
.widget.clickable { cursor: pointer; }
.widget.clickable:focus-visible {
outline: 2px solid rgba(66, 243, 154, 0.45);
outline-offset: 2px;
}
.widget-chip {
width: 46px;
height: 46px;
border-radius: 14px;
flex-shrink: 0;
display: grid;
place-items: center;
color: #fff;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 6px 16px rgba(0, 0, 0, 0.3);
}
.widget-chip svg { width: 22px; height: 22px; }
.widget-chip img { width: 46px; height: 46px; display: block; object-fit: contain; }
.widget-chip.chip-green {
background: linear-gradient(160deg, #2f9f6b, #17683f);
}
.widget-chip.chip-btc {
background: linear-gradient(160deg, #f7931a, #b96a0a);
}
.widget-chip.chip-amber {
background: linear-gradient(160deg, #b98426, #8a5f16);
}
.widget-chip.chip-sovran {
background: linear-gradient(160deg, #42f39a, #1aa45d);
}
.widget-chip.chip-neutral {
background: linear-gradient(160deg, #2e333a, #23272c);
}
.w-body { min-width: 0; }
.widget h3 {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: -0.005em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.widget .sub {
margin-top: 4px;
font-size: 0.8rem;
line-height: 1.45;
color: var(--text-2);
overflow-wrap: anywhere;
}
.widget .sub b { color: var(--text); font-weight: 650; }
.widget .sub .good { color: var(--accent); font-weight: 650; }
.widget .sub .warn { color: var(--amber); font-weight: 650; }
.w-bar {
height: 7px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.07);
overflow: hidden;
margin: 8px 0 7px;
}
.w-bar-fill {
height: 100%;
border-radius: 99px;
background: linear-gradient(90deg, #42f39a, #1aa45d);
transition: width 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
}
/* ── Tiles area ─────────────────────────────────────────────────── */
#tiles-area {
flex: 1;
height: 100%;
overflow-y: auto;
padding: 24px 20px 48px;
min-width: 0;
}
.dashboard-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 72px 24px;
color: var(--text-3);
font-size: 0.9rem;
}
.dashboard-loading-spinner {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2.5px solid rgba(255, 255, 255, 0.12);
border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Category sections ──────────────────────────────────────────── */
.category-section {
@@ -535,30 +140,25 @@
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
font-size: 0.72rem;
font-weight: 750;
letter-spacing: 0.14em;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-2);
white-space: nowrap;
color: var(--text-secondary);
margin-bottom: 4px;
padding-left: 4px;
}
.section-divider {
border: none;
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--border), transparent);
margin: 0;
border-top: 1px solid var(--border-color);
margin-bottom: 16px;
}
.tiles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
gap: 15px;
display: flex;
flex-wrap: wrap;
gap: 14px;
}
/* ── Empty state ────────────────────────────────────────────────── */
@@ -566,103 +166,10 @@
.empty-state {
text-align: center;
padding: 64px 24px;
color: var(--text-3);
color: var(--text-dim);
}
.empty-state p {
font-size: 1rem;
margin-bottom: 8px;
}
/* ── Upgrade modal (kept from previous layout) ──────────────────── */
.upgrade-dialog {
max-width: 480px;
}
.upgrade-info-box {
background: rgba(0, 0, 0, 0.16);
border: 1px solid var(--border-strong);
border-radius: 16px;
padding: 14px 16px;
margin: 14px 0;
}
.upgrade-info-title {
font-size: 0.88rem;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.upgrade-info-list {
padding-left: 20px;
font-size: 0.85rem;
color: var(--text-2);
line-height: 1.7;
margin: 0;
}
.upgrade-info-list a {
color: var(--accent);
}
.upgrade-rebuild-note {
font-style: italic;
color: var(--text-3);
font-size: 0.82rem;
}
/* ── First-login security banner (inside .content) ──────────────── */
.security-first-login-banner {
display: flex;
align-items: center;
gap: 12px;
background: rgba(233, 182, 74, 0.08);
border: 1px solid rgba(233, 182, 74, 0.30);
border-radius: 16px;
padding: 12px 16px;
color: var(--text-2);
font-size: 0.85rem;
margin-bottom: 24px;
}
/* ── Narrow viewports (phone / half-screen RDP) ──────────────────── */
/* The sidebar becomes an icon rail so the content keeps room, and the
topbar wraps its search onto a second row instead of overflowing. */
@media (max-width: 920px) {
.sidebar {
width: 76px;
padding: 16px 8px 12px;
}
.brand { justify-content: center; padding: 2px 0 18px; gap: 0; }
.brand-text,
.nav-label,
.nav-text,
.nav-count,
.sidebar-support-text,
.sidebar-divider,
#sidebar-features { display: none; }
.nav-item { justify-content: center; gap: 0; padding: 10px 0; }
.sidebar-support-btn { justify-content: center; gap: 0; padding: 10px 0; }
.content { padding: 18px 18px 40px; }
/* keep the welcome background full-bleed against the new padding */
.welcome {
margin: -18px -18px -40px;
padding: 48px 20px 40px;
min-height: calc(100% + 58px);
}
}
@media (max-width: 640px) {
.topbar { flex-wrap: wrap; row-gap: 10px; padding: 12px 16px; }
.search-box { order: 5; flex-basis: 100%; }
.welcome { padding: 40px 16px 36px; }
.welcome-cards { grid-template-columns: 1fr; }
}
File diff suppressed because it is too large. Load diff
@@ -7,7 +7,7 @@
justify-content: flex-start;
min-height: 100vh;
background:
radial-gradient(ellipse at top, rgba(62, 207, 142, 0.04) 0%, transparent 50%),
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
var(--bg-color);
padding: 24px 16px 48px;
overflow-y: auto;
@@ -70,13 +70,13 @@
.onboarding-step-dot.active {
background-color: var(--accent-color);
border-color: var(--accent-color);
color: #04220f;
color: #0A1A10;
}
.onboarding-step-dot.completed {
background-color: var(--green);
border-color: var(--green);
color: #04220f;
color: #0A1A10;
}
.onboarding-step-connector {
@@ -139,7 +139,7 @@
/* Cards */
.onboarding-card {
background-color: rgba(18, 24, 20, 0.65);
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);
@@ -184,10 +184,10 @@
font-size: 0.82rem;
font-weight: 700;
color: var(--accent-color);
background-color: rgba(62, 207, 142, 0.10);
background-color: rgba(94, 173, 138, 0.10);
padding: 3px 10px;
border-radius: 20px;
border: 1px solid rgba(62, 207, 142, 0.25);
border: 1px solid rgba(94, 173, 138, 0.25);
}
/* Step header */
@@ -371,8 +371,8 @@
.onboarding-port-warn {
padding: 10px 14px;
background-color: rgba(233, 182, 74, 0.1);
border: 1px solid rgba(233, 182, 74, 0.35);
background-color: rgba(229, 165, 10, 0.1);
border: 1px solid rgba(229, 165, 10, 0.35);
border-radius: 8px;
font-size: 0.85rem;
color: var(--yellow);
@@ -409,8 +409,8 @@
.onboarding-creds-notice {
padding: 12px 16px;
background-color: rgba(62, 207, 142, 0.08);
border: 1px solid rgba(62, 207, 142, 0.20);
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);
@@ -503,8 +503,8 @@
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 4px;
background-color: rgba(62, 207, 142, 0.08);
border: 1px solid rgba(62, 207, 142, 0.20);
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;
@@ -512,7 +512,7 @@
}
.onboarding-cred-reveal-btn:hover {
background-color: rgba(62, 207, 142, 0.15);
background-color: rgba(94, 173, 138, 0.15);
}
/* Completion checklist (Step 5) */
@@ -671,7 +671,7 @@
}
.onboarding-password-toggle:hover {
background-color: rgba(62, 207, 142, 0.10);
background-color: rgba(94, 173, 138, 0.10);
border-color: var(--accent-color);
}
@@ -683,8 +683,8 @@
.onboarding-password-warning {
padding: 10px 14px;
background-color: rgba(233, 182, 74, 0.1);
border: 1px solid rgba(233, 182, 74, 0.35);
background-color: rgba(229, 165, 10, 0.1);
border: 1px solid rgba(229, 165, 10, 0.35);
border-radius: 8px;
font-size: 0.85rem;
color: var(--yellow);
@@ -694,8 +694,8 @@
.onboarding-password-success {
padding: 12px 16px;
background-color: rgba(62, 207, 142, 0.10);
border: 1px solid rgba(62, 207, 142, 0.30);
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);
@@ -748,7 +748,7 @@
}
.reboot-card {
background-color: rgba(18, 24, 20, 0.8);
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);
@@ -823,8 +823,57 @@
/* ── Responsive ─────────────────────────────────────────────────── */
/* Legacy pre-redesign narrow-screen rules removed: they targeted the old
DOM (.main-content, .header-bar, .ip-bar) and their .sidebar /
#tiles-area / .service-tile / .creds-qr-img overrides fought the new
responsive layout in layout.css (sidebar icon rail, wrapping topbar).
Narrow-viewport behavior now lives in layout.css + header.css. */
@media (max-width: 768px) {
body {
overflow: auto;
}
.main-content {
flex-direction: column;
overflow: visible;
}
.sidebar {
width: 100%;
height: auto;
border-right: none;
border-bottom: 1px solid var(--border-color);
padding: 14px 12px;
}
#tiles-area {
height: auto;
overflow-y: visible;
padding: 16px 12px 40px;
}
}
@media (max-width: 600px) {
.header-bar {
padding: 10px 14px;
gap: 10px;
}
.header-bar .title {
font-size: 0.95rem;
}
.ip-bar {
gap: 16px;
flex-wrap: wrap;
padding: 8px 14px;
}
.tiles-grid {
justify-content: center;
}
.service-tile {
width: 140px;
min-height: 130px;
}
.reboot-card {
padding: 36px 28px;
margin: 0 16px;
}
.creds-dialog {
margin: 0 12px;
}
.creds-qr-img {
width: 200px;
height: 200px;
}
}
@@ -28,7 +28,7 @@
.security-warning-box {
background-color: rgba(180, 40, 40, 0.10);
border-left: 3px solid var(--red);
border-left: 3px solid #c94040;
border-radius: 6px;
padding: 12px 14px;
margin-bottom: 14px;
@@ -78,7 +78,7 @@
}
.btn-danger {
background-color: var(--red);
background-color: #c94040;
color: #fff;
border: none;
border-radius: 6px;
@@ -90,7 +90,7 @@
}
.btn-danger:hover:not(:disabled) {
background-color: var(--red);
background-color: #a83030;
}
.btn-danger:disabled {
@@ -105,8 +105,8 @@
}
.security-status-info { color: var(--text-secondary); }
.security-status-ok { color: var(--accent); }
.security-status-error { color: var(--red); }
.security-status-ok { color: #6DBF8B; }
.security-status-error { color: #e05252; }
/* ── Verify System Integrity ─────────────────────────────────────── */
@@ -160,7 +160,7 @@
.security-verify-link {
font-size: 0.78rem;
color: var(--accent-color, var(--accent));
color: var(--accent-color, #6DBF8B);
text-decoration: none;
}
@@ -174,13 +174,13 @@
}
.security-verify-pass {
background-color: rgba(66, 243, 154, 0.15);
color: var(--accent);
background-color: rgba(109, 191, 139, 0.15);
color: #6DBF8B;
}
.security-verify-fail {
background-color: rgba(224, 82, 82, 0.15);
color: var(--red);
color: #e05252;
}
.security-verify-errors {
@@ -221,11 +221,12 @@
display: none;
position: fixed;
inset: 0;
background: radial-gradient(1000px 640px at 50% 18%, #202429, #0f1113 72%);
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;
padding: 26px;
animation: security-reset-fade-in 0.35s ease-out;
}
@@ -252,13 +253,13 @@
}
.security-reset-password-box {
font-family: var(--mono);
font-family: monospace;
font-size: 1.35rem;
font-weight: 700;
color: var(--text-primary);
background: var(--accent-dim);
border: 1.5px solid rgba(66, 243, 154, 0.35);
border-radius: 14px;
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;
@@ -276,11 +277,10 @@
}
.security-reset-reboot-btn {
background: linear-gradient(180deg, #3fd68e, #1ea263);
color: #04220f;
background-color: #6DBF8B;
color: #0a0c0b;
border: none;
border-radius: 99px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
border-radius: 7px;
padding: 11px 22px;
font-size: 0.88rem;
font-weight: 700;
@@ -290,7 +290,7 @@
}
.security-reset-reboot-btn:hover:not(:disabled) {
background-color: var(--accent);
background-color: #5aab78;
}
.security-reset-reboot-btn:disabled {
@@ -303,12 +303,11 @@
.security-first-login-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
margin-bottom: 24px;
background: rgba(233, 182, 74, 0.08);
border: 1px solid rgba(233, 182, 74, 0.30);
border-radius: 16px;
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);
}
@@ -334,21 +333,19 @@
.security-banner-dismiss {
background: none;
border: 1px solid var(--border-strong);
border-radius: 99px;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-secondary);
padding: 4px 10px;
padding: 2px 7px;
flex-shrink: 0;
line-height: 1.4;
transition: 0.15s;
transition: background-color 0.15s;
}
.security-banner-dismiss:hover {
color: var(--text);
border-color: rgba(255, 255, 255, 0.26);
background: rgba(255, 255, 255, 0.04);
background-color: rgba(0,0,0,0.08);
}
/* ── Legacy security inline warning banner ───────────────────────── */
@@ -360,14 +357,14 @@
padding: 12px 14px;
margin-bottom: 12px;
background-color: rgba(180, 100, 0, 0.12);
border-left: 3px solid #c98d08;
border-left: 3px solid #c97a00;
border-radius: 6px;
color: var(--text-primary);
}
.security-inline-icon {
font-size: 1rem;
color: #e0a010;
color: #e69000;
flex-shrink: 0;
}
@@ -381,9 +378,9 @@
display: inline-block;
font-size: 0.82rem;
font-weight: 600;
color: #e0a010;
color: #e69000;
text-decoration: none;
border: 1px solid #c98d08;
border: 1px solid #c97a00;
border-radius: 4px;
padding: 4px 10px;
align-self: flex-start;
@@ -450,9 +447,9 @@
.pw-credentials-note {
font-size: 0.78rem;
color: #c98d08;
color: #c97a00;
background-color: rgba(180, 100, 0, 0.10);
border-left: 2px solid #c98d08;
border-left: 2px solid #c97a00;
border-radius: 4px;
padding: 7px 10px;
margin-bottom: 12px;
+11 -11
View File
@@ -104,7 +104,7 @@
}
.support-steps code {
background-color: rgba(62, 207, 142, 0.10);
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: #04220f;
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: #04220f;
color: #0A1A10;
font-size: 0.95rem;
font-weight: 700;
margin-top: 16px;
@@ -168,7 +168,7 @@
}
.support-btn-auditlog:hover:not(:disabled) {
background-color: var(--card-hover);
background-color: #1c2a24;
}
.support-fine-print {
@@ -219,13 +219,13 @@
}
.support-wallet-protected {
background-color: rgba(66, 243, 154, 0.06);
border-color: rgba(66, 243, 154, 0.3);
background-color: rgba(109, 191, 139, 0.06);
border-color: rgba(109, 191, 139, 0.3);
}
.support-wallet-unlocked {
background-color: rgba(233, 182, 74, 0.06);
border-color: rgba(233, 182, 74, 0.3);
background-color: rgba(229, 165, 10, 0.06);
border-color: rgba(229, 165, 10, 0.3);
}
.support-wallet-warning {
@@ -290,7 +290,7 @@
padding: 8px 16px;
border-radius: var(--radius-btn);
background-color: var(--yellow);
color: #04220f;
color: #0A1A10;
font-size: 0.82rem;
font-weight: 700;
}
@@ -310,7 +310,7 @@
}
.support-btn-wallet-lock:hover:not(:disabled) {
background-color: var(--accent);
background-color: #529E7E;
}
/* ── Audit log ───────────────────────────────────────────────────── */
@@ -324,7 +324,7 @@
.support-audit-log {
max-height: 200px;
overflow-y: auto;
background-color: var(--inset);
background-color: #0c0f0e;
border-radius: 8px;
padding: 10px 14px;
}
+401 -106
View File
@@ -1,178 +1,473 @@
/* ── Service tiles ──────────────────────────────────────────────── */
/* ── Service tile card (status-only) ─────────────────────────────── */
.service-tile {
background: var(--card);
border: 1px solid var(--border);
border-radius: 20px;
overflow: hidden;
padding: 21px 12px 17px;
width: 160px;
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;
flex-direction: column;
align-items: center;
cursor: pointer;
justify-content: center;
padding: 20px 12px 18px;
gap: 0;
transition: box-shadow 0.2s, border-color 0.2s;
position: relative;
transition: transform 0.18s, border-color 0.18s, background 0.18s, box-shadow 0.18s, opacity 0.18s;
animation: tileIn 0.45s cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
}
@keyframes tileIn {
from { opacity: 0; transform: translateY(10px) scale(0.96); }
cursor: pointer;
}
.service-tile:hover {
transform: translateY(-4px);
border-color: var(--border-strong);
background: var(--card-hover);
box-shadow: var(--shadow-hover);
border-color: var(--accent-color);
}
.service-tile:active { transform: translateY(-1px) scale(0.985); }
.service-tile:focus-visible {
outline: 2px solid rgba(66, 243, 154, 0.45);
outline-offset: 2px;
.service-tile.disabled {
opacity: 0.45;
}
.service-tile.disabled { opacity: 0.55; }
.tile-icon {
width: 62px;
height: 62px;
display: block;
width: 48px;
height: 48px;
object-fit: contain;
margin-bottom: 10px;
}
.tile-icon-fallback {
width: 62px;
height: 62px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-3);
font-size: 1.2rem;
font-weight: 700;
background-color: var(--border-color);
border-radius: 12px;
color: var(--text-dim);
font-size: 1.5rem;
margin-bottom: 10px;
}
.tile-name {
margin-top: 13px;
font-size: 0.86rem;
font-weight: 650;
font-size: 0.88rem;
font-weight: 600;
text-align: center;
line-height: 1.25;
min-height: 2.5em;
color: var(--text-primary);
line-height: 1.3;
max-width: 140px;
word-break: break-word;
hyphens: auto;
min-height: 1.3em;
display: flex;
align-items: center;
justify-content: center;
color: var(--text);
max-width: 100%;
}
/* Status pill — dot + label inside a rounded pill */
.tile-status {
margin-top: 9px;
display: inline-flex;
font-size: 0.75rem;
margin-top: 8px;
display: flex;
align-items: center;
gap: 6px;
font-size: 0.69rem;
font-weight: 700;
padding: 3px 10px;
border-radius: 99px;
letter-spacing: 0.01em;
white-space: nowrap;
background: rgba(255, 255, 255, 0.05);
color: var(--text-2);
gap: 5px;
color: var(--text-secondary);
}
.status-text { line-height: 1; }
.tile-version {
font-size: 0.7rem;
color: var(--text-dim);
margin-top: 2px;
text-align: center;
}
.status-dot {
width: 6px;
height: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-3);
flex-shrink: 0;
background-color: var(--grey);
}
.status-dot.active { background: var(--accent); }
.status-dot.needs-attention { background: var(--amber); }
.status-dot.failed { background: var(--red); }
.status-dot.inactive,
.status-dot.disabled { background: var(--text-3); }
.status-dot.syncing,
.status-dot.loading,
.status-dot.checking-reachability { background: var(--blue); animation: tile-dot-pulse 1.4s ease-in-out infinite; }
.status-dot.unknown { background: var(--text-3); }
.status-dot.active { background-color: var(--green); }
.status-dot.inactive { background-color: var(--red); }
.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; }
.status-dot.failed { background-color: var(--red); }
.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; }
@keyframes tile-dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
/* Progressive enhancement: tint the whole pill on modern browsers */
.tile-status:has(.status-dot.active) { background: var(--accent-dim); color: var(--accent); }
.tile-status:has(.status-dot.needs-attention) { background: rgba(233, 182, 74, 0.10); color: var(--amber); }
.tile-status:has(.status-dot.failed) { background: rgba(246, 97, 81, 0.10); color: var(--red); }
.tile-status:has(.status-dot.syncing),
.tile-status:has(.status-dot.loading),
.tile-status:has(.status-dot.checking-reachability) { background: rgba(120, 174, 237, 0.10); color: var(--blue); }
.support-status-label {
font-size: 0.69rem;
font-weight: 700;
color: var(--text-2);
}
/* ── Bitcoin IBD sync tile ──────────────────────────────────────── */
/* ── Bitcoin IBD sync progress bar ──────────────────────────────── */
.tile-sync-container {
margin-top: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 100%;
padding: 0 6px;
margin-top: 6px;
}
.tile-sync-label {
font-size: 0.66rem;
font-weight: 750;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--amber);
font-size: 0.72rem;
color: #f5a623;
font-weight: 600;
text-align: center;
white-space: nowrap;
}
.tile-sync-bar-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 7px;
gap: 5px;
width: 100%;
}
.tile-sync-bar-track {
flex: 1;
height: 7px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.07);
height: 6px;
background-color: var(--border-color);
border-radius: 3px;
overflow: hidden;
}
.tile-sync-bar-fill {
height: 100%;
border-radius: 99px;
background: linear-gradient(90deg, #42f39a, #1aa45d);
transition: width 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
background-color: #f5a623;
border-radius: 3px;
transition: width 0.6s ease;
min-width: 2px;
}
.tile-sync-percent {
font-family: var(--mono);
font-size: 0.7rem;
font-size: 0.72rem;
font-weight: 700;
color: var(--amber);
flex-shrink: 0;
color: #f5a623;
white-space: nowrap;
min-width: 2.5em;
text-align: right;
}
.tile-sync-eta {
margin-top: 6px;
font-family: var(--mono);
font-size: 0.66rem;
color: var(--text-3);
font-size: 0.68rem;
color: var(--text-dim);
text-align: center;
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 {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.svc-detail-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.svc-detail-section-title {
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
margin-bottom: 10px;
}
.svc-detail-desc {
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.6;
}
.svc-detail-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
}
/* ── Service detail: Domain ──────────────────────────────────────── */
.svc-detail-domain-value {
font-size: 0.9rem;
color: var(--text-primary);
font-weight: 600;
}
.tile-domain-label--ok {
color: var(--green);
font-weight: 600;
}
.tile-domain-label--warn {
color: var(--yellow);
font-weight: 600;
}
.tile-domain-label--error {
color: var(--red);
font-weight: 600;
}
/* ── Service detail: Port table ──────────────────────────────────── */
.svc-detail-port-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
margin-top: 8px;
}
.svc-detail-port-table th {
text-align: left;
color: var(--text-dim);
font-weight: 600;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 6px 10px;
border-bottom: 1px solid var(--border-color);
}
.svc-detail-port-table td {
padding: 8px 10px;
border-bottom: 1px solid rgba(30, 45, 39, 0.6);
color: var(--text-primary);
}
.svc-detail-port-table tr:last-child td {
border-bottom: none;
}
.svc-detail-port-table-port {
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
font-weight: 600;
color: var(--accent-color);
}
.svc-detail-port-table-proto {
text-transform: uppercase;
color: var(--text-secondary);
}
.svc-detail-port-table-desc {
color: var(--text-secondary);
}
.svc-detail-port-table-status {
font-weight: 600;
}
.port-status-listening { color: var(--green); }
.port-status-open { color: var(--yellow); }
.port-status-closed { color: var(--red); }
.port-status-unknown { color: var(--text-dim); }
/* ── Service detail: Troubleshoot box ────────────────────────────── */
.svc-detail-troubleshoot {
margin-top: 12px;
padding: 14px 16px;
background-color: rgba(229, 165, 10, 0.08);
border: 1px solid rgba(229, 165, 10, 0.3);
border-radius: 10px;
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.6;
}
.svc-detail-troubleshoot strong {
color: var(--yellow);
}
.svc-detail-troubleshoot ol {
margin-top: 8px;
padding-left: 20px;
}
.svc-detail-troubleshoot li {
margin-bottom: 4px;
}
.svc-detail-troubleshoot code {
background-color: rgba(94, 173, 138, 0.10);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.82rem;
color: var(--accent-color);
}
.svc-detail-troubleshoot a {
color: var(--accent-color);
text-decoration: none;
}
.svc-detail-troubleshoot a:hover {
text-decoration: underline;
}
/* ── Service detail: Domain configure button ─────────────────────── */
.svc-detail-domain-btn {
margin-top: 12px;
}
/* ── Service detail: Addon feature toggle ────────────────────────── */
.svc-detail-addon-row {
display: flex;
align-items: center;
gap: 14px;
margin-top: 12px;
}
.svc-detail-addon-status {
font-size: 0.88rem;
font-weight: 700;
}
.addon-status--on {
color: var(--green);
}
.addon-status--off {
color: var(--text-dim);
}
.feature-conflict-warning {
margin-top: 8px;
margin-bottom: 8px;
padding: 10px 14px;
background-color: rgba(229, 165, 10, 0.1);
border: 1px solid rgba(229, 165, 10, 0.3);
border-radius: 8px;
font-size: 0.82rem;
color: var(--yellow);
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 ──────────────────────────────────────── */
.svc-detail-launch-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.svc-detail-launch-btn {
font-size: 0.85rem;
padding: 8px 18px;
cursor: pointer;
}
@@ -5,16 +5,6 @@
const POLL_INTERVAL_SERVICES = 5000;
const POLL_INTERVAL_UPDATES = 1800000;
const UPDATE_POLL_INTERVAL = 2000;
// A pending fetch never rejects by itself. Bound every status request so a
// wedged browser connection cannot leave the modal spinning forever.
const STATUS_POLL_FETCH_TIMEOUT = 15000;
// Eight timed-out requests plus the poll interval is a little over two minutes.
// A brief Hub restart or a heavily loaded Nix build remains well inside this.
const STATUS_POLL_MAX_FAILURES = 8;
// Keep verbose Nix output from making textContent updates quadratic and
// freezing the Hub renderer (especially noticeable over RDP).
const UPDATE_VISIBLE_LOG_MAX_CHARS = 250000;
const UPDATE_VISIBLE_LOG_TRIM_CHARS = 200000;
const REBOOT_CHECK_INTERVAL = 5000;
const REBOOT_FETCH_TIMEOUT = 12000;
const REBOOT_REQUEST_TIMEOUT = 4000;
@@ -23,19 +13,13 @@ const SUPPORT_TIMER_INTERVAL = 1000;
const CATEGORY_ORDER = [
"infrastructure",
"bitcoin",
"bitcoin-base",
"bitcoin-apps",
"communication",
"apps",
"nostr",
];
/* The service catalog still distinguishes the Bitcoin foundation services
from the Bitcoin apps; the Hub shows them as one "Bitcoin" section. */
const CATEGORY_ALIASES = {
"bitcoin-base": "bitcoin",
"bitcoin-apps": "bitcoin",
};
const FEATURE_SUBCATEGORY_LABELS = {
"infrastructure": "🔧 Infrastructure",
"bitcoin": "₿ Bitcoin",
@@ -1,451 +0,0 @@
"use strict";
/* ── The Hub dashboard chrome ──────────────────────────────────────
Welcome dashboard (default view), category navigation, service
search, status cards, and the Systems Operational modal.
Everything lives in an IIFE so no globals leak into the other Hub
scripts; tiles.js calls window.dashboardServicesUpdated() whenever
the service list refreshes. */
(function () {
var $nav = document.getElementById("sidebar-nav");
var $pageTitle = document.getElementById("page-title");
var $search = document.getElementById("search-input");
var $sysModal = document.getElementById("systems-modal");
var $sysBody = document.getElementById("systems-body");
var $welcome = document.getElementById("welcome-view");
var $tilesArea = document.getElementById("tiles-area");
var $wcSystems = document.getElementById("wc-systems");
var $wcMore = document.getElementById("wc-more");
var $greeting = document.getElementById("welcome-greeting");
var $welcomeRole = document.getElementById("welcome-role");
var $browseBtn = document.getElementById("welcome-browse-btn");
var _view = "dashboard"; // "dashboard" | "services"
var _cat = "all";
var _query = "";
var CAT_ICONS = {
"all": "g-grid",
"infrastructure": "g-server",
"bitcoin": "g-btc-sym",
"communication": "g-chat",
"apps": "g-dots",
"nostr": "g-antenna",
"other": "g-dots"
};
var CAT_FALLBACK_LABELS = {
"infrastructure": "Infrastructure",
"bitcoin": "Bitcoin",
"communication": "Communication",
"apps": "Personal Apps",
"nostr": "Nostr",
"other": "Other"
};
/* Units that serve a domain — used to find a live diagnostics
checklist for the Systems Operational modal (order matters only
for which service is polled first). */
var DOMAIN_UNITS = [
"matrix-synapse.service",
"btcpayserver.service",
"vaultwarden.service",
"phpfpm-nextcloud.service",
"phpfpm-wordpress.service",
"haven-relay.service",
"livekit.service",
"albyhub.service"
];
function icon(id) {
return '<svg><use href="#' + id + '"/></svg>';
}
function visibleServices() {
var services = (typeof _servicesCache !== "undefined" && _servicesCache) ? _servicesCache : [];
return services.filter(function (s) {
return s.category !== "support" && s.type !== "support";
});
}
/* ── Views ────────────────────────────────────────────────────── */
function setTitle(t) {
if ($pageTitle) $pageTitle.textContent = t;
}
function syncNav() {
if (!$nav) return;
$nav.querySelectorAll(".nav-item").forEach(function (b) {
var isActive;
if (b.dataset.cat === "__dash") isActive = (_view === "dashboard");
else isActive = (_view === "services" && b.dataset.cat === _cat);
b.classList.toggle("active", isActive);
});
}
function showDashboard() {
_view = "dashboard";
_cat = "all";
if ($welcome) $welcome.style.display = "";
if ($tilesArea) $tilesArea.style.display = "none";
setTitle("Dashboard");
syncNav();
}
function showServices(cat) {
_view = "services";
if (cat) _cat = cat;
if ($welcome) $welcome.style.display = "none";
if ($tilesArea) $tilesArea.style.display = "";
setTitle(_cat === "all" ? "All services" : catLabel(_cat));
syncNav();
applyFilter();
}
/* ── Category navigation ──────────────────────────────────────── */
function renderNav() {
if (!$nav) return;
var counts = {};
var order = [];
visibleServices().forEach(function (s) {
var cat = s.category || "other";
if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
if (!counts[cat]) { counts[cat] = 0; order.push(cat); }
counts[cat]++;
});
var total = visibleServices().length;
var html = '<div class="nav-label">Menu</div>';
html += '<button class="nav-item' + (_view === "dashboard" ? " active" : "") + '" data-cat="__dash" type="button">' +
icon("g-home") +
'<span class="nav-text">Dashboard</span>' +
'</button>';
html += '<div class="nav-label">Services</div>';
html += navItem("all", "All services", total);
order.forEach(function (cat) {
html += navItem(cat, catLabel(cat), counts[cat]);
});
$nav.innerHTML = html;
$nav.querySelectorAll(".nav-item").forEach(function (btn) {
btn.addEventListener("click", function () {
if (btn.dataset.cat === "__dash") showDashboard();
else showServices(btn.dataset.cat);
});
});
}
function navItem(cat, label, count) {
return '<button class="nav-item' + (_view === "services" && cat === _cat ? " active" : "") + '" data-cat="' + escHtml(cat) + '" type="button">' +
icon(CAT_ICONS[cat] || "g-dots") +
'<span class="nav-text">' + escHtml(label) + '</span>' +
'<span class="nav-count">' + count + '</span>' +
'</button>';
}
function catLabel(cat) {
if (typeof _categoryLabels !== "undefined" && _categoryLabels[cat]) return _categoryLabels[cat];
return CAT_FALLBACK_LABELS[cat] || cat;
}
/* ── Filtering (applies in the services view) ─────────────────── */
function applyFilter() {
if (_view !== "services") return;
var area = $tilesArea;
if (!area) return;
var q = _query.trim().toLowerCase();
area.querySelectorAll(".category-section").forEach(function (section) {
var catMatch = (_cat === "all") || (section.dataset.category === _cat);
var anyVisible = false;
section.querySelectorAll(".service-tile").forEach(function (tile) {
var nameEl = tile.querySelector(".tile-name");
var name = nameEl ? nameEl.textContent.toLowerCase() : "";
var match = catMatch && (!q || name.indexOf(q) !== -1);
tile.style.display = match ? "" : "none";
if (match) anyVisible = true;
});
section.style.display = (catMatch && (anyVisible || !q)) ? "" : "none";
});
}
if ($search) {
$search.addEventListener("input", function () {
// Searching implies browsing services — leave the welcome view.
if (_view === "dashboard" && $search.value) showServices("all");
_query = $search.value;
applyFilter();
});
}
if ($browseBtn) {
$browseBtn.addEventListener("click", function () { showServices("all"); });
}
/* ── Welcome header ───────────────────────────────────────────── */
function updateWelcomeMeta() {
if ($greeting) {
var h = new Date().getHours();
var g;
if (h >= 5 && h < 12) g = "Good morning";
else if (h >= 12 && h < 17) g = "Good afternoon";
else g = "Good evening";
$greeting.textContent = g;
}
if ($welcomeRole) {
$welcomeRole.textContent = (typeof window._roleLabel !== "undefined" && window._roleLabel) ? window._roleLabel : "";
}
}
/* ── Status cards ─────────────────────────────────────────────── */
function serviceCounts() {
var services = visibleServices();
var running = 0, attention = 0, off = 0;
var attentionNames = [], offNames = [];
services.forEach(function (s) {
if (!s.enabled) { off++; offNames.push(s.name); return; }
var h = s.health || s.status;
if (h === "needs_attention" || h === "failed") { attention++; attentionNames.push(s.name); }
else running++;
});
return { services: services, running: running, attention: attention, off: off, attentionNames: attentionNames, offNames: offNames };
}
function renderWidgets() {
if (!$wcSystems) return;
var c = serviceCounts();
if (!c.services.length) { $wcSystems.innerHTML = ""; if ($wcMore) $wcMore.innerHTML = ""; return; }
/* Systems operational */
var sub = '<b>' + c.running + '</b> running';
if (c.attention) sub += ' · <span class="warn">' + c.attention + ' needs attention</span>';
if (c.off) sub += ' · ' + c.off + ' off';
var attentionTitle = c.attention ? "Systems need attention" : "Systems operational";
$wcSystems.innerHTML =
'<div class="widget clickable" id="w-systems" role="button" tabindex="0" title="System status and router setup">' +
'<div class="widget-chip chip-green">' + icon("g-pulse") + '</div>' +
'<div class="w-body"><h3>' + attentionTitle + '</h3><div class="sub">' + sub + '</div></div>' +
'</div>';
var wSys = document.getElementById("w-systems");
if (wSys) {
wSys.addEventListener("click", openSystemsModal);
wSys.addEventListener("keydown", function (e) { if (e.key === "Enter") openSystemsModal(); });
}
if (!$wcMore) return;
var more = "";
/* Bitcoin Core sync */
var btc = null;
c.services.forEach(function (s) {
if (s.sync_ibd && s.enabled) btc = s;
});
if (btc) {
var pct = Math.round((btc.sync_progress || 0) * 100);
var blocks = btc.sync_blocks ? btc.sync_blocks.toLocaleString() : "—";
var eta = (typeof _calcBtcEta === "function") ? _calcBtcEta(btc.unit + "::" + btc.name, btc.sync_progress || 0) : "";
more +=
'<div class="widget clickable" id="w-btc" role="button" tabindex="0" title="' + escHtml(btc.name) + ' details">' +
'<div class="widget-chip chip-btc"><img src="/static/icons/' + escHtml(btc.icon) + '.svg" alt="" style="width:46px;height:46px;display:block;object-fit:contain"/></div>' +
'<div class="w-body"><h3>' + escHtml(btc.name) + ' — syncing timechain</h3>' +
'<div class="w-bar"><div class="w-bar-fill" style="width:' + pct + '%"></div></div>' +
'<div class="sub">Block <b>' + blocks + '</b> · ' + pct + '% · <span class="warn">' + escHtml(eta) + '</span></div></div>' +
'</div>';
} else {
var btcDone = null;
c.services.forEach(function (s) { if (s.unit === "bitcoind.service" && s.enabled) btcDone = s; });
if (btcDone) {
var blk = btcDone.sync_blocks ? btcDone.sync_blocks.toLocaleString() : "";
more +=
'<div class="widget clickable" id="w-btc" role="button" tabindex="0" title="' + escHtml(btcDone.name) + ' details">' +
'<div class="widget-chip chip-btc"><img src="/static/icons/' + escHtml(btcDone.icon) + '.svg" alt="" style="width:46px;height:46px;display:block;object-fit:contain"/></div>' +
'<div class="w-body"><h3>' + escHtml(btcDone.name) + '</h3><div class="sub"><span class="good">Fully synced</span>' + (blk ? ' · Block <b>' + blk + '</b>' : '') + '</div></div>' +
'</div>';
}
}
/* Updates — mirror the sidebar's read of the update state, so the card
never claims "up to date" while an update failed or needs a restart */
var upd = (typeof window._lastUpdateCheck === "object" && window._lastUpdateCheck) ? window._lastUpdateCheck : null;
var updStatus = (upd && upd.status) || "idle";
var updTitle, updSub, updChip;
if (updStatus === "failed") {
updTitle = "Update failed"; updSub = "Click to retry the update"; updChip = "chip-amber";
} else if (updStatus === "reboot_required") {
updTitle = "Restart required"; updSub = "Click to restart and finish the update"; updChip = "chip-amber";
} else if (updStatus === "running") {
updTitle = "Update in progress"; updSub = "Installing the new system generation"; updChip = "chip-amber";
} else if (upd && upd.available) {
updTitle = "Updates available"; updSub = "Click to review and update"; updChip = "chip-amber";
} else if (!upd) {
/* First check hasn't returned yet — never claim "up to date" before
a check has actually completed */
updTitle = "Checking for updates"; updSub = "Comparing with the latest Sovran_SystemsOS release"; updChip = "chip-green";
} else {
/* Updates are applied by the user, not automatically — the card must
not imply the OS updates itself */
updTitle = "System is up to date"; updSub = "Last check found no updates · Click to check again"; updChip = "chip-green";
}
more +=
'<div class="widget clickable" id="w-updates" role="button" tabindex="0" title="Check for system updates">' +
'<div class="widget-chip ' + updChip + '">' + icon("g-update") + '</div>' +
'<div class="w-body"><h3>' + updTitle + '</h3>' +
'<div class="sub">' + updSub + '</div></div>' +
'</div>';
$wcMore.innerHTML = more;
var wBtc = document.getElementById("w-btc");
if (wBtc) {
var svc = btc || btcDone;
if (svc) {
wBtc.addEventListener("click", function () { openServiceDetailModal(svc.unit, svc.name, svc.icon); });
wBtc.addEventListener("keydown", function (e) { if (e.key === "Enter") openServiceDetailModal(svc.unit, svc.name, svc.icon); });
}
}
var wUpd = document.getElementById("w-updates");
if (wUpd) {
wUpd.addEventListener("click", function () { openUpdateModal(); });
wUpd.addEventListener("keydown", function (e) { if (e.key === "Enter") openUpdateModal(); });
}
}
/* ── Systems Operational modal ────────────────────────────────── */
function isNodeRole() {
return (typeof _currentRole !== "undefined" && _currentRole === "node");
}
function hasEnabledDomainService(services) {
return DOMAIN_UNITS.some(function (u) {
return services.some(function (s) { return s.unit === u && s.enabled; });
});
}
function whoUsesPorts() {
if (isNodeRole()) {
return 'On this <strong>Bitcoin Node</strong> install, <strong>BTCPay Server</strong> and <strong>Lightning Wallet Connections (LNURL)</strong> are the domain services that use these ports.';
}
return 'All your domain services share ports 80 and 443 — Matrix, BTCPay Server, VaultWarden, Nextcloud, WordPress, Haven Relay, Lightning Wallet Connections, and Element Calling.';
}
function step(n, title, sub, value) {
return '<div class="sysstep"><div class="sysnum">' + n + '</div><div class="sysstep-x">' +
'<div class="sysstep-t">' + title + (sub ? ' <span class="sysstep-sub">· ' + sub + '</span>' : '') + '</div>' +
(value ? '<div class="sysval"><span class="sysval-text">' + value + '</span></div>' : '') +
'</div></div>';
}
function openSystemsModal() {
if (!$sysModal || !$sysBody) return;
var c = serviceCounts();
var html = "";
/* System status */
html += '<div class="sysmodal-card">' +
'<div class="sysmodal-card-title">' + icon("g-pulse") + 'System Status</div>' +
step(1, "Services running", "", String(c.running)) +
step(2, "Needs attention", "", c.attention ? escHtml(c.attentionNames.join(", ")) : "None") +
step(3, "Turned off", "", c.off ? escHtml(c.offNames.join(", ")) : "None") +
'</div>';
/* Router — a simple open / not-open verdict. How to open the ports is
covered during onboarding, so the modal does not repeat instructions.
When no domain service is enabled (a fresh Desktop-only install, a
Node with BTCPay/LNURL off, or everything turned off), there is
nothing to check on the router — say so instead of listing services
this machine does not have. */
if (!hasEnabledDomainService(c.services)) {
var noRouterDesc = isNodeRole()
? 'Ports 80 and 443 only need to be forwarded on your router if you turn on <strong>BTCPay Server</strong> or <strong>Lightning Wallet Connections (LNURL)</strong>. If you enable one of them, come back here to check your ports.'
: 'None of your services need ports forwarded from your router right now. If you turn on a service that uses a domain, come back here to check your ports.';
html += '<div class="sysmodal-card">' +
'<div class="sysmodal-card-title">' + icon("g-wifi") + 'Router</div>' +
'<div class="sysnote"><div class="sysnote-title">' + icon("g-check") + 'No router setup needed yet</div>' +
'<div class="sysnote-desc">' + noRouterDesc + '</div></div>' +
'</div>';
} else {
html += '<div class="sysmodal-card" id="sys-ports-card" style="display:none">' +
'<div class="sysmodal-card-title">' + icon("g-wifi") + 'Router</div>' +
'<div id="sys-ports-status"><div class="sysfineprint">Checking…</div></div>' +
'</div>';
}
/* Who uses these ports (only meaningful when a domain service is
actually enabled — otherwise the router note above covers it) */
if (hasEnabledDomainService(c.services)) {
html += '<div class="sysnote" style="margin-top:14px">' +
'<div class="sysnote-title">' + icon("g-antenna") + 'Who uses these ports</div>' +
'<div class="sysnote-desc">' + whoUsesPorts() + '</div></div>';
}
$sysBody.innerHTML = html;
$sysModal.classList.add("open");
/* Poll the first configured domain service and reduce its diagnostics
to one verdict: the ports are open or they are not. */
var portsCard = document.getElementById("sys-ports-card");
var portsEl = document.getElementById("sys-ports-status");
var units = DOMAIN_UNITS.filter(function (u) {
return c.services.some(function (s) { return s.unit === u && s.enabled; });
});
if (!units.length || !portsCard || !portsEl) return;
portsCard.style.display = "";
apiFetch("/api/service-detail/" + encodeURIComponent(units[0]))
.then(function (data) {
var steps = (data && data.domain_check_steps) || [];
var portsStep = null;
steps.forEach(function (s) {
if (Number(s.step) === 3 || /ports?\s*80/i.test(s.label || "")) portsStep = s;
});
if (!portsStep) { portsCard.style.display = "none"; return; }
if (portsStep.status === "ok") {
portsEl.innerHTML = '<div class="svc-detail-status" style="font-size:0.92rem"><span class="status-dot active"></span>Ports 80 and 443 are open</div>';
} else {
portsEl.innerHTML = '<div class="svc-detail-status" style="font-size:0.92rem"><span class="status-dot failed"></span>Ports 80 and 443 are not open</div>';
}
})
.catch(function () {
portsCard.style.display = "none";
});
}
function closeSystemsModal() {
if ($sysModal) $sysModal.classList.remove("open");
}
var sysClose = document.getElementById("systems-close-btn");
if (sysClose) sysClose.addEventListener("click", closeSystemsModal);
if ($sysModal) {
$sysModal.addEventListener("click", function (e) {
if (e.target === $sysModal) closeSystemsModal();
});
$sysModal.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeSystemsModal();
});
}
/* ── Public hook for tiles.js ────────────────────────────────── */
window.dashboardServicesUpdated = function () {
renderNav();
updateWelcomeMeta();
renderWidgets();
applyFilter();
};
// Initial view
showDashboard();
updateWelcomeMeta();
})();
@@ -1,109 +0,0 @@
/* Sovran_SystemsOS Hub — Shared domain and router instructions.
This is the single source of truth for the simple setup guidance shown in:
• First-boot onboarding for Server + Desktop
• Feature setup in the Hub
• Domain reconfiguration and troubleshooting
Keep the wording consistent by editing it here. */
"use strict";
function dpEsc(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/* Explain only what the user needs for the step in front of them. */
function renderDomainNeedsHtml(opts) {
opts = opts || {};
var serviceName = opts.serviceName || null;
var purpose = opts.purpose || "";
var html = "";
if (purpose === "btcpay") {
html += "<p><strong>Put your BTCPay Server online</strong></p>";
html += "<p>Let people donate Bitcoin, pay you, or visit your online store.</p>";
html += "<p>BTCPay Server needs a domain so people can reach it. "
+ "Sovran_SystemsOS walks you through getting your domain from "
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
+ " and connecting it. Just follow the steps below.</p>";
} else if (serviceName) {
html += "<p>To make <strong>" + dpEsc(serviceName) + "</strong> available outside your home, it needs a domain.</p>";
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
+ " and connecting it. Just follow the steps below.</p>";
} else {
html += "<p>Your domain is the address people use to reach your services on the internet.</p>";
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
+ " and connecting your services. Just follow the steps below.</p>";
}
// Every variant above ends with a domain that points at the user's home
// connection. Say what that publishes (README: "Server + Desktop and your
// home IP address").
html += "<p>⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection, "
+ "so anyone can look up your home IP address. Domain privacy does not hide it.</p>";
return html;
}
/* The shortest Njal.la steps that still tell the user exactly what to do. */
function renderNjallaStepsHtml(opts) {
var hostExample = dpEsc((opts && opts.hostExample) || "call");
var pasteHint = (opts && opts.pasteHint) || "below";
var html = "";
html += '<p style="margin-top:12px;"><strong>Get and connect your domain:</strong></p>';
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
html += '<li>Open <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>, create an account, and get a domain.</li>';
html += "<li>Open your domain and add a <strong>Dynamic</strong> record for this service. "
+ "In the <strong>Name</strong> box, enter only the first part of the address. "
+ "For <code>" + hostExample + ".yourdomain.com</code>, enter <code>" + hostExample + "</code>. "
+ "If you are using the whole domain, leave the box blank or enter <code>@</code>.</li>";
html += "<li>Copy the update command Njal.la gives you and paste it " + pasteHint + ".</li>";
html += "</ol>";
return html;
}
/* One router task shared by all normal domain-based services. */
function renderRouterPortsHtml(opts) {
opts = opts || {};
var ipPart = opts.internalIp
? " to this computer at <strong>" + dpEsc(opts.internalIp) + "</strong>"
: " to this computer";
var html = "";
html += "🔌 <strong>One router task:</strong> in your router&rsquo;s <strong>Port Forwarding</strong> settings, "
+ "forward <strong>80 (TCP)</strong> and <strong>443 (TCP)</strong>"
+ ipPart + ". If your router asks for outside and inside port numbers, use the same number for both. "
+ "You only need to do this once. All your services share these two ports.";
if (opts.extraNote) {
html += " " + dpEsc(opts.extraNote);
}
return html;
}
/* Show generic wording immediately, then add the computer's address when found. */
function renderRouterPortsBox(elId, opts) {
var el = document.getElementById(elId);
if (!el) return;
el.innerHTML = renderRouterPortsHtml(opts || {});
fetch("/api/network")
.then(function(r) { return r.json(); })
.then(function(data) {
var target = document.getElementById(elId);
if (!target) return;
var ip = data && data.internal_ip;
if (ip && ip !== "unavailable") {
var next = {};
for (var k in (opts || {})) next[k] = opts[k];
next.internalIp = String(ip).trim();
target.innerHTML = renderRouterPortsHtml(next);
}
})
.catch(function() { /* Generic wording is already shown. */ });
}
+17 -99
View File
@@ -4,21 +4,8 @@
// if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); // moved to sidebar in tiles.js
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
if ($btnCheckAgain) $btnCheckAgain.addEventListener("click", function() { openUpdateModal(); });
if ($updateCloseBtn) $updateCloseBtn.addEventListener("click", closeUpdateModal);
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
if ($btnRetryUpdate) $btnRetryUpdate.addEventListener("click", retryUpdateStatus);
if ($btnRetryRun) $btnRetryRun.addEventListener("click", retryUpdateRun);
// Browser timers and requests may be suspended while an RDP session/tab is in
// the background. Reconcile immediately when the user returns instead of
// waiting for the next interval.
window.addEventListener("focus", resumeUpdateStatusAfterInterruption);
window.addEventListener("online", resumeUpdateStatusAfterInterruption);
document.addEventListener("visibilitychange", function() {
if (document.visibilityState === "visible") resumeUpdateStatusAfterInterruption();
});
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
@@ -30,7 +17,6 @@ if ($logoutBtn) $logoutBtn.addEventListener("click", function () {
// Rebuild modal
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
if ($rebuildCloseHdr) $rebuildCloseHdr.addEventListener("click", closeRebuildModal);
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport);
if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); });
@@ -58,28 +44,6 @@ 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() {
@@ -90,37 +54,6 @@ 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…"; }
@@ -178,9 +111,9 @@ function showSecurityBanner() {
'</div>' +
'<button class="security-banner-dismiss" id="security-banner-dismiss-btn" title="Dismiss">\u2715</button>';
var contentArea = document.querySelector(".content");
if (contentArea) {
contentArea.insertAdjacentElement("afterbegin", banner);
var mainContent = document.querySelector(".main-content");
if (mainContent) {
mainContent.insertAdjacentElement("beforebegin", banner);
} else {
document.body.insertAdjacentElement("afterbegin", banner);
}
@@ -201,39 +134,30 @@ function showSecurityBanner() {
// ── Init ──────────────────────────────────────────────────────────
async function init() {
// These lightweight requests are independent. Running them together avoids
// making the dashboard wait through three serial network round-trips before
// it can even start loading the service tiles.
var onboardingStatus = null;
var bannerData = null;
var cfg;
// Check onboarding status first — redirect to wizard if not complete
try {
var startupResults = await Promise.all([
apiFetch("/api/onboarding/status").catch(function() { return null; }),
apiFetch("/api/security/banner-status").catch(function() { return null; }),
apiFetch("/api/config"),
]);
onboardingStatus = startupResults[0];
bannerData = startupResults[1];
cfg = startupResults[2];
} catch (_) {
// If config cannot be loaded, continue with the normal service fallback.
cfg = null;
}
if (onboardingStatus && !onboardingStatus.complete) {
var onboardingStatus = await apiFetch("/api/onboarding/status");
if (!onboardingStatus.complete) {
window.location.href = "/onboarding";
return;
}
} catch (_) {
// If we can't reach the endpoint, continue to normal dashboard
}
// 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 {
if (!cfg) throw new Error("Hub config unavailable");
var cfg = await apiFetch("/api/config");
_currentRole = cfg.role || "server_plus_desktop";
if (cfg.category_order) {
for (var i = 0; i < cfg.category_order.length; i++) {
@@ -242,11 +166,10 @@ async function init() {
}
var badge = document.getElementById("role-badge");
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
window._roleLabel = cfg.role_label || "";
await refreshServices();
loadNetwork();
checkUpdates();
await refreshServices();
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
@@ -256,18 +179,13 @@ async function init() {
}
loadAutolaunchToggle();
} catch (_) {
await refreshServices();
loadNetwork();
checkUpdates();
await refreshServices();
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
loadAutolaunchToggle();
}
// If the page was reloaded or the RDP/browser session resumed during an
// update, reopen the modal from the persisted backend state. This also
// surfaces a completed update that is waiting for its activation reboot.
await restoreUpdateModalIfNeeded();
}
document.addEventListener("DOMContentLoaded", init);
+104 -65
View File
@@ -59,9 +59,6 @@ function openDomainSetupModal(feat, onSaved) {
if (!$domainSetupModal) return;
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
var isBtcpay = feat.id === "btcpay-web";
var npubField = "";
if (feat.id === "haven") {
var currentNpub = "";
@@ -76,31 +73,48 @@ 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 nwcWarning = isWalletConnections
? '<div class="domain-nwc-warning">' +
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
'</div>'
: '';
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : (isBtcpay ? "btcpay.yourdomain.com" : "myservice.example.com");
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : (isBtcpay ? "btcpay.yourdomain.com" : "call.yourdomain.com");
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
// identical wording to the Server + Desktop onboarding wizard and every other
// domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
var hostExample = isWalletConnections ? "lightning" : (isBtcpay ? "btcpay" : "call");
var purpose = isBtcpay ? "btcpay" : "";
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">' +
nwcWarning +
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample, purpose: purpose }) +
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
'<p style="margin-top:10px;">Enter the address for this service and paste the update command from Njal.la.</p>' +
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">Service address (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=' + domainPlaceholder + '&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint">Paste the update command from Njal.la</p></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>';
@@ -136,21 +150,17 @@ function openDomainSetupModal(feat, onSaved) {
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = "Save & Enable";
var msg = (err && err.message) ? err.message : "Failed to save domain. Please try again.";
alert(msg);
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 isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
var npubField = "";
if (feat.id === "haven") {
var currentNpub = "";
@@ -165,22 +175,11 @@ function openDomainReconfigureModal(feat, existingDomain, 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 nwcWarning = isWalletConnections
? '<div class="domain-nwc-warning">' +
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
'</div>'
: '';
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
var externalIp = _cachedExternalIp || "your external IP";
var currentDomain = existingDomain || "";
$domainSetupBody.innerHTML =
'<div class="domain-setup-intro">' +
nwcWarning +
'<p>Your domain <strong>' + escHtml(currentDomain || "this domain") + '</strong> is configured but isn\'t resolving correctly.</p>' +
'<p><strong>Troubleshooting steps:</strong></p>' +
'<ol>' +
@@ -192,8 +191,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
'<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. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" 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=' + domainPlaceholder + '&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-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>';
@@ -229,13 +228,11 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
} catch (err) {
saveBtn.disabled = false;
saveBtn.textContent = "Save & Update";
var msg = (err && err.message) ? err.message : "Failed to save domain. Please try again.";
alert(msg);
alert("Failed to save domain. Please try again.");
}
});
$domainSetupModal.classList.add("open");
}
function closeDomainSetupModal() {
@@ -252,12 +249,28 @@ function openPortRequirementsModal(featureName, ports, onContinue) {
: '';
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 =
renderPortForwardGuideHtml(ports, {
internalIp: internalIp,
serviceName: featureName,
}) +
'<p class="port-req-hint">💡 This list is always available again on the <strong>' + escHtml(featureName) + '</strong> tile.</p>' +
'<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 +
@@ -335,11 +348,8 @@ async function performFeatureToggle(featId, enabled, extra) {
function handleFeatureToggle(feat, newEnabled) {
if (!newEnabled) {
// Disable: ask confirmation
var disableMessage = (feat.id === "bitcoin-tor-gossip")
? "This will stop advertising your Bitcoin Core onion address to other peers. Nodes that already know the address can still connect through Tor, and no clearnet port will be opened. The system will rebuild. Continue?"
: "This will disable " + feat.name + ". The system will rebuild. Continue?";
openFeatureConfirm(
disableMessage,
"This will disable " + feat.name + ". The system will rebuild. Continue?",
function() { performFeatureToggle(feat.id, false, {}); }
);
return;
@@ -393,15 +403,44 @@ function handleFeatureToggle(feat, newEnabled) {
return;
}
// Always show the full list of ports to forward. Port forwarding happens on
// the router, which this computer cannot inspect — a local check would only
// hide ports the user still has to open.
// 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 (feat.id === "bitcoin-tor-gossip") {
var torGossipConfirmMsg = "This will advertise your Bitcoin Core .onion P2P address through Bitcoin peer gossip. More Tor-capable nodes may discover your node and request historical blocks during IBD, which can use significant upload bandwidth. Your home IP remains hidden and no clearnet port or router forwarding is opened. Continue?";
openFeatureConfirm(torGossipConfirmMsg, proceedAfterConflictCheck);
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 {
@@ -628,16 +667,16 @@ async function loadAutolaunchToggle() {
}
function renderAutolaunchToggle(enabled) {
// The preference lives in the Systems Operational modal (moved from the sidebar)
var slot = document.getElementById("autolaunch-slot");
if (!slot) return;
var old = slot.querySelector(".autolaunch-section");
// Remove existing section if any
var old = $sidebarFeatures.querySelector(".autolaunch-section");
if (old) old.parentNode.removeChild(old);
var section = document.createElement("div");
section.className = "category-section autolaunch-section";
section.innerHTML =
'<div class="section-header">Preferences</div>' +
'<hr class="section-divider" />' +
'<div class="feature-card">' +
'<div class="feature-card-top">' +
'<div class="feature-card-info">' +
@@ -651,7 +690,7 @@ function renderAutolaunchToggle(enabled) {
'</div>' +
'</div>';
slot.appendChild(section);
$sidebarFeatures.appendChild(section);
var input = document.getElementById("autolaunch-toggle-input");
var label = document.getElementById("autolaunch-toggle-label");
+14 -94
View File
@@ -40,57 +40,6 @@ function linkify(str) {
return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="creds-link">$1</a>');
}
// ── Router port-forwarding guide ──────────────────────────────────
// Whether a port is truly reachable can only be judged from OUTSIDE the
// network, so we never show a local "ready" verdict here. We only tell the
// user exactly what to enter in their router.
// Render the protocol cell so TCP / UDP / both is unmistakable.
function portProtocolHtml(protocol) {
var p = String(protocol || "TCP").toUpperCase();
var isTcp = p.indexOf("TCP") !== -1;
var isUdp = p.indexOf("UDP") !== -1;
if (isTcp && isUdp) {
return '<span class="port-proto-badge port-proto-badge--both">TCP + UDP</span>' +
'<span class="port-proto-note">both required</span>';
}
if (isUdp) return '<span class="port-proto-badge port-proto-badge--udp">UDP</span>';
return '<span class="port-proto-badge port-proto-badge--tcp">TCP</span>';
}
// ports: [{ port, protocol, description }]
// opts: { internalIp, serviceName, tableClass, introClass, noteClass }
function renderPortForwardGuideHtml(ports, opts) {
opts = opts || {};
var tableClass = opts.tableClass || "port-req-table";
var introClass = opts.introClass || "port-req-intro";
var noteClass = opts.noteClass || "port-req-hint";
var ipHtml = opts.internalIp
? '<code class="port-req-internal-ip">' + escHtml(opts.internalIp) + '</code>'
: 'this computer&rsquo;s <strong>internal IP</strong>';
var rows = (ports || []).map(function(p) {
return '<tr>' +
'<td class="port-req-port">' + escHtml(p.port) + '</td>' +
'<td class="port-req-proto">' + portProtocolHtml(p.protocol) + '</td>' +
'<td class="port-req-desc">' + escHtml(p.description || "") + '</td>' +
'</tr>';
}).join("");
var forWhat = opts.serviceName
? 'To make <strong>' + escHtml(opts.serviceName) + '</strong> reachable from outside your home, forward these ports to ' + ipHtml + ':'
: 'Forward these ports to ' + ipHtml + ':';
return '<p class="' + introClass + '">' + forWhat + '</p>' +
'<p class="port-req-steps" style="margin-top:6px;margin-bottom:10px;font-size:0.92em;color:#555;">' +
'Set the internal and external port to the <strong>same number</strong>. Match <strong>TCP</strong> or <strong>UDP</strong> exactly. For ranges like <strong>40000-40099</strong>, use your router&rsquo;s range fields (start 40000, end 40099).' +
'</p>' +
'<table class="' + tableClass + '">' +
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
}
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
@@ -102,55 +51,26 @@ function formatDuration(seconds) {
// ── Fetch wrappers ────────────────────────────────────────────────
// Avoid issuing multiple redirects when several startup requests discover an
// expired session at the same time.
let _authRedirectInProgress = false;
async function apiFetch(path, options) {
const res = await fetch(path, options || {});
if (res.status === 401) {
if (!_authRedirectInProgress) {
_authRedirectInProgress = true;
window.location.replace("/login");
}
throw new Error("Unauthenticated");
}
if (!res.ok) {
let detail = res.status + " " + res.statusText;
try {
const body = await res.json();
if (body && body.detail) {
if (typeof body.detail === "string") {
detail = body.detail;
} else if (body.detail && typeof body.detail.message === "string") {
detail = body.detail.message;
}
} else if (body && typeof body.message === "string") {
detail = body.message;
} else if (body && typeof body.error === "string") {
detail = body.error;
}
} catch (e) {}
try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {}
throw new Error(detail);
}
return res.json();
}
async function apiFetchWithTimeout(path, options, timeoutMs) {
var controller = new AbortController();
var fetchOptions = Object.assign({}, options || {});
fetchOptions.signal = controller.signal;
var timer = setTimeout(function() { controller.abort(); }, timeoutMs);
try {
return await apiFetch(path, fetchOptions);
} catch (err) {
if (controller.signal.aborted) {
var timeoutError = new Error("Request timed out");
timeoutError.name = "TimeoutError";
throw timeoutError;
}
throw err;
} finally {
clearTimeout(timer);
}
}
// ── 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)' }
};
+7 -59
View File
@@ -2,52 +2,20 @@
// ── Rebuild modal ─────────────────────────────────────────────────
// Status line + header pill for the rebuild dialog (same presentation
// contract as the update dialog's _setUpdateStatus).
function _setRebuildStatus(text) {
if ($rebuildStatus) $rebuildStatus.textContent = text;
if ($rebuildPill) {
var cls = "upd-pill";
var html;
if (text.charAt(0) === "✓") {
if (text.indexOf("restart required") !== -1) {
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
} else {
cls += " st-active"; html = '<span class="status-dot active"></span>Done';
}
} else if (text.charAt(0) === "✗") {
cls += " st-failed"; html = '<span class="status-dot failed"></span>Failed';
} else {
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Applying…';
}
$rebuildPill.className = cls;
$rebuildPill.innerHTML = html;
}
if ($rebuildStatus) {
var msg = "update-status-msg";
if (text.charAt(0) === "✓") $rebuildStatus.className = (text.indexOf("restart required") !== -1) ? msg + " st-warn" : msg + " st-ok";
else if (text.charAt(0) === "✗") $rebuildStatus.className = msg + " st-err";
else $rebuildStatus.className = msg;
}
}
function openRebuildModal() {
if (!$rebuildModal) return;
_rebuildLog = "";
_rebuildLogOffset = 0;
_rebuildServerDown = false;
_rebuildFinished = false;
_rebuildPollInFlight = false;
_rebuildPollFailures = 0;
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
var label = _rebuildFeatureName || "feature";
_setRebuildStatus(action + " " + label + "…");
if ($rebuildStatus) $rebuildStatus.textContent = action + " " + label + "…";
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
if ($rebuildReboot) $rebuildReboot.style.display = "none";
if ($rebuildSave) $rebuildSave.style.display = "none";
if ($rebuildClose) $rebuildClose.disabled = true;
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = true;
$rebuildModal.classList.add("open");
// Delay first poll slightly to let the rebuild service start and clear stale log
setTimeout(startRebuildPoll, 1500);
@@ -65,7 +33,6 @@ function appendRebuildLog(text) {
}
function startRebuildPoll() {
if (_rebuildPollTimer) clearInterval(_rebuildPollTimer);
pollRebuildStatus();
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
}
@@ -75,15 +42,9 @@ function stopRebuildPoll() {
}
async function pollRebuildStatus() {
if (_rebuildFinished || _rebuildPollInFlight) return;
_rebuildPollInFlight = true;
if (_rebuildFinished) return;
try {
var data = await apiFetchWithTimeout(
"/api/rebuild/status?offset=" + _rebuildLogOffset,
{ cache: "no-store" },
STATUS_POLL_FETCH_TIMEOUT
);
_rebuildPollFailures = 0;
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
if (_rebuildServerDown) { _rebuildServerDown = false; }
if (data.log) appendRebuildLog(data.log);
_rebuildLogOffset = data.offset;
@@ -96,35 +57,22 @@ async function pollRebuildStatus() {
onRebuildDone(data.result === "success");
}
} catch (err) {
_rebuildPollFailures += 1;
// The Hub restarts itself during activation, which briefly drops this poll.
// If polling stays broken long past a normal restart, reload to
// re-authenticate and show the resulting feature state.
if (_rebuildPollFailures >= STATUS_POLL_MAX_FAILURES) {
_rebuildFinished = true;
stopRebuildPoll();
window.location.reload();
return;
}
if (!_rebuildServerDown) { _rebuildServerDown = true; _setRebuildStatus("Applying changes…"); }
} finally {
_rebuildPollInFlight = false;
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
}
}
function onRebuildDone(result) {
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
if ($rebuildClose) $rebuildClose.disabled = false;
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = false;
if (result === true) {
_setRebuildStatus("✓ Done");
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") {
_setRebuildStatus("✓ Done — restart required");
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — reboot required";
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
} else {
_setRebuildStatus("✗ Something went wrong");
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
}
+12 -14
View File
@@ -5,12 +5,7 @@
function openSecurityModal() {
if ($supportModal) $supportModal.classList.add("open");
var title = document.getElementById("support-modal-title");
if (title) title.textContent = "Security";
var chip = document.getElementById("support-modal-chip");
if (chip) {
var use = chip.querySelector("use");
if (use) use.setAttribute("href", "#g-shield-check");
}
if (title) title.textContent = "\uD83D\uDEE1 Security";
if ($supportBody) {
$supportBody.innerHTML =
@@ -150,25 +145,28 @@ function openSecurityModal() {
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 + ")";
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + 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";
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now";
} else {
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + 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();
rebootBtn.textContent = "Rebooting\u2026";
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
_rebootStartTime = Date.now();
_serverWentDown = false;
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
var rebootCtrl = new AbortController();
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal }).catch(function() {});
}, { once: true });
}
} catch (err) {
File diff suppressed because it is too large. Load diff
@@ -6,13 +6,9 @@ let _servicesCache = [];
let _categoryLabels = {};
let _updateLog = "";
let _updatePollTimer = null;
let _updatePollInFlight = false;
let _updateLogOffset = 0;
let _updateVisibleLogChars = 0;
let _serverWasDown = false;
let _updateFinished = false;
let _updateStatusUnavailable = false;
let _updatePollFailures = 0; // consecutive failed update-status polls
let _supportTimerInt = null;
let _supportEnabledAt = null;
let _supportStatus = null; // last fetched /api/support/status payload
@@ -27,10 +23,8 @@ let _featuresData = null;
let _rebuildLog = "";
let _rebuildLogOffset = 0;
let _rebuildPollTimer = null;
let _rebuildPollInFlight = false;
let _rebuildFinished = false;
let _rebuildServerDown = false;
let _rebuildPollFailures = 0; // consecutive failed rebuild-status polls
let _pendingToggle = null; // {feature, extra} waiting for domain/confirm
let _rebuildFeatureName = "";
let _rebuildIsEnabling = true;
@@ -52,18 +46,9 @@ const $modalStatus = document.getElementById("modal-status");
const $modalLog = document.getElementById("modal-log");
const $btnReboot = document.getElementById("btn-reboot");
const $btnSave = document.getElementById("btn-save-report");
const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
const $btnRetryRun = document.getElementById("btn-retry-update");
const $btnCloseModal = document.getElementById("btn-close-modal");
const $btnCheckAgain = document.getElementById("btn-check-again");
const $updateCloseBtn = document.getElementById("update-close-btn");
const $updPill = document.getElementById("upd-pill");
const $updLastChecked = document.getElementById("upd-last-checked");
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");
@@ -84,8 +69,6 @@ const $rebuildLog = document.getElementById("rebuild-log");
const $rebuildReboot = document.getElementById("rebuild-reboot-btn");
const $rebuildSave = document.getElementById("rebuild-save-report");
const $rebuildClose = document.getElementById("rebuild-close-btn");
const $rebuildCloseHdr = document.getElementById("rebuild-close-hdr");
const $rebuildPill = document.getElementById("rebuild-pill");
// Feature Manager — domain setup modal
const $domainSetupModal = document.getElementById("domain-setup-modal");
@@ -118,14 +101,5 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
// 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)
+18 -122
View File
@@ -5,14 +5,6 @@
async function openSupportModal() {
if (!$supportModal) return;
$supportModal.classList.add("open");
// The dialog is shared with Security — always restore its identity
var title = document.getElementById("support-modal-title");
if (title) title.textContent = "Tech Support";
var chip = document.getElementById("support-modal-chip");
if (chip) {
var use = chip.querySelector("use");
if (use) use.setAttribute("href", "#g-lifebuoy");
}
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
try {
var status = await apiFetch("/api/support/status");
@@ -118,26 +110,12 @@ function renderSupportInactive() {
'</div>',
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
'<li>Support\'s SSH key is added only to that restricted account — not to root</li>',
'<li>Our SSH key is added only to that restricted account</li>',
'<li>Wallet files are locked via access controls — not visible to support</li>',
'<li>You control if and when wallet access is granted (time-limited)</li>',
'<li>All session events are logged for your audit</li>',
'<li>Access expires automatically after 24 hours</li>',
'</ol></div>',
'<div class="support-key-section">',
'<label class="support-key-label" for="support-ssh-pubkey">',
'<strong>Paste the support SSH public key provided by Sovran Systems:</strong>',
'</label>',
'<textarea id="support-ssh-pubkey" class="support-key-input" rows="3" ',
'placeholder="ssh-ed25519 AAAA… support-session" ',
'spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>',
'<p class="support-key-hint">',
'The key must start with <code>ssh-ed25519</code> or <code>ecdsa-sha2-nistp256</code>. ',
'Do not paste your own private key — only paste the one-time public key sent by Sovran Systems support.',
'</p>',
'</div>',
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
'<p id="support-key-error" class="support-key-error" style="display:none;color:#c0392b;margin-top:8px;"></p>',
'<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>',
'</div>',
].join("");
@@ -249,43 +227,16 @@ function renderSupportRemoved(verified) {
async function enableSupport() {
var btn = document.getElementById("btn-support-enable");
var errEl = document.getElementById("support-key-error");
var textarea = document.getElementById("support-ssh-pubkey");
if (errEl) { errEl.style.display = "none"; errEl.textContent = ""; }
var sshKey = textarea ? textarea.value.trim() : "";
if (!sshKey) {
if (errEl) { errEl.textContent = "Please paste the SSH public key provided by Sovran Systems support."; errEl.style.display = "block"; }
return;
}
// Client-side pre-validation: key must start with a known algorithm prefix
var validPrefixes = ["ssh-ed25519 ", "ecdsa-sha2-nistp256 ", "ecdsa-sha2-nistp384 ", "ecdsa-sha2-nistp521 ", "sk-ssh-ed25519@openssh.com "];
var hasValidPrefix = validPrefixes.some(function(p) { return sshKey.startsWith(p); });
if (!hasValidPrefix) {
if (errEl) { errEl.textContent = "Invalid key format. The key must start with ssh-ed25519 or ecdsa-sha2-nistp256. Do not paste a private key."; errEl.style.display = "block"; }
return;
}
if (sshKey.indexOf("\n") !== -1) {
if (errEl) { errEl.textContent = "The key must be a single line. Please check the pasted value."; errEl.style.display = "block"; }
return;
}
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
try {
await apiFetch("/api/support/enable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ssh_public_key: sshKey }),
});
await apiFetch("/api/support/enable", { method: "POST" });
var status = await apiFetch("/api/support/status");
_supportStatus = status;
_supportEnabledAt = status.enabled_at;
renderSupportActive(status);
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
var detail = (err && err.detail) ? err.detail : "Failed to enable support access. Please check the key and try again.";
if (errEl) { errEl.textContent = detail; errEl.style.display = "block"; }
else { alert(detail); }
alert("Failed to enable support access. Please try again.");
}
}
@@ -522,27 +473,11 @@ function renderBackupReady(drives) {
].join("");
}
// ── Role-aware backup description ─────────────────────────────
// Desktop Only systems run no server or Bitcoin services and have no
// internal second drive, so the backup mirrors only the NixOS configuration
// and home directory. nix-bitcoin secrets, /var/lib system service data,
// and the database/blockchain caveats apply only to the Node and
// Server + Desktop roles.
var isDesktopOnly = (_currentRole === "desktop");
$supportBody.innerHTML = [
'<div class="support-section">',
'<div class="support-icon-big">\ud83d\udcbe</div>',
'<h3 class="support-heading">Manual Backup</h3>',
var introHtml;
if (isDesktopOnly) {
introHtml = [
'<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
'<p class="support-wallet-desc">',
'This manual backup lets you create a copy of your system on an external USB drive \u2014 ',
'storing your data in a second location, outside the computer, for maximum protection ',
'against hardware failure or physical damage.',
'</p>',
'</div>',
].join("");
} else {
introHtml = [
'<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
'<p class="support-wallet-desc">',
'Your Sovran Pro already backs up your data automatically to its internal second drive. ',
@@ -551,70 +486,32 @@ function renderBackupReady(drives) {
'against hardware failure or physical damage.',
'</p>',
'</div>',
].join("");
}
var backupItemsHtml;
if (isDesktopOnly) {
backupItemsHtml = [
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
'<li>Home directory (<code>/home</code>)</li>',
].join("");
} else {
backupItemsHtml = [
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</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>',
].join("");
}
// The database/blockchain caveat is only relevant when server services exist.
var dbNoteHtml = "";
if (!isDesktopOnly) {
dbNoteHtml = [
'<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>',
].join("");
}
$supportBody.innerHTML = [
'<div class="support-section">',
'<div class="support-icon-big">\ud83d\udcbe</div>',
'<h3 class="support-heading">Manual Backup</h3>',
introHtml,
'<div class="support-steps">',
'<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>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>',
'<li>At least 500 GB of free space on the drive</li>',
'<li>Drive must be formatted as <strong>exFAT</strong></li>',
'</ol>',
'</div>',
'<div class="support-steps">',
'<div class="support-steps-title">What gets backed up</div>',
'<ol class="support-backup-steps">',
backupItemsHtml,
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
'<li>System service data (<code>/var/lib</code>) including Vaultwarden, bitcoind, LND, sovran-hub, domains, and secrets</li>',
'<li>Home directory (<code>/home</code>)</li>',
'</ol>',
'</div>',
dbNoteHtml,
'<div class="support-wallet-box support-wallet-protected">',
'<div class="support-wallet-box support-wallet-warning">',
'<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">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>',
'<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>',
'</div>',
driveSelector,
@@ -687,10 +584,9 @@ async function pollBackupStatus() {
logDiv.scrollTop = logDiv.scrollHeight;
}
_backupLogOffset = data.offset;
const result = (data.result || "").toLowerCase();
if (result === "success" || result === "failed") {
if (!data.running) {
stopBackupPoll();
renderBackupDone(result === "success");
renderBackupDone(data.result === "success");
}
} catch (_) {}
}
@@ -722,7 +618,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 ext4. 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 exFAT. 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>',
+77 -49
View File
@@ -4,6 +4,14 @@
// 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");
@@ -25,7 +33,6 @@ function buildTiles(services, categoryLabels) {
continue;
}
var cat = svc.category || "other";
if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(svc);
}
@@ -53,7 +60,6 @@ function buildTiles(services, categoryLabels) {
if ($tilesArea.children.length === 0) {
$tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>';
}
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
}
function renderSidebarSupport(supportServices) {
@@ -64,22 +70,20 @@ function renderSidebarSupport(supportServices) {
sidebarUpdateBtn.className = "sidebar-support-btn";
sidebarUpdateBtn.id = "sidebar-btn-update";
sidebarUpdateBtn.innerHTML =
'<span class="sidebar-support-icon"><svg><use href="#g-update"/></svg></span>' +
'<img class="sidebar-support-icon" src="/static/icons/update.svg" alt="Update" style="width:1.5rem;height:1.5rem;">' +
'<span class="sidebar-support-text">' +
'<span class="sidebar-support-title">Update System</span>' +
'<span class="sidebar-support-hint" id="sidebar-update-hint">Check for updates</span>' +
'</span>';
sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); });
$sidebarSupport.appendChild(sidebarUpdateBtn);
// checkUpdates may already have run before the sidebar was built
applyUpdateSidebarState(window._lastUpdateCheck);
for (var i = 0; i < supportServices.length; i++) {
var svc = supportServices[i];
var btn = document.createElement("button");
btn.className = "sidebar-support-btn";
btn.innerHTML =
'<span class="sidebar-support-icon"><svg><use href="#g-lifebuoy"/></svg></span>' +
'<span class="sidebar-support-icon">🛟</span>' +
'<span class="sidebar-support-text">' +
'<span class="sidebar-support-title">' + escHtml(svc.name || "Tech Support") + '</span>' +
'<span class="sidebar-support-hint">Click for help</span>' +
@@ -92,7 +96,7 @@ function renderSidebarSupport(supportServices) {
var backupBtn = document.createElement("button");
backupBtn.className = "sidebar-support-btn";
backupBtn.innerHTML =
'<span class="sidebar-support-icon"><svg><use href="#g-box"/></svg></span>' +
'<span class="sidebar-support-icon">💾</span>' +
'<span class="sidebar-support-text">' +
'<span class="sidebar-support-title">Manual Backup</span>' +
'<span class="sidebar-support-hint">Back up to external drive</span>' +
@@ -104,7 +108,7 @@ function renderSidebarSupport(supportServices) {
var securityBtn = document.createElement("button");
securityBtn.className = "sidebar-support-btn";
securityBtn.innerHTML =
'<span class="sidebar-support-icon"><svg><use href="#g-shield-check"/></svg></span>' +
'<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>' +
@@ -117,7 +121,7 @@ function renderSidebarSupport(supportServices) {
var upgradeBtn = document.createElement("button");
upgradeBtn.className = "sidebar-support-btn";
upgradeBtn.innerHTML =
'<span class="sidebar-support-icon"><svg><use href="#g-antenna"/></svg></span>' +
'<span class="sidebar-support-icon">🚀</span>' +
'<span class="sidebar-support-text">' +
'<span class="sidebar-support-title">Upgrade to Full Server</span>' +
'<span class="sidebar-support-hint">Unlock all services</span>' +
@@ -152,10 +156,13 @@ function buildTile(svc) {
var pct = Math.round((svc.sync_progress || 0) * 100);
var id = tileId(svc);
var eta = _calcBtcEta(id, svc.sync_progress || 0);
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-sync-container">' +
'<div class="tile-sync-label">\u23F3 Syncing Timechain</div>' +
'<div class="tile-sync-bar-row">' +
@@ -171,7 +178,10 @@ function buildTile(svc) {
return tile;
}
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="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
var ver = svc.version || svc.bitcoin_version || '';
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</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() {
@@ -225,6 +235,22 @@ function updateTiles(services) {
if (fill) fill.style.width = pct + "%";
if (pctEl) pctEl.textContent = pct + "%";
if (etaEl) etaEl.textContent = etaText;
// Update or insert version label
var syncVer = svc.version || svc.bitcoin_version || '';
if (syncVer) {
var syncVerEl = tile.querySelector(".tile-version");
if (syncVerEl) {
syncVerEl.textContent = syncVer;
} else {
var syncNameEl = tile.querySelector(".tile-name");
if (syncNameEl) {
var newSyncVerEl = document.createElement("div");
newSyncVerEl.className = "tile-version";
newSyncVerEl.textContent = syncVer;
syncNameEl.insertAdjacentElement("afterend", newSyncVerEl);
}
}
}
} else {
// IBD finished or not syncing — if tile had sync layout rebuild it normally
if (tile.querySelector(".tile-sync-container")) {
@@ -239,9 +265,41 @@ function updateTiles(services) {
var text = tile.querySelector(".status-text");
if (dot) dot.className = "status-dot " + sc;
if (text) text.textContent = st;
// Update or insert version label for all service tiles
var tileVer = svc.version || svc.bitcoin_version || '';
if (tileVer) {
var verEl = tile.querySelector(".tile-version");
if (verEl) {
verEl.textContent = tileVer;
} else {
var nameEl = tile.querySelector(".tile-name");
if (nameEl) {
var newVerEl = document.createElement("div");
newVerEl.className = "tile-version";
newVerEl.textContent = tileVer;
nameEl.insertAdjacentElement("afterend", newVerEl);
}
}
}
// 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);
}
}
}
}
}
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
}
// ── Service polling ───────────────────────────────────────────────
@@ -253,10 +311,6 @@ async function refreshServices() {
var services = await apiFetch("/api/services");
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
else { updateTiles(services); }
// Service data has rendered — the dashboard is ready; lift the boot
// splash (no-op once lifted). Note: dashboardServicesUpdated may also
// fire from checkUpdates before this resolves, so the lift lives here.
if (typeof window.__liftAppSplash === "function") window.__liftAppSplash();
} catch (err) { console.warn("Failed to fetch services:", err); }
}
@@ -276,33 +330,16 @@ async function loadNetwork() {
// ── Update check ──────────────────────────────────────────────────
// Paint the sidebar Update button from the last known update state.
// Called after each check AND when the button is (re)built, so the hint
// is correct regardless of which finishes first at startup.
function applyUpdateSidebarState(data) {
if (!data) return;
async function checkUpdates() {
try {
var data = await apiFetch("/api/updates/check");
var hasUpdates = !!data.available;
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
if (!sidebarUpdateBtn) return;
var hasUpdates = !!data.available;
var updateStatus = data.status || "idle";
if (updateStatus === "failed") {
// Last update errored and did not apply — surface it as a persistent
// red banner that re-opens the failed run with a "Retry Update" action.
sidebarUpdateBtn.style.borderColor = "#f66151";
sidebarUpdateBtn.style.backgroundColor = "rgba(246, 97, 81, 0.10)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update failed — click to retry";
} else if (updateStatus === "reboot_required") {
sidebarUpdateBtn.style.borderColor = "#e9b64a";
sidebarUpdateBtn.style.backgroundColor = "rgba(233, 182, 74, 0.10)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
} else if (updateStatus === "running") {
sidebarUpdateBtn.style.borderColor = "#78aeed";
sidebarUpdateBtn.style.backgroundColor = "rgba(120, 174, 237, 0.10)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
} else if (hasUpdates) {
sidebarUpdateBtn.style.borderColor = "#3ecf8e";
sidebarUpdateBtn.style.backgroundColor = "rgba(62, 207, 142, 0.10)";
if (sidebarUpdateBtn) {
if (hasUpdates) {
sidebarUpdateBtn.style.borderColor = "#2ec27e";
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
} else {
sidebarUpdateBtn.style.borderColor = "";
@@ -310,14 +347,5 @@ function applyUpdateSidebarState(data) {
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
}
}
async function checkUpdates() {
try {
var data = await apiFetch("/api/updates/check");
window._lastUpdateCheck = data;
if (typeof markUpdateChecked === "function") markUpdateChecked();
applyUpdateSidebarState(data);
// The welcome dashboard's updates card reads this state
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
} catch (_) {}
}
+32 -310
View File
@@ -2,129 +2,20 @@
// ── Update modal ──────────────────────────────────────────────────
// ── Update dialog presentation (pill, status line, last-checked) ──
var _updateLastCheckTs = 0;
function markUpdateChecked() {
_updateLastCheckTs = Date.now();
}
function _updateLastCheckedText() {
if (!$updLastChecked) return;
if (!_updateLastCheckTs) { $updLastChecked.textContent = "—"; return; }
var s = Math.floor((Date.now() - _updateLastCheckTs) / 1000);
if (s < 30) { $updLastChecked.textContent = "just now"; return; }
var m = Math.floor(s / 60);
if (m < 60) { $updLastChecked.textContent = m + (m === 1 ? " minute ago" : " minutes ago"); return; }
var h = Math.floor(m / 60);
$updLastChecked.textContent = h + (h === 1 ? " hour ago" : " hours ago");
}
function setUpdatePill(state) {
if (!$updPill) return;
var cls = "upd-pill";
var html;
if (state === "checking") {
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Checking…';
} else if (state === "uptodate") {
cls += " st-active"; html = '<span class="status-dot active"></span>Up to date';
} else if (state === "complete") {
cls += " st-active"; html = '<span class="status-dot active"></span>Update complete';
} else if (state === "reboot") {
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
} else if (state === "failed") {
cls += " st-failed"; html = '<span class="status-dot failed"></span>Update failed';
} else if (state === "unavailable") {
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Status unknown';
} else {
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Updating…';
}
$updPill.className = cls;
$updPill.innerHTML = html;
}
// Single place that reflects update state in the dialog: status message
// text + color, header pill, and the Close / Check again availability.
function _setUpdateStatus(text) {
if ($modalStatus) $modalStatus.textContent = text;
var state;
if (text.charAt(0) === "✗") state = "failed";
else if (text.indexOf("restart required") !== -1) state = "reboot";
else if (text.charAt(0) === "✓") state = (text.indexOf("already up to date") !== -1) ? "uptodate" : "complete";
else if (text.indexOf("unavailable") !== -1) state = "unavailable";
else if (text.indexOf("Checking") === 0) state = "checking";
else state = "updating";
if ($modalStatus) {
var msg = "update-status-msg";
if (state === "failed") $modalStatus.className = msg + " st-err";
else if (state === "reboot" || state === "unavailable") $modalStatus.className = msg + " st-warn";
else if (state === "uptodate" || state === "complete") $modalStatus.className = msg + " st-ok";
else $modalStatus.className = msg;
// In the up-to-date state the green console line already says it —
// the mockup shows it exactly once.
$modalStatus.style.display = (state === "uptodate") ? "none" : "";
}
setUpdatePill(state);
var interactive = (state !== "updating");
if ($updateCloseBtn) $updateCloseBtn.disabled = !interactive;
if ($btnCheckAgain) {
$btnCheckAgain.disabled = (state === "checking" || state === "updating");
$btnCheckAgain.style.display = interactive ? "inline-flex" : "none";
}
_updateLastCheckedText();
}
async function openUpdateModal() {
function openUpdateModal() {
if (!$modal) return;
// Open immediately in a checking state; the status/check requests below
// fill in the real result (mockup: auto-check on open).
$modal.classList.add("open");
if ($modalLog) $modalLog.innerHTML = '<span class="dim">Checking for updates…</span>';
_setUpdateStatus("Checking for updates…");
// Reattach before checking for new updates. This makes a browser reload,
// RDP reconnect, or suspended tab recover the authoritative systemd-backed
// state instead of starting over or claiming the system is merely up to date.
try {
var current = await apiFetchWithTimeout(
"/api/updates/status?offset=0",
{ cache: "no-store" },
STATUS_POLL_FETCH_TIMEOUT
);
if (current.running || current.result === "reboot_required" || current.result === "failed") {
// An in-progress update, a staged update awaiting reboot, or a prior
// failed update — reattach to the persisted systemd/log state instead of
// starting over or wrongly reporting "up to date".
showExistingUpdate(current);
return;
}
} catch (_) {
// The normal start path below has its own visible error handling.
}
apiFetchWithTimeout(
"/api/updates/check",
{ cache: "no-store" },
STATUS_POLL_FETCH_TIMEOUT
)
apiFetch("/api/updates/check")
.then(function(data) {
markUpdateChecked();
if (!data.available) {
stopUpdatePoll();
_updateLog = "";
_updateLogOffset = 0;
_updateVisibleLogChars = 0;
_updateFinished = true;
_updateStatusUnavailable = false;
if ($modalLog) $modalLog.innerHTML = '<span class="ok">✓ System is already up to date</span>';
_setUpdateStatus("✓ System is already up to date");
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 ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnRetryRun) $btnRetryRun.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false;
$modal.classList.add("open");
return;
@@ -136,70 +27,22 @@ async function openUpdateModal() {
});
}
function prepareUpdateModal() {
function _doOpenUpdateModal() {
if (!$modal) return;
stopUpdatePoll();
_updateLog = "";
_updateLogOffset = 0;
_updateVisibleLogChars = 0;
_updatePollInFlight = false;
_serverWasDown = false;
_updateFinished = false;
_updateStatusUnavailable = false;
_updatePollFailures = 0;
if ($modalLog) $modalLog.textContent = "";
_setUpdateStatus("Starting update…");
if ($modalStatus) $modalStatus.textContent = "Starting update…";
if ($modalSpinner) $modalSpinner.classList.add("spinning");
if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnRetryRun) $btnRetryRun.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = true;
$modal.classList.add("open");
}
function _doOpenUpdateModal() {
prepareUpdateModal();
startUpdate();
}
function showExistingUpdate(data) {
prepareUpdateModal();
if (data.log) appendLog(data.log);
_updateLogOffset = Number(data.offset) || 0;
if (data.running) {
_setUpdateStatus("Updating…");
startUpdatePoll();
return;
}
_updateFinished = true;
if (data.result === "reboot_required") {
onUpdateDone("reboot_required");
} else if (data.result === "success") {
onUpdateDone(true);
} else {
onUpdateDone(false);
}
}
async function restoreUpdateModalIfNeeded() {
if (!$modal || $modal.classList.contains("open")) return;
try {
var data = await apiFetchWithTimeout(
"/api/updates/status?offset=0",
{ cache: "no-store" },
STATUS_POLL_FETCH_TIMEOUT
);
if (data.running || data.result === "reboot_required") {
showExistingUpdate(data);
}
} catch (_) {
// Dashboard startup must remain usable when status cannot be reached.
}
}
function closeUpdateModal() {
if (!$modal) return;
$modal.classList.remove("open");
@@ -209,44 +52,27 @@ function closeUpdateModal() {
function appendLog(text) {
if (!text) return;
_updateLog += text;
if ($modalLog) {
// Appending a text node avoids reparsing/replacing the complete log on
// every two-second poll. Trim only occasionally once the visible log is
// large; the complete _updateLog remains available for error reports.
if (_updateVisibleLogChars + text.length > UPDATE_VISIBLE_LOG_MAX_CHARS) {
var tail = _updateLog.slice(-UPDATE_VISIBLE_LOG_TRIM_CHARS);
var notice = "[Earlier update output hidden from this view; it remains in the saved report.]\n\n";
$modalLog.textContent = notice + tail;
_updateVisibleLogChars = notice.length + tail.length;
} else {
$modalLog.appendChild(document.createTextNode(text));
_updateVisibleLogChars += text.length;
}
$modalLog.scrollTop = $modalLog.scrollHeight;
}
if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; }
}
function startUpdate() {
apiFetchWithTimeout(
"/api/updates/run",
{ method: "POST" },
STATUS_POLL_FETCH_TIMEOUT * 2
)
fetch("/api/updates/run", { method: "POST" })
.then(function(response) {
if (!response.ok) return response.text().then(function(t) { throw new Error(t); });
return response.json();
})
.then(function(data) {
if (data.status === "no_updates") {
if ($modalLog) $modalLog.innerHTML = '<span class="ok">✓ System is already up to date</span>';
_setUpdateStatus("✓ System is already up to date");
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 ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnRetryRun) $btnRetryRun.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false;
_updateFinished = true;
return;
}
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
_setUpdateStatus("Updating…");
if ($modalStatus) $modalStatus.textContent = "Updating…";
startUpdatePoll();
})
.catch(function(err) {
@@ -256,7 +82,6 @@ function startUpdate() {
}
function startUpdatePoll() {
if (_updatePollTimer) clearInterval(_updatePollTimer);
pollUpdateStatus();
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
}
@@ -266,45 +91,32 @@ function stopUpdatePoll() {
}
async function pollUpdateStatus() {
// setInterval does not wait for an async callback. The guard prevents a slow
// request from creating overlapping, out-of-order status polls.
if (_updateFinished || _updatePollInFlight) return;
_updatePollInFlight = true;
if (_updateFinished) return;
try {
var data = await apiFetchWithTimeout(
"/api/updates/status?offset=" + _updateLogOffset,
{ cache: "no-store" },
STATUS_POLL_FETCH_TIMEOUT
);
_updatePollFailures = 0;
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
if (_serverWasDown) {
_serverWasDown = false;
if (!data.running) {
// The update finished while the server or browser connection was away.
// Re-fetch from offset 0 so the final result and complete tail agree.
// 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;
_updateVisibleLogChars = 0;
if ($modalLog) $modalLog.textContent = "";
try {
var fullData = await apiFetchWithTimeout(
"/api/updates/status?offset=0",
{ cache: "no-store" },
STATUS_POLL_FETCH_TIMEOUT
);
var fullData = await apiFetch("/api/updates/status?offset=0");
if (fullData.log) appendLog(fullData.log);
_updateLogOffset = fullData.offset;
data = fullData;
} catch (_) {
} 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("[Reconnected — update completed, reboot required.]\n");
appendLog("[Server restarted — update completed, reboot required.]\n");
} else if (data.result === "success") {
appendLog("[Reconnected — update completed successfully.]\n");
appendLog("[Server restarted — update completed successfully.]\n");
} else {
appendLog("[Reconnected — update encountered an error.]\n");
appendLog("[Server restarted — update encountered an error.]\n");
}
_updateFinished = true;
stopUpdatePoll();
@@ -315,8 +127,8 @@ async function pollUpdateStatus() {
}
return;
}
appendLog("[Update status reconnected]\n");
_setUpdateStatus("Updating…");
appendLog("[Server reconnected]\n");
if ($modalStatus) $modalStatus.textContent = "Updating…";
}
if (data.log) appendLog(data.log);
_updateLogOffset = data.offset;
@@ -331,80 +143,23 @@ async function pollUpdateStatus() {
onUpdateDone(false);
}
} catch (err) {
_updatePollFailures += 1;
if (_updatePollFailures >= STATUS_POLL_MAX_FAILURES) {
showUpdateStatusUnavailable();
return;
}
if (!_serverWasDown) {
_serverWasDown = true;
appendLog("\n[Update status connection interrupted — retrying…]\n");
_setUpdateStatus("Reconnecting to update…");
}
} finally {
_updatePollInFlight = false;
}
}
function showUpdateStatusUnavailable() {
_updateFinished = true;
_updateStatusUnavailable = true;
stopUpdatePoll();
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
_setUpdateStatus("Update status unavailable — update may still be running");
appendLog("\n[The Hub could not confirm update status. The background update was not stopped. Select Retry Status after reconnecting.]\n");
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "inline-flex";
if ($btnCloseModal) $btnCloseModal.disabled = false;
}
function retryUpdateStatus() {
if (!$modal) return;
_updateFinished = false;
_updateStatusUnavailable = false;
_updatePollFailures = 0;
_serverWasDown = true;
if ($modalSpinner) $modalSpinner.classList.add("spinning");
_setUpdateStatus("Reconnecting to update…");
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = true;
startUpdatePoll();
}
// Re-run a failed (or never-applied) update from scratch. The backend always
// allows this after a FAILED attempt even though flake.lock may already be
// advanced (the previous build never staged a bootable generation).
function retryUpdateRun() {
if ($btnRetryRun) $btnRetryRun.style.display = "none";
if ($btnSave) $btnSave.style.display = "none";
if ($btnReboot) $btnReboot.style.display = "none";
_doOpenUpdateModal();
}
function resumeUpdateStatusAfterInterruption() {
if (!$modal || !$modal.classList.contains("open")) return;
if (_updateStatusUnavailable) {
retryUpdateStatus();
} else if (!_updateFinished) {
pollUpdateStatus();
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
}
}
function onUpdateDone(result) {
_updateStatusUnavailable = false;
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false;
if (result === true) {
_setUpdateStatus("✓ Update complete");
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else if (result === "reboot_required") {
_setUpdateStatus("✓ Update complete — restart required");
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — reboot required";
if ($btnReboot) $btnReboot.style.display = "inline-flex";
} else {
_setUpdateStatus("✗ Update failed — your system was not changed. Run the update again or save the error report for support.");
if ($btnRetryRun) $btnRetryRun.style.display = "inline-flex";
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
if ($btnSave) $btnSave.style.display = "inline-flex";
if ($btnReboot) $btnReboot.style.display = "none";
if ($btnReboot) $btnReboot.style.display = "inline-flex";
}
}
@@ -420,55 +175,27 @@ function saveErrorReport() {
URL.revokeObjectURL(url);
}
// ── 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");
_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.
});
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal }).catch(function() {});
// Wait before the first check — NixOS shutdown after an update can take 20-40s
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
}
function waitForServerReboot() {
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);
@@ -478,23 +205,18 @@ function waitForServerReboot() {
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() {
clearTimeout(timeoutId);
if (!_serverWentDown) {
_serverWentDown = true;
_setRebootStatus("The computer is restarting\u2026");
}
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
});
}
+143 -47
View File
@@ -1,16 +1,16 @@
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
Drives the 4-step post-install setup flow. */
Drives the 5-step post-install setup flow. */
"use strict";
// ── Constants ─────────────────────────────────────────────────────
const TOTAL_STEPS = 4;
const TOTAL_STEPS = 5;
// Steps to skip per role (step 3 involves domain setup)
// Steps to skip per role (steps 3 and 4 involve domain/port setup)
// Step 2 (timezone/locale) is NEVER skipped — all roles need it.
const ROLE_SKIP_STEPS = {
"desktop": [3],
"node": [3],
"desktop": [3, 4],
"node": [3, 4],
};
// ── Role state (loaded at init) ───────────────────────────────────
@@ -66,7 +66,7 @@ function setStatus(elId, msg, type) {
el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : "");
}
function updateCompleteChecklist() {
function updateStep5Checklist() {
var checklist = document.getElementById("onboarding-checklist");
if (!checklist) return;
var existing = document.getElementById("onboarding-migration-check");
@@ -135,7 +135,8 @@ function showStep(step) {
// Lazy-load step content
if (step === 2) loadStep2();
if (step === 3) loadStep3();
// Step 4 (Complete) is static — no lazy-load needed
if (step === 4) loadStep4();
// Step 5 (Complete) is static — no lazy-load needed
}
// Return the next step number, skipping over role-excluded steps
@@ -318,27 +319,20 @@ async function loadStep3() {
if (!body) return;
try {
// Fetch services and domains in parallel (network info is best-effort below)
// Fetch services, domains, and network info in parallel
var results = await Promise.all([
apiFetch("/api/services"),
apiFetch("/api/domains/status"),
apiFetch("/api/network"),
]);
_servicesData = results[0];
_domainsData = results[1];
var networkData = results[2];
} catch (err) {
body.innerHTML = '<p class="onboarding-error">⚠ Could not load service data: ' + escHtml(err.message) + '</p>';
return;
}
// Best-effort internal IP for the router note — never blocks this step
var internalIp = "";
try {
var networkData = await apiFetch("/api/network");
if (networkData && networkData.internal_ip && networkData.internal_ip !== "unavailable") {
internalIp = String(networkData.internal_ip).trim();
}
} catch (_) {}
// Build set of enabled service units
var enabledUnits = new Set();
(_servicesData || []).forEach(function(svc) {
@@ -350,47 +344,49 @@ async function loadStep3() {
return enabledUnits.has(d.unit);
});
var domainValues = (_domainsData && _domainsData.domains) || {};
var html = "";
if (relevantDomains.length === 0) {
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
} else {
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
// identical wording to the feature-enable domain modal shown for NWC,
// BTCPay Server, and every other domain-based feature, in every role.
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
+ '<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>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 the address for each service and paste its update command from Njal.la.</p>';
// Router note (the same wording is shown again, per-service, whenever a
// domain-based feature is enabled, and lives on each service tile afterwards)
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ renderRouterPortsHtml({ internalIp: internalIp })
+ '</div>';
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
relevantDomains.forEach(function(d) {
var currentVal = domainValues[d.name] || "";
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 Update Command</label>';
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la DDNS Curl Command</label>';
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl &quot;https://njal.la/update/?h=...&amp;k=...&amp;auto&quot;" />';
html += '<p class="onboarding-hint" style="margin-top:4px;">Paste the update command from Njal.la</p>';
html += '<p class="onboarding-hint" style="margin-top:4px;">ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p>';
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-domain="' + escHtml(d.name) + '" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
html += '<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>';
});
}
// Contact email section
var emailVal = domainValues["sslemail"] || "";
// SSL email section
var emailVal = (_domainsData && _domainsData["sslemail"]) || "";
html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
html += '<label class="onboarding-domain-label">📧 Email Address</label>';
html += '<p class="onboarding-hint onboarding-hint--inline">Used to send important notices about your services.</p>';
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>';
@@ -515,10 +511,106 @@ async function saveStep3() {
return true;
}
// ── Step 4: Complete ──────────────────────────────────────────────
// ── Step 4: Port Forwarding ───────────────────────────────────────
async function loadStep4() {
var body = document.getElementById("step-4-body");
if (!body) return;
body.innerHTML = '<p class="onboarding-loading">Loading router setup…</p>';
var networkData = null;
try {
networkData = await apiFetch("/api/network");
} catch (err) {
body.innerHTML = '<p class="onboarding-error">⚠ Could not load network data: ' + escHtml(err.message) + '</p>';
return;
}
var trimmedInternalIp = (networkData && networkData.internal_ip) ? String(networkData.internal_ip).trim() : "";
var internalIp = trimmedInternalIp || "";
var hasInternalIp = !!internalIp;
var ip = escHtml(internalIp || "Could not detect");
var routerIpHelp = hasInternalIp
? "Use this IP address as the destination/internal IP when creating each router forwarding rule."
: "Use this computer’s internal IP as the destination/internal IP when creating each router forwarding rule.";
var destinationInstruction = hasInternalIp
? 'Set the destination/internal IP to <strong>' + ip + '</strong>'
: 'Use this computer’s internal IP as the destination/internal IP';
var html = '<p class="onboarding-port-note" style="margin-bottom:14px;">'
+ '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
+ '</p>';
html += '<div class="onboarding-port-ip">';
html += ' <span class="onboarding-port-ip-label">Forward router traffic to this Sovran_SystemsOS computer:</span>';
html += ' <span class="port-req-internal-ip">' + ip + '</span>';
html += '</div>';
html += '<div class="onboarding-port-note" style="margin:8px 0 16px;">' + routerIpHelp + '</div>';
// Required ports table
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required Router Rules</div>';
html += '<table class="onboarding-port-table">';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&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 / SSL setup</td></tr>';
html += '<tr><td class="port-req-port">443</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTPS</td></tr>';
html += '<tr><td class="port-req-port">22</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Remote SSH access</td></tr>';
html += '</tbody></table>';
html += '</div>';
// Optional ports table
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Element Call Router Rules</div>';
html += '<div style="font-size:0.88em;margin-bottom:8px;color:var(--color-text-muted,#888);">Only add these if you enable Element Call. These ports help video and audio calls connect reliably.</div>';
html += '<table class="onboarding-port-table">';
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&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">7882</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media (UDP mux)</td></tr>';
html += '<tr><td class="port-req-port">5349</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN over TLS</td></tr>';
html += '<tr><td class="port-req-port">3478</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN (STUN/relay)</td></tr>';
html += '<tr><td class="port-req-port">30000-40000</td><td class="port-req-proto">TCP &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: 3</strong> (without Element Call)<br>';
html += '<strong>Total port openings: 8</strong> (with Element Call — 3 required + 5 optional)';
html += '</div>';
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ '⚠ <strong>Ports 80 and 443 must be forwarded first.</strong> '
+ 'Caddy uses these to obtain SSL certificates from Let\'s Encrypt. '
+ 'If they are closed, HTTPS will not work and your services will be unreachable from outside your network.'
+ '</div>';
html += '<details class="onboarding-port-details" style="margin-bottom:16px;">'
+ '<summary class="onboarding-port-details-summary">How to set up port forwarding</summary>'
+ '<ol style="margin:12px 0 0 16px; padding:0; line-height:1.8;">'
+ '<li>Open your router\'s admin panel — usually <code>http://192.168.1.1</code> or <code>http://192.168.0.1</code></li>'
+ '<li>Look for <strong>"Port Forwarding"</strong>, <strong>"NAT"</strong>, or <strong>"Virtual Server"</strong> in the settings</li>'
+ '<li>Create a new rule for each port listed above</li>'
+ '<li>' + destinationInstruction + '</li>'
+ '<li>Set both internal and external port to the same number</li>'
+ '<li>Save and apply changes</li>'
+ '</ol>'
+ '</details>';
html += '<div class="onboarding-port-note" style="margin-top:12px;">'
+ '<strong>Important:</strong> The Hub can show which ports Sovran_SystemsOS needs, but it cannot fully confirm router forwarding from inside your home network. Full public port verification requires an outside internet check.'
+ '</div>';
body.innerHTML = html;
}
// ── Step 5: Complete ──────────────────────────────────────────────
async function completeOnboarding() {
var btn = document.getElementById("step-4-finish");
var btn = document.getElementById("step-5-finish");
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
try {
@@ -541,7 +633,7 @@ function wireNavButtons() {
try {
await apiFetch("/api/migration/password-acknowledge", { method: "POST" });
_migrationOccurred = true;
updateCompleteChecklist();
updateStep5Checklist();
showStep1FromMigration();
} catch (err) {
setStatus("migration-password-status", "⚠ " + err.message, "error");
@@ -577,9 +669,13 @@ function wireNavButtons() {
showStep(nextStep(3));
});
// Step 4: finish
var s4finish = document.getElementById("step-4-finish");
if (s4finish) s4finish.addEventListener("click", completeOnboarding);
// Step 4 → 5 (port forwarding — no save needed)
var s4next = document.getElementById("step-4-next");
if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); });
// Step 5: finish
var s5finish = document.getElementById("step-5-finish");
if (s5finish) s5finish.addEventListener("click", completeOnboarding);
// Back buttons
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
@@ -611,13 +707,13 @@ document.addEventListener("DOMContentLoaded", async function() {
try {
var migration = await apiFetch("/api/migration/password-status");
if (migration && migration.pending) {
updateCompleteChecklist();
updateStep5Checklist();
showMigrationStep(migration.password || "");
return;
}
} catch (_) {}
updateCompleteChecklist();
updateStep5Checklist();
showStep(1);
loadStep1();
});
-283
View File
@@ -1,283 +0,0 @@
"""Sovran Hub — injectable support session operations.
Functions here handle legacy migration, root-key removal, and support-session
expiry. All filesystem paths, clocks, and callback functions are injectable
so that the test suite can exercise the exact production implementations with
temporary files and mocks rather than maintaining separate copies.
All functions depend only on the Python standard library and the co-located
``security_helpers`` module.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import time as _time_module
from typing import Callable
# ── Legacy Njalla curl line regex ─────────────────────────────────────────────
#
# Matches both forms written by old Hub versions:
# curl [flags] https://njal.la/... (unquoted)
# curl [flags] "https://njal.la/..." (quoted — historical form)
#
# Optional flags (in order): --silent, --max-time N, --fail
#
# Rejected outright: semicolons, pipes, backticks, redirects, newlines,
# ${...} except the literal ${IP} placeholder, and any extra arguments.
_LEGACY_NJALLA_CURL_RE = re.compile(
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?'
r'(?:'
r'"(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)"' # group 1: quoted
r'|(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)' # group 2: unquoted
r')$'
)
# The exact base64 blob of the historical fleet-wide root support key that
# was shipped with old releases of Sovran_SystemsOS and must be removed from
# /root/.ssh/authorized_keys on upgrade.
LEGACY_ROOT_KEY_BLOB = (
"AAAAC3NzaC1lZDI1NTE5AAAAIPxPF2Qm11FQxC20wydKtlmn/Bo07YnDda3b9/CyXxQP"
)
def remove_legacy_root_key(
authorized_keys_path: str,
target_blob: str,
*,
audit_fn: Callable[[str, str], None] | None = None,
) -> bool:
"""Remove the exact historical fleet-wide support key from an authorized_keys file.
Identifies the key by its exact base64 blob (``parts[1]``), regardless of
algorithm prefix or comment field. All other keys, blank lines, and comment
lines are preserved unchanged. The file is written back atomically.
Args:
authorized_keys_path: Path to the authorized_keys file to modify.
target_blob: The exact base64 key blob to remove. Only lines whose
second whitespace-delimited field matches this value are removed;
no substring or comment matching is performed.
audit_fn: Optional callback ``(event: str, details: str)`` for audit
logging. The full key blob is **never** passed to this callback.
Returns:
``True`` if the file was modified (at least one line removed),
``False`` if unchanged or absent.
"""
def _audit(event: str, details: str = "") -> None:
if audit_fn:
audit_fn(event, details)
try:
with open(authorized_keys_path, "r") as f:
lines = f.readlines()
except FileNotFoundError:
return False
except OSError:
return False
kept: list[str] = []
removed_count = 0
for line in lines:
stripped = line.rstrip("\n")
parts = stripped.split()
# Key lines have at least two space-separated fields: algorithm + blob.
# Remove only lines whose blob (parts[1]) matches exactly — no
# substring matching, no comment matching.
if len(parts) >= 2 and parts[1] == target_blob:
removed_count += 1
# Audit without logging the key blob itself.
_audit("LEGACY_ROOT_KEY_REMOVED", "removed exact historical root support key")
else:
kept.append(line)
if removed_count == 0:
return False
# Atomic write: mkstemp in same directory + os.replace
auth_dir = os.path.dirname(os.path.abspath(authorized_keys_path))
fd, tmp = tempfile.mkstemp(dir=auth_dir, prefix=".authorized_keys_tmp")
try:
with os.fdopen(fd, "w") as f:
f.writelines(kept)
os.chmod(tmp, 0o600)
os.replace(tmp, authorized_keys_path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
return False
_audit(
"LEGACY_ROOT_KEY_CLEANUP_COMPLETE",
f"removed={removed_count} keys_retained={len(kept)}",
)
return True
def migrate_legacy_njalla_script(
script_path: str,
validate_fn: Callable[[str], str],
save_fn: Callable[[list[str]], None],
load_fn: Callable[[], list[str]],
*,
audit_fn: Callable[[str, str], None] | None = None,
) -> None:
"""Safely migrate legacy curl DDNS lines from a njalla.sh script to JSON store.
Reads the script **without** executing or sourcing it. Parses only the
exact narrow curl-pattern lines (quoted or unquoted) written by old Hub
versions. Any other line is silently discarded — never executed or logged
(it may contain secret tokens).
On successful persistence the script is archived with mode ``0o000`` so
it can no longer be executed. If persistence fails the script is left
**untouched**.
Args:
script_path: Path to the legacy njalla.sh file.
validate_fn: URL validation function; raises ``ValueError`` on invalid
URLs. Callers must substitute the ``${IP}`` placeholder before
calling — this function passes ``url.replace("${IP}", "127.0.0.1")``
to the validator.
save_fn: Callable that atomically writes a ``list[str]`` URL list to
the persistent JSON store.
load_fn: Callable that returns the current ``list[str]`` URL list from
the persistent store.
audit_fn: Optional callback ``(event: str, details: str)`` for audit
logging. Token-bearing URLs are **never** passed to this callback.
"""
def _audit(event: str, details: str = "") -> None:
if audit_fn:
audit_fn(event, details)
try:
with open(script_path, "r") as f:
content = f.read()
except FileNotFoundError:
return
except OSError:
return
existing_urls = load_fn()
new_urls: list[str] = []
for raw_line in content.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or line.startswith("IP=") or line.startswith("#!/"):
continue
m = _LEGACY_NJALLA_CURL_RE.match(line)
if not m:
# Unrecognised line — discard silently, do NOT log (may contain tokens)
continue
# group(1) = quoted form, group(2) = unquoted form
raw_url = m.group(1) or m.group(2)
# Substitute placeholder so host/scheme/path validation works
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
try:
validate_fn(url_to_validate)
except ValueError:
continue # Silently discard invalid/non-Njal.la URLs
if raw_url not in existing_urls and raw_url not in new_urls:
new_urls.append(raw_url)
if new_urls:
combined = existing_urls + new_urls
try:
save_fn(combined)
except Exception:
# Persistence failed — leave the script untouched, return without
# archiving so the migration can be retried.
return
_audit("NJALLA_MIGRATION", f"migrated {len(new_urls)} DDNS URLs from legacy script")
# Archive: remove all permission bits so cron/any mechanism cannot run it
try:
os.chmod(script_path, 0o000)
except OSError:
pass
def expire_if_stale(
status_file: str,
*,
clock_fn: Callable[[], float] | None = None,
disable_fn: Callable[[], bool] | None = None,
audit_fn: Callable[[str, str], None] | None = None,
session_id: str | None = None,
expected_expiry: float | None = None,
max_session_seconds: float = 86400.0,
) -> bool:
"""Expire a support session if its deadline has passed.
**Stale-timer guard:** when ``session_id`` and/or ``expected_expiry`` are
provided (used by the server-side timer callback), the stored session
metadata is compared field-by-field. A mismatch means a replacement
session has been started after this timer was scheduled; in that case the
function returns ``False`` without touching anything.
Args:
status_file: Path to the JSON session metadata file.
clock_fn: Callable returning current Unix time (default: ``time.time``).
disable_fn: Callable that performs the full disable sequence — removes
the support key, removes wallet-unlock metadata, restores deny
ACLs, clears session metadata, and audits the event. If ``None``,
expiry is detected but no action is taken (useful for tests that
want to inspect detection only).
audit_fn: Optional callback ``(event: str, details: str)`` for audit
logging.
session_id: If given, expiry is skipped unless the stored
``session_id`` field matches exactly.
expected_expiry: If given, expiry is skipped unless the stored
``expires_at`` field matches exactly.
max_session_seconds: Legacy fallback: maximum age (from ``enabled_at``)
when ``expires_at`` is absent.
Returns:
``True`` if a session was expired, ``False`` otherwise.
"""
_now = clock_fn if clock_fn is not None else _time_module.time
def _audit(event: str, details: str = "") -> None:
if audit_fn:
audit_fn(event, details)
def _disable() -> bool:
return disable_fn() if disable_fn is not None else True
try:
with open(status_file, "r") as f:
info = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return False
# Stale-timer guard
if session_id is not None and info.get("session_id") != session_id:
return False
if expected_expiry is not None and info.get("expires_at") != expected_expiry:
return False
expires_at = info.get("expires_at")
now = _now()
if expires_at is None:
enabled_at = info.get("enabled_at", 0)
if enabled_at and (now - enabled_at) > max_session_seconds:
_audit("SUPPORT_EXPIRED", "legacy session without expires_at exceeded max duration")
_disable()
return True
return False
if now >= expires_at:
_audit("SUPPORT_EXPIRED", f"session expired at {expires_at:.0f}")
_disable()
return True
return False
-231
View File
@@ -1,231 +0,0 @@
"""system_password.py -- the one place that knows about the 'free' (and root) password.
Rules
* /etc/shadow is the ONLY source of truth for authentication: Hub login == GDM login.
* /var/lib/secrets/free-password (0600 root) is a DISPLAY copy for the Hub's credentials
view. Only set_free_password() writes it, and it is verified against /etc/shadow before
it is shown, so the Hub can never show a stale password.
* Legacy / unknown-password machines go through the *migration* flow the onboarding already
has: generate a new diceware password, show it, and apply it on acknowledgement. Until
then the old password keeps working everywhere (shadow is untouched).
* No keyring code anywhere: the GNOME login keyring is decoupled from the login password
(blank-password keyring, pam_gnome_keyring off), so nothing has to be wiped or re-keyed.
Needs libxcrypt. On NixOS the Hub service gets: environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1"
"""
from __future__ import annotations
import ctypes
import ctypes.util
import hmac
import os
import secrets
import subprocess
import threading
from typing import Callable
USER = "free"
SECRETS_DIR = "/var/lib/secrets"
DISPLAY_FILE = f"{SECRETS_DIR}/free-password"
ROOT_DISPLAY_FILE = f"{SECRETS_DIR}/root-password"
MIGRATION_FILE = f"{SECRETS_DIR}/free-password-migration-newpass" # same path the onboarding UI already uses
LIVE_ISO_MARKER = "/iso" # NixOS live media mounts the image here; never start a migration there
_lock = threading.Lock() # crypt() returns a static buffer; serialise calls
_libcrypt = None
# ── low level ──────────────────────────────────────────────────────────────
def _crypt():
global _libcrypt
if _libcrypt is None:
path = os.environ.get("LIBCRYPT") or ctypes.util.find_library("crypt") or "libcrypt.so.1"
lib = ctypes.CDLL(path)
lib.crypt.restype = ctypes.c_char_p
lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
_libcrypt = lib
return _libcrypt
def shadow_hash(user: str = USER) -> str | None:
"""Password hash from /etc/shadow, or None if the account has none / is locked."""
try:
with open("/etc/shadow", encoding="utf-8") as f:
for line in f:
fields = line.rstrip("\n").split(":")
if fields[0] == user:
h = fields[1]
return h if h and h[0] not in "!*" else None
except OSError:
return None
return None
def verify_system_password(password: str, user: str = USER) -> bool:
"""True iff `password` is the account's current login password
(yescrypt, sha512crypt, ... whatever chpasswd produced)."""
h = shadow_hash(user)
if not h or not password:
return False
with _lock:
out = _crypt().crypt(password.encode(), h.encode())
return out is not None and hmac.compare_digest(out, h.encode())
def _write_secret(path: str, value: str) -> None:
os.makedirs(SECRETS_DIR, mode=0o700, exist_ok=True)
tmp = path + ".tmp"
with open(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w", encoding="utf-8") as f:
f.write(value + "\n")
os.replace(tmp, path)
def _read_secret(path: str) -> str | None:
try:
with open(path, encoding="utf-8") as f:
v = f.read().rstrip("\n")
return v or None
except OSError:
return None
def _chpasswd(user: str, password: str) -> None:
import shutil
bin_ = shutil.which("chpasswd") or "/run/current-system/sw/bin/chpasswd"
subprocess.run([bin_], input=f"{user}:{password}\n", text=True, check=True, capture_output=True)
# ── the single writers ─────────────────────────────────────────────────────
def set_free_password(new_password: str) -> None:
"""System password first, then the display copy. Used by: change-password, reset,
migration acknowledgement, the CLI."""
if not new_password:
raise ValueError("empty password")
_chpasswd(USER, new_password)
_write_secret(DISPLAY_FILE, new_password)
def set_root_password(new_password: str) -> None:
"""Same contract for root (the credentials view lists it too)."""
if not new_password:
raise ValueError("empty password")
_chpasswd("root", new_password)
_write_secret(ROOT_DISPLAY_FILE, new_password)
# ── what the Hub shows ─────────────────────────────────────────────────────
def read_display_password() -> str | None:
"""The 'free' password to SHOW -- only if it really is the current one."""
pw = _read_secret(DISPLAY_FILE)
return pw if pw and verify_system_password(pw) else None
def read_root_display_password() -> str | None:
pw = _read_secret(ROOT_DISPLAY_FILE)
if not pw or ":" in pw: # ':' = scrypt hash written by older reset code -> not showable
return None
return pw if verify_system_password(pw, "root") else None
def _display_state(path: str) -> str:
""""missing" | "legacy" | "present" -- "present" only means a plaintext copy
exists; it can still be stale (use read_display_password() to check that)."""
pw = _read_secret(path)
if pw is None:
return "missing"
return "legacy" if ":" in pw else "present"
# ── migration flow (legacy machines) ────────────────────────────────────────
def migration_pending() -> str | None:
"""The generated password awaiting acknowledgement, if any."""
return _read_secret(MIGRATION_FILE)
def ensure_migration_pending(generate: Callable[[], str]) -> bool:
"""Legacy detection. Call it where onboarding status is computed.
Condition: the account HAS a password (so this is not the first boot) but the Hub
has NO display copy of it at all (file missing or still the old hash format).
Then generate a new password once and park it for the onboarding "migration
password" step. Nothing is applied until the user acknowledges, so the old
password keeps working at GDM and in the Hub. Returns True when a migration is
pending (caller re-opens onboarding).
A copy that exists but merely fails to verify (password changed in GNOME
Settings, `passwd`, or the pre-upgrade Hub) is DRIFT, not a legacy install:
it never triggers a migration and never changes any password -- it is healed
automatically at the next login via record_known_password(pw, only_if_stale=True).
Never fires on the live ISO (/iso) or while shadow is still locked."""
if os.path.exists(LIVE_ISO_MARKER):
return False
if migration_pending():
return True
if shadow_hash() is None:
return False
if _display_state(DISPLAY_FILE) == "present":
return False # copy exists: current, or just stale (drift)
_write_secret(MIGRATION_FILE, generate())
return True
def acknowledge_migration() -> bool:
"""User has written the new password down: make it real, then clear the pending file."""
pw = migration_pending()
if not pw:
return False
set_free_password(pw)
os.remove(MIGRATION_FILE)
return True
def record_known_password(password: str, only_if_stale: bool = False) -> bool:
""""Keep my current password" (migration step): if the user types the correct
current password, store it as the display copy and cancel the migration.
only_if_stale=True is the login-handler heal: write ONLY when an existing
plaintext copy is stale, never create a missing one (a missing copy is the
legacy case and belongs to the migration flow)."""
if not verify_system_password(password):
return False
if only_if_stale and _display_state(DISPLAY_FILE) != "present":
return False
if _read_secret(DISPLAY_FILE) != password:
_write_secret(DISPLAY_FILE, password)
try:
os.remove(MIGRATION_FILE)
except FileNotFoundError:
pass
return True
def generate_diceware_password(words: list[str] | None = None, n: int = 4) -> str:
"""word-word-word-word-NN, same shape the installer/reset produce (CSPRNG)."""
words = words or ("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").split()
return "-".join(secrets.choice(words) for _ in range(n)) + f"-{secrets.randbelow(100):02d}"
if __name__ == "__main__": # tiny CLI for credentials.nix: echo NEW | system_password.py set-free
import sys
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd in ("set-free", "set-root"):
pw = sys.stdin.readline().rstrip("\n")
(set_free_password if cmd == "set-free" else set_root_password)(pw)
print("ok")
elif cmd == "verify":
sys.exit(0 if verify_system_password(sys.stdin.readline().rstrip("\n")) else 1)
else:
print("usage: system_password.py set-free|set-root|verify (password on stdin)", file=sys.stderr)
sys.exit(2)
-39
View File
@@ -18,45 +18,6 @@ def is_active(unit: str, scope: Literal["system", "user"] = "system") -> str:
return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown"
def active_states(units_by_scope: dict[str, list[str]], timeout: float = 5) -> dict[tuple[str, str], str]:
"""Return active states for many units with one systemctl call per scope.
The dashboard polls service state frequently. Spawning one ``systemctl``
process per tile makes a slow or unavailable system bus multiply the delay
(and can make the first dashboard render wait through many timeouts). The
``is-active`` command accepts multiple units, so batch them and cap the
whole batch at a single timeout.
"""
states: dict[tuple[str, str], str] = {}
valid_scopes = {"system", "user"}
for scope, units in units_by_scope.items():
unique_units = list(dict.fromkeys(unit for unit in units if unit))
if not unique_units:
continue
if scope not in valid_scopes:
for unit in unique_units:
states[(scope, unit)] = "unknown"
continue
try:
result = subprocess.run(
["systemctl", f"--{scope}", "is-active", "--", *unique_units],
capture_output=True,
text=True,
timeout=timeout,
)
lines = result.stdout.splitlines()
for index, unit in enumerate(unique_units):
state = lines[index].strip() if index < len(lines) else ""
states[(scope, unit)] = state or "unknown"
except Exception:
for unit in unique_units:
states[(scope, unit)] = "unknown"
return states
def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str:
return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown"
+59 -379
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran_SystemsOS — The Hub</title>
<title>Sovran_SystemsOS Hub</title>
<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 }}" />
@@ -18,324 +18,51 @@
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<!-- service glyphs -->
<!-- ═══ Real service logos — extracted verbatim from app/icons/*.svg ═══
(internal ids namespaced per icon to avoid collisions) -->
<!-- symbolic helpers (nav + modal internals) -->
<!-- Bitcoin nav icon: the OFFICIAL logo silhouette (paths taken verbatim
from app/icons/bitcoin-core.svg) rendered monochrome in currentColor
so it matches the symbolic sidebar set -->
<symbol id="g-btc-sym" viewBox="-34 -34 580 580">
<path fill="currentColor" d="M317.871 7.656c-137.12-34.192-276.024 49.28-310.2 186.44-34.208 137.136 49.256 276.048 186.36 310.24 137.16 34.199 276.063-49.265 310.256-186.408 34.192-137.152-49.264-276.08-186.416-310.272m50.936 211.872c-3.688 24.936-17.512 37.008-35.864 41.24 25.2 13.12 38.024 33.239 25.809 68.12-15.16 43.319-51.176 46.976-99.072 37.912l-11.624 46.584-28.088-7 11.472-45.96a1076 1076 0 0 1-22.384-5.809l-11.512 46.177-28.056-7 11.624-46.673c-6.561-1.68-13.225-3.464-20.024-5.168l-36.552-9.111 13.943-32.152s20.696 5.504 20.416 5.096c7.952 1.969 11.48-3.216 12.872-6.672l18.368-73.64.048-.2 13.104-52.568c.344-5.968-1.712-13.496-13.088-16.336.439-.296-20.4-5.072-20.4-5.072l7.472-30 38.736 9.673-.032.144c5.824 1.448 11.824 2.824 17.937 4.216L245.423 89.2l28.072 7-11.28 45.224c7.536 1.721 15.12 3.456 22.504 5.297l11.2-44.929 28.088 7-11.504 46.145c35.464 12.215 61.401 30.527 56.304 64.591"/>
<path fill="currentColor" d="m254.647 174.6-13.983 56.08c15.855 3.951 64.735 20.071 72.656-11.656 8.248-33.096-42.817-40.472-58.673-44.424"/>
<path fill="currentColor" d="m233.608 258.984-15.425 61.832c19.04 4.729 77.769 23.584 86.448-11.296 9.072-36.376-51.984-45.784-71.023-50.536"/>
</symbol>
<symbol id="g-chat" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 5.5v7a2.5 2.5 0 0 1-2.5 2.5H10l-4 3.2c-.6.5-1 .3-1-.5V5.5A2.5 2.5 0 0 1 7.5 3h10A2.5 2.5 0 0 1 20 5.5z"/>
<circle cx="8.7" cy="9.2" r="0.5" fill="currentColor"/><circle cx="12" cy="9.2" r="0.5" fill="currentColor"/><circle cx="15.3" cy="9.2" r="0.5" fill="currentColor"/>
</g>
</symbol>
<symbol id="g-antenna" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
<path d="M5.4 9.9a9.3 9.3 0 0 1 13.2 0"/>
<path d="M8.1 12.9a5.6 5.6 0 0 1 7.8 0"/>
<circle cx="12" cy="16.6" r="1.5" fill="currentColor" stroke="none"/>
<path d="M12 16.6V21"/>
</g>
</symbol>
<symbol id="g-lock" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<rect x="5.5" y="10.5" width="13" height="9.5" rx="2.6"/>
<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>
<circle cx="12" cy="15" r="1.3" fill="currentColor" stroke="none"/>
</g>
</symbol>
<!-- nav + ui symbolic icons -->
<symbol id="g-home" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 11.2L12 4.5l8 6.7"/>
<path d="M6 10.5v8a1.2 1.2 0 0 0 1.2 1.2h9.6a1.2 1.2 0 0 0 1.2-1.2v-8"/>
<path d="M10 19.7v-5.4h4v5.4"/>
</g>
</symbol>
<symbol id="g-grid" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round">
<rect x="3.5" y="3.5" width="7.2" height="7.2" rx="2"/><rect x="13.3" y="3.5" width="7.2" height="7.2" rx="2"/>
<rect x="3.5" y="13.3" width="7.2" height="7.2" rx="2"/><rect x="13.3" y="13.3" width="7.2" height="7.2" rx="2"/>
</g>
</symbol>
<symbol id="g-server" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round">
<rect x="3.5" y="3.5" width="17" height="7.4" rx="2"/><rect x="3.5" y="13.1" width="17" height="7.4" rx="2"/>
<circle cx="7.2" cy="7.2" r="0.6" fill="currentColor"/><circle cx="7.2" cy="16.8" r="0.6" fill="currentColor"/>
</g>
</symbol>
<symbol id="g-dots" viewBox="0 0 24 24">
<g fill="currentColor">
<circle cx="5" cy="5" r="1.7"/><circle cx="12" cy="5" r="1.7"/><circle cx="19" cy="5" r="1.7"/>
<circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/>
<circle cx="5" cy="19" r="1.7"/><circle cx="12" cy="19" r="1.7"/><circle cx="19" cy="19" r="1.7"/>
</g>
</symbol>
<symbol id="g-refresh" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M20 12a8 8 0 1 1-2.9-6.2"/>
<path d="M20.6 2.6l-.5 4.9-4.6-1.7z" fill="currentColor" stroke="none"/>
</g>
</symbol>
<!-- Update System: down arrow into a tray (get / install updates) -->
<symbol id="g-update" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 4v10.5"/>
<path d="M7.8 10.3L12 14.5l4.2-4.2"/>
<path d="M4.5 16.5v2a2.5 2.5 0 0 0 2.5 2.5h10a2.5 2.5 0 0 0 2.5-2.5v-2"/>
</g>
</symbol>
<symbol id="g-box" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<rect x="3.5" y="4" width="17" height="4.4" rx="1.6"/>
<path d="M5.2 8.4v9a2.4 2.4 0 0 0 2.4 2.4h8.8a2.4 2.4 0 0 0 2.4-2.4v-9"/>
<path d="M10 12.5h4"/>
</g>
</symbol>
<symbol id="g-shield-check" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2.5 4.5 5.5v6c0 4.6 3.1 8 7.5 9.9 4.4-1.9 7.5-5.3 7.5-9.9v-6z"/>
<path d="M8.6 12l2.4 2.4 4.4-4.6"/>
</g>
</symbol>
<!-- Systems Operational: activity pulse (system health) — distinct from
the Security shield above -->
<symbol id="g-pulse" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M3 12h4l3-7 4 14 3-7h4"/>
</symbol>
<symbol id="g-lifebuoy" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<circle cx="12" cy="12" r="8.6"/><circle cx="12" cy="12" r="3.6"/>
<path d="M6 6l3.2 3.2M18 6l-3.2 3.2M6 18l3.2-3.2M18 18l-3.2-3.2"/>
</g>
</symbol>
<symbol id="g-power" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M7.2 6.1a7 7 0 1 0 9.6 0"/>
<path d="M12 2.8v8.4"/>
</g>
</symbol>
<symbol id="g-logout" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14"/>
<path d="M4 12h10M10.5 8.5 14 12l-3.5 3.5"/>
</g>
</symbol>
<symbol id="g-search" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="6.5"/>
<path d="M15.8 15.8 21 21"/>
</g>
</symbol>
<symbol id="g-copy" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="11" height="11" rx="2.2"/>
<path d="M5.5 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v.5"/>
</g>
</symbol>
<symbol id="g-check" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.5l5 5 10-11"/></symbol>
<symbol id="g-chev" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" d="M9.5 5.5 16 12l-6.5 6.5"/></symbol>
<symbol id="g-x" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></symbol>
<symbol id="g-wifi" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
<path d="M4.5 10.2a11 11 0 0 1 15 0"/>
<path d="M7.3 13.3a7 7 0 0 1 9.4 0"/>
<circle cx="12" cy="17" r="1.4" fill="currentColor" stroke="none"/>
</g>
</symbol>
<symbol id="g-key" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<circle cx="7.5" cy="15.5" r="4"/>
<path d="M10.4 12.6 20 3M16.2 6.8l2.4 2.4M18.8 4.2l2.2 2.2"/>
</g>
</symbol>
<symbol id="g-alert" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3.6 22.2 20.4H1.8z"/>
<path d="M12 9.8v4.6"/><circle cx="12" cy="17.2" r="0.6" fill="currentColor" stroke="none"/>
</g>
</symbol>
</svg>
<!-- ═══ BOOT SPLASH ═══ covers the shell while the first data loads;
dashboard.js lifts it once the welcome cards are rendered -->
<div id="app-splash" role="status" aria-live="polite">
<div class="splash-card">
<div class="splash-ring">
<img src="/static/sovran-hub-icon.svg" alt="" width="64" height="64" />
<span class="splash-ring-arc" aria-hidden="true"></span>
</div>
<div class="splash-title">Starting The Hub</div>
<div class="splash-sub" id="splash-sub">Gathering your services…</div>
</div>
</div>
<script>
(function () {
var splash = document.getElementById("app-splash");
var sub = document.getElementById("splash-sub");
var lifted = false;
function lift() {
if (lifted || !splash) return;
lifted = true;
splash.classList.add("done");
setTimeout(function () {
if (splash && splash.parentNode) splash.parentNode.removeChild(splash);
}, 500);
}
window.__liftAppSplash = lift;
// Reassure the user if the backend is slow (e.g. right after a reboot)
setTimeout(function () {
if (!lifted && sub) sub.textContent = "Still starting\u2026 this can take a moment after a reboot.";
}, 8000);
// Never trap the user behind the splash
setTimeout(lift, 25000);
})();
</script>
<div class="app">
<!-- ═══ SIDEBAR ═══ -->
<aside class="sidebar" id="sidebar">
<div class="brand">
<img src="/static/sovran-hub-icon.svg" class="brand-logo" alt="The Hub" />
<div class="brand-text">
<div class="brand-title">The <em>Hub</em></div>
<div class="brand-sub">Sovran_SystemsOS v{{ sovran_version }}</div>
</div>
</div>
<nav id="sidebar-nav" aria-label="Service categories"></nav>
<div class="nav-label">System</div>
<div id="sidebar-support"></div>
<!-- Feature Manager + Preferences (features.js) -->
<div id="sidebar-features"></div>
<div class="sidebar-spacer"></div>
</aside>
<!-- ═══ MAIN ═══ -->
<div class="main">
<header class="topbar">
<div class="page-title" id="page-title">Dashboard</div>
<div class="search-box">
<svg><use href="#g-search"/></svg>
<input id="search-input" type="text" placeholder="Search services…" autocomplete="off" />
</div>
<div class="top-actions">
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
<!-- Header bar -->
<header class="header-bar">
<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-logout" id="btn-logout" title="Sign out">Sign Out</button>
</div>
</header>
<main class="content" id="content">
<!-- ═══ WELCOME DASHBOARD (default view) ═══ -->
<section class="welcome" id="welcome-view">
<div class="welcome-bg" aria-hidden="true">
<div class="orb orb-a"></div>
<div class="orb orb-b"></div>
<div class="orb orb-c"></div>
</div>
<div class="welcome-inner">
<div class="welcome-greeting" id="welcome-greeting">Hello</div>
<h1 class="welcome-title">Welcome to Your <em>Sovereign</em> Digital &amp; Financial Life</h1>
<div class="welcome-meta">Sovran_SystemsOS v{{ sovran_version }}<span class="welcome-meta-sep">·</span><span id="welcome-role"></span></div>
<div class="welcome-cards" id="welcome-cards">
<div class="welcome-dyn" id="wc-systems"></div>
<div class="widget w-network" id="w-network">
<div class="widget-chip chip-green"><svg><use href="#g-wifi"/></svg></div>
<div class="w-body">
<h3>Network</h3>
<div class="net-row"><span class="net-k">LAN</span><span class="ip-value" id="ip-internal">…</span></div>
<div class="net-row"><span class="net-k">WAN</span><span class="ip-value" id="ip-external">…</span></div>
<div class="sub">sovransystemsos.local:8937</div>
</div>
</div>
<div class="welcome-dyn" id="wc-more"></div>
</div>
<button class="btn btn-ghost welcome-browse" id="welcome-browse-btn">Browse all services →</button>
</div>
</section>
<!-- ═══ SERVICES GRID ═══ -->
<div id="tiles-area" style="display:none">
<div class="dashboard-loading" role="status" aria-live="polite">
<span class="dashboard-loading-spinner" aria-hidden="true"></span>
<span>Loading service status…</span>
</div>
</div>
</main>
</div>
</div>
<!-- ═══ SYSTEMS OPERATIONAL MODAL (dashboard.js) ═══ -->
<div class="modal-overlay" id="systems-modal" role="dialog" aria-modal="true" aria-labelledby="systems-modal-title">
<div class="creds-dialog">
<div class="creds-header">
<span class="creds-title" id="systems-modal-title">
<span class="widget-chip chip-green" style="width:54px;height:54px;border-radius:16px"><svg style="width:27px;height:27px"><use href="#g-pulse"/></svg></span>
<span>Systems Operational</span>
<!-- IP bar -->
<div class="ip-bar">
<span>
<span class="ip-label">Internal IP:</span>
<span class="ip-value" id="ip-internal">…</span>
</span>
<span class="ip-separator">|</span>
<span>
<span class="ip-label">External IP:</span>
<span class="ip-value" id="ip-external">…</span>
</span>
<button class="creds-close-btn" id="systems-close-btn" title="Close">✕</button>
</div>
<div class="creds-body" id="systems-body">
<p class="creds-loading">Loading…</p>
</div>
<!-- Auto-launch preference (features.js) — moved here from the sidebar -->
<div class="sysprefs" id="autolaunch-slot"></div>
</div>
</div>
<!-- Service tiles -->
<main class="main-content">
<aside class="sidebar" id="sidebar">
<div id="sidebar-support"></div>
<div id="sidebar-features"></div>
</aside>
<div id="tiles-area"></div>
</main>
<!-- Update modal -->
<div class="modal-overlay" id="update-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-text">
<div class="modal-dialog">
<div class="upd-header">
<span class="widget-chip chip-sovran upd-chip"><svg class="upd-chip-svg"><use href="#g-update"/></svg></span>
<span class="upd-title-wrap">
<span class="upd-title" id="modal-title-text">Sovran_SystemsOS Update</span>
<span class="upd-sub">
<span class="ver-chip">Sovran_SystemsOS v{{ sovran_version }}</span>
<span class="upd-pill" id="upd-pill"></span>
<span class="modal-spinner" id="modal-spinner"></span>
</span>
</span>
<button class="creds-close-btn" id="update-close-btn" title="Close">✕</button>
<div class="modal-header">
<span class="modal-title" id="modal-title-text">Sovran_SystemsOS Update</span>
<div class="modal-spinner" id="modal-spinner"></div>
<span class="modal-status" id="modal-status">Updating…</span>
</div>
<div class="modal-body-scroll">
<div class="sysmodal-card">
<div class="sysmodal-card-title"><svg><use href="#g-server"/></svg>System Details</div>
<div class="sysstep"><div class="sysnum">1</div><div class="sysstep-x"><div class="sysstep-t">Current version</div><div class="sysval"><span class="sysval-text">{{ sovran_version }}</span></div></div></div>
<div class="sysstep"><div class="sysnum">2</div><div class="sysstep-x"><div class="sysstep-t">Release channel</div><div class="sysval"><span class="sysval-text">stable</span></div></div></div>
<div class="sysstep"><div class="sysnum">3</div><div class="sysstep-x"><div class="sysstep-t">Last checked</div><div class="sysval"><span class="sysval-text" id="upd-last-checked">—</span></div></div></div>
</div>
<div class="update-status-msg" id="modal-status">Checking for updates…</div>
<div class="modal-log" id="modal-log" aria-live="polite"></div>
</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-save" id="btn-retry-update-status" style="display:none">Retry Status</button>
<button class="btn btn-reboot" id="btn-retry-update" style="display:none">Retry Update</button>
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
<span class="modal-footer-spacer"></span>
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Reboot</button>
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
<button class="btn btn-primary" id="btn-check-again"><svg><use href="#g-refresh"/></svg>Check again</button>
</div>
</div>
</div>
@@ -357,10 +84,7 @@
<div class="modal-overlay" id="support-modal" role="dialog" aria-modal="true" aria-labelledby="support-modal-title">
<div class="creds-dialog">
<div class="creds-header">
<span class="creds-title">
<span class="widget-chip chip-neutral" id="support-modal-chip" style="width:54px;height:54px;border-radius:16px"><svg style="width:27px;height:27px"><use href="#g-lifebuoy"/></svg></span>
<span id="support-modal-title">Tech Support</span>
</span>
<span class="creds-title" id="support-modal-title">Tech Support</span>
<button class="creds-close-btn" id="support-close-btn" title="Close">✕</button>
</div>
<div class="creds-body" id="support-body">
@@ -432,26 +156,15 @@
<!-- Rebuild Modal -->
<div class="modal-overlay" id="rebuild-modal" role="dialog" aria-modal="true" aria-labelledby="rebuild-modal-title">
<div class="modal-dialog">
<div class="upd-header">
<span class="widget-chip chip-sovran upd-chip"><svg class="upd-chip-svg"><use href="#g-server"/></svg></span>
<span class="upd-title-wrap">
<span class="upd-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
<span class="upd-sub">
<span class="ver-chip">Sovran_SystemsOS v{{ sovran_version }}</span>
<span class="upd-pill" id="rebuild-pill"></span>
<span class="modal-spinner" id="rebuild-spinner"></span>
</span>
</span>
<button class="creds-close-btn" id="rebuild-close-hdr" title="Close">✕</button>
</div>
<div class="modal-body-scroll">
<div class="update-status-msg" id="rebuild-status">Rebuilding…</div>
<div class="modal-log" id="rebuild-log" aria-live="polite" style="display:none"></div>
<div class="modal-header">
<span class="modal-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
<div class="modal-spinner" id="rebuild-spinner"></div>
<span class="modal-status" id="rebuild-status">Rebuilding…</span>
</div>
<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">Restart Entire System</button>
<span class="modal-footer-spacer"></span>
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Reboot</button>
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
</div>
</div>
@@ -470,18 +183,22 @@
encrypted messaging, password management, cloud storage, website hosting, and more.
</p>
<div class="upgrade-info-box">
<p class="upgrade-info-title">Simple, guided setup:</p>
<p class="upgrade-info-title">⚠ What you should know:</p>
<ul class="upgrade-info-list">
<li>Sovran_SystemsOS walks you through getting your domain from <a href="https://njal.la" target="_blank" rel="noopener noreferrer">Njal.la</a> and connecting your services</li>
<li>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</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>
<div class="upgrade-info-box">
<p class="upgrade-info-title">ℹ️ Good to know:</p>
<ul class="upgrade-info-list">
<li>Your services will be accessible via your home internet connection. Your approximate geographic area may be visible through DNS records — this is normal for all self-hosted services</li>
<li>Your Bitcoin node remains fully private over Tor</li>
</ul>
</div>
<p class="support-desc">
⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection,
so anyone can look up your home IP address. Domain privacy does not hide it.
</p>
<p class="support-desc">
The Hub guides you through every step.
<strong>Don't worry</strong> — the Hub will walk you through every step after the upgrade.
Domain setup, port forwarding, and configuration are all guided.
</p>
<p class="support-desc upgrade-rebuild-note">
The system will rebuild after upgrading. This may take several minutes.
@@ -520,71 +237,35 @@
<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:8937</em>.
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
I have written down my new password — Reboot now
</button>
</div>
</div>
<!-- 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>
<div class="reboot-card">
<div class="reboot-icon">↻</div>
<h2 class="reboot-title">System Rebooting</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.
Sovran_SystemsOS is now restarting.<br />
This page will automatically reconnect once the system is back online.
</p>
<div class="reboot-dots" aria-hidden="true">
<div class="reboot-dots">
<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 1–3 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>
<p class="reboot-submessage">Stay tuned…</p>
</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/domain-prereqs.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>
@@ -592,7 +273,6 @@
<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/dashboard.js?v={{ asset_version }}"></script>
<script src="/static/js/events.js?v={{ asset_version }}"></script>
</body>
</html>
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>The Hub — Login</title>
<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>
@@ -11,9 +11,8 @@
<div class="login-wrapper">
<div class="login-card">
<div class="login-header">
<img src="/static/sovran-hub-icon.svg" alt="The Hub" class="login-logo" />
<div class="login-title">The <em>Hub</em></div>
<div class="login-sub">Sovran_SystemsOS</div>
<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;">
@@ -34,6 +34,8 @@
<span class="onboarding-step-dot" data-step="3">3</span>
<span class="onboarding-step-connector"></span>
<span class="onboarding-step-dot" data-step="4">4</span>
<span class="onboarding-step-connector"></span>
<span class="onboarding-step-dot" data-step="5">5</span>
</div>
<!-- Step panels -->
@@ -50,10 +52,10 @@
<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(62, 207, 142, 0.10); border:1.5px solid rgba(62, 207, 142, 0.35); border-radius:8px; padding:14px 24px; letter-spacing:0.04em; text-align:center; word-break:break-all; margin-bottom:8px;">
<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(233, 182, 74, 0.1); border:1px solid rgba(233, 182, 74, 0.35); border-radius:8px; font-size:0.92rem; color:var(--yellow); line-height:1.55;">
<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>
@@ -123,10 +125,11 @@
<div class="onboarding-panel" id="step-3" style="display:none">
<div class="onboarding-step-header">
<span class="onboarding-step-icon">🌐</span>
<h2 class="onboarding-step-title">Connect Your Services</h2>
<h2 class="onboarding-step-title">Domain Configuration</h2>
<p class="onboarding-step-desc">
Sovran_SystemsOS walks you through getting your domain from <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> and connecting your services.
To make your services available outside your home, you’ll complete one router task: forward ports <strong>80</strong> and <strong>443</strong> to this computer.
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.
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" id="step-3-body">
@@ -141,8 +144,29 @@
</div>
</div>
<!-- ── Step 4: Complete ── -->
<!-- ── Step 4: Port Forwarding ── -->
<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">Router Setup</h2>
<p class="onboarding-step-desc">
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" 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>
<button class="btn btn-primary onboarding-btn-next" id="step-4-next">
Continue →
</button>
</div>
</div>
<!-- ── Step 5: Complete ── -->
<div class="onboarding-panel" id="step-5" style="display:none">
<div class="onboarding-hero">
<div class="onboarding-logo">✅</div>
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
@@ -156,11 +180,12 @@
<ul class="onboarding-checklist" id="onboarding-checklist">
<li>✅ Timezone &amp; locale configured</li>
<li>✅ Domain configuration saved</li>
<li>✅ Port forwarding reviewed</li>
</ul>
</div>
<div class="onboarding-footer">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
<button class="btn btn-primary" id="step-4-finish">
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button>
<button class="btn btn-primary" id="step-5-finish">
Go to Dashboard →
</button>
</div>
@@ -169,7 +194,6 @@
</div><!-- /panel-wrap -->
</div><!-- /shell -->
<script src="/static/js/domain-prereqs.js?v={{ asset_version }}"></script>
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
</body>
</html>
-88
View File
@@ -1,88 +0,0 @@
"""Update-state helpers for the Sovran Hub.
The full-system updater stages a NixOS generation with ``nixos-rebuild boot``.
That generation is not active until the machine reboots — and the same is true
for updates started from a terminal or an SSH support session, which never go
near the Hub's status files.
The ONLY reliable indicator that a reboot is pending is NixOS itself: the
system profile (``/nix/var/nix/profiles/system``), which ``nixos-rebuild``
points at the newest generation on every ``boot`` AND every ``switch``, versus
``/run/current-system``, the generation actually running since the last boot.
When the two differ, a staged generation has not been booted yet.
Earlier revisions reconstructed this from a marker file and log tails written
by the Hub's own updater. Any system updated by other means — or whose
``REBOOT_REQUIRED`` status was written by an updater older than the marker
feature — left the Hub showing "Restart required" forever: the recorded
generation could never equal the (since advanced) running one, so the marker
could never be cleared.
This module has no FastAPI or systemd dependencies so the policy can be tested
without importing the Hub server.
"""
from __future__ import annotations
import os
# The NixOS system profile. ``nixos-rebuild boot`` and ``nixos-rebuild
# switch`` both add a generation here; ``boot`` additionally makes it the
# bootloader default. The path is a symlink chain (``system`` ->
# ``system-N-link`` -> ``/nix/store/...-nixos-system-...``).
BOOT_PROFILE_PATH = "/nix/var/nix/profiles/system"
CURRENT_SYSTEM_PATH = "/run/current-system"
def reboot_is_pending(
boot_profile_path: str = BOOT_PROFILE_PATH,
current_system_path: str = CURRENT_SYSTEM_PATH,
) -> bool:
"""Return whether a staged NixOS generation has not been booted yet.
This is deliberately independent of how the update was started — Hub
"Update System", terminal ``nixos-rebuild boot``, or a support session all
move the system profile the same way:
* after ``nixos-rebuild boot``: profile -> new, current -> old → pending
* after rebooting: both -> new → cleared
* after ``nixos-rebuild switch``: both move together → no reboot
ever needed (switch activates immediately)
* after a rollback: both point at the rollback target → cleared
Unreadable or missing paths are treated as "not pending": the Hub must
never demand a reboot it cannot substantiate.
"""
try:
boot_default = os.path.realpath(boot_profile_path)
current = os.path.realpath(current_system_path)
except OSError:
return False
if not os.path.exists(boot_default) or not os.path.exists(current):
return False
return boot_default != current
def effective_update_status(
status: str,
boot_profile_path: str = BOOT_PROFILE_PATH,
current_system_path: str = CURRENT_SYSTEM_PATH,
) -> str:
"""Map a persisted Hub status to the one that reflects live NixOS state.
Only ``REBOOT_REQUIRED`` is re-validated: it means "the update staged a
generation the machine has not booted into", a claim that must stay true
no matter which tool performed the last update. When the boot default IS
the running system the claim is stale — the staged generation booted, was
superseded by a newer update, or the marker was written by an updater that
could never clear it — so the effective status is ``IDLE``.
All other statuses (``RUNNING``, ``FAILED``, ``SUCCESS``, ``IDLE``) pass
through unchanged; RUNNING staleness is handled separately against the
systemd unit itself.
"""
if status == "REBOOT_REQUIRED" and not reboot_is_pending(
boot_profile_path, current_system_path
):
return "IDLE"
return status
-16
View File
@@ -1,16 +0,0 @@
{
"caddy.service": "2.11.7",
"tor.service": "0.4.9.14",
"gnome-remote-desktop.service": "50.2",
"bitcoind.service": "31.1",
"electrs.service": "0.12.0",
"lnd.service": "0.21.4-beta",
"rtl.service": "0.15.13",
"btcpayserver.service": "2.4.5",
"albyhub.service": "2.0.0",
"mempool.service": "3.3.1",
"matrix-synapse.service": "1.162.0",
"livekit.service": "1.13.8",
"vaultwarden.service": "1.37.4",
"haven-relay.service": "1.2.2"
}
+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()
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

+20 -42
View File
@@ -74,6 +74,18 @@
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;
@@ -133,13 +145,12 @@
ranger fastfetch gedit openssl pwgen
aspell aspellDicts.en lm_sensors
hunspell hunspellDicts.en_US
synadm brave-origin dua
synadm brave dua
gparted pv unzip parted screen zenity
libargon2 gnome-terminal libreoffice-stable
libargon2 gnome-terminal libreoffice-fresh
dig firefox wp-cli axel
lk-jwt-service livekit-libwebrtc livekit
matrix-synapse age onlyoffice-desktopeditors
tor-browser
];
# ── Shell ──────────────────────────────────────────────────
@@ -153,14 +164,6 @@
programs.fish = { enable = true; promptInit = "fastfetch"; };
# ── PostgreSQL base ────────────────────────────────────────
# Shared cluster for Nextcloud (nextclouddb) + Matrix Synapse.
# Sized for the README's Server + Desktop recommendation (32 GB RAM,
# 500 GB NVMe OS + 2 TB NVMe timechain). Postgres shares the box with
# Bitcoin Core, Electrs, LND, MariaDB, PHP-FPM and GNOME, so
# shared_buffers stays below the 25%-of-RAM dedicated-server rule.
# Fixes Nextcloud 35 Database checks (pg.cache_hit_ratio,
# pg.dead_tuples). Override in custom.nix for other hosts, e.g.:
# services.postgresql.settings.shared_buffers = lib.mkForce "512MB";
services.postgresql = {
enable = true;
authentication = lib.mkForce ''
@@ -168,34 +171,6 @@
host all all 127.0.0.1/32 trust
host all all ::1/128 trust
'';
settings = {
# Memory — fixes low buffer cache hit ratio (stock default is
# 128MB shared_buffers). effective_cache_size is only a planner
# hint, not an allocation, so it can be generous.
# NOTE: changing shared_buffers requires a Postgres restart.
shared_buffers = "2GB";
effective_cache_size = "12GB";
maintenance_work_mem = "512MB";
work_mem = "32MB";
wal_buffers = "64MB";
# Checkpoints — spread write bursts out on NVMe. Reload-only.
min_wal_size = "1GB";
max_wal_size = "4GB";
checkpoint_completion_target = 0.9;
# Autovacuum — the stock 60s naptime can't keep up with
# Nextcloud's and Synapse's write-heavy tables (filecache,
# activity, jobs, state). Reload-only.
autovacuum_naptime = "30s";
autovacuum_vacuum_scale_factor = 0.05;
autovacuum_analyze_scale_factor = 0.025;
autovacuum_max_workers = 4;
# NVMe planner assumptions (README: NVMe OS + data disks).
random_page_cost = "1.1";
effective_io_concurrency = 200;
};
};
# ── Backups ────────────────────────────────────────────────
@@ -217,9 +192,12 @@ backup /etc/nix-bitcoin-secrets/ localhost/
};
# ── Cron ───────────────────────────────────────────────────
# The legacy njalla.sh root cron job has been replaced by the systemd timer
# defined in modules/core/njalla.nix (sovran-ddns-update.timer). Cron is
# retained so that rsnapshot and other module-defined cron jobs continue to run.
services.cron = {
enable = true;
systemCronJobs = [
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh"
];
};
# ── Tor ────────────────────────────────────────────────────
services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
-5
View File
@@ -22,11 +22,6 @@
# ─── Add your custom NixOS configuration below ───────────
# ─── Mullvad VPN (Desktop Only) ───────────────────────────
# Installed on the Desktop Role as the stock NixOS service; account, relay
# and settings belong to the app. To take it off a Desktop machine:
# services.mullvad-vpn.enable = lib.mkForce false;
# ─── Custom Caddy virtual hosts ──────────────────────────
# Uncomment and edit below to add your own Caddy sites:
#
Generated
+162 -67
View File
@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1792157233,
"narHash": "sha256-XQnyEc+Pj0Tg21ckbPMri0fiK/exCzhBw6wNgd1INqw=",
"lastModified": 1783086783,
"narHash": "sha256-NxXpNF/9tq2nI+SxFHUxjro3u11SF3l4vs7bawdMKkQ=",
"owner": "emmanuelrosa",
"repo": "btc-clients-nix",
"rev": "990d29e23437f30b8573f640d3e323167de1dc7b",
"rev": "4f6d07cae877ef58f0fbc9e731c99800ddb80859",
"type": "github"
},
"original": {
@@ -18,6 +18,32 @@
"type": "github"
}
},
"extra-container": {
"inputs": {
"flake-utils": [
"nix-bitcoin",
"flake-utils"
],
"nixpkgs": [
"nix-bitcoin",
"nixpkgs"
]
},
"locked": {
"lastModified": 1766155727,
"narHash": "sha256-XGp4HHH6D6ZKiO5RnMzqYJYnZB538EnEflvlTsOKpvo=",
"owner": "erikarvstedt",
"repo": "extra-container",
"rev": "b450bdb24fca1076973c852d87bcb49b8eb5fd49",
"type": "github"
},
"original": {
"owner": "erikarvstedt",
"ref": "0.14",
"repo": "extra-container",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
@@ -26,11 +52,11 @@
]
},
"locked": {
"lastModified": 1790898470,
"narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=",
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "024633cd702b10285db5cb19b40ad48d2399ba60",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
@@ -39,13 +65,54 @@
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nix-bitcoin": {
"inputs": {
"extra-container": "extra-container",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2",
"nixpkgs-25_05": "nixpkgs-25_05",
"nixpkgs-unstable": "nixpkgs-unstable"
},
"locked": {
"lastModified": 1779253922,
"narHash": "sha256-k5DpYVfyy27ELuEiV+51EfVg7B6vKUW63NWeA6eKGd0=",
"owner": "fort-nix",
"repo": "nix-bitcoin",
"rev": "1496f842477976c085cd96f1837ea12444014088",
"type": "github"
},
"original": {
"owner": "fort-nix",
"ref": "release",
"repo": "nix-bitcoin",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1792155732,
"narHash": "sha256-3Dk56anLQ1V1VklQFKFD6ul3cKQk40sThurHlPHIhOo=",
"owner": "nixos",
"lastModified": 1782911660,
"narHash": "sha256-PbR+tJ5E/Ux+01UtdFKqblccVA4/FgWbkym4ev3VHHQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7694c4f6b8537d50062ff36c21ef36de3c9322d7",
"rev": "cf720c15e108d432d29041cc5a185630809acefb",
"type": "github"
},
"original": {
@@ -54,13 +121,29 @@
"type": "github"
}
},
"nixpkgs-25_05": {
"locked": {
"lastModified": 1767313136,
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1792030654,
"narHash": "sha256-OaQwAtPMTjQiY2sT/2/62UbSPhzP/0aDnWzMm/NCiUw=",
"lastModified": 1782999065,
"narHash": "sha256-5Dgj5+pIQYZKrXUGaLCk7CKfN3MmpwIhO94++WVxvng=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "26f4107e47e3ad0ffd0db220469ba83b6f6d96e8",
"rev": "80d591ed473cfc46329932c2aadac9b435342c7c",
"type": "github"
},
"original": {
@@ -70,29 +153,13 @@
"type": "github"
}
},
"nixpkgs_2": {
"nixpkgs-unstable": {
"locked": {
"lastModified": 1792094642,
"narHash": "sha256-zp8ToDMnqZcu4o4IH9pdwXFXGp7yLm/M0dEnNWnyd7I=",
"lastModified": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "fe13242281a67641c0102cdb1087f0ebe586c7df",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1791487292,
"narHash": "sha256-tqM7xDEsaeK1p6VvRygeZ9LkQRpUUkbRT02BHv6hnTM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8edc0c72e3a38faf5434e40d1f19431125fe4b30",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
"type": "github"
},
"original": {
@@ -102,13 +169,29 @@
"type": "github"
}
},
"nixpkgs_4": {
"nixpkgs_2": {
"locked": {
"lastModified": 1791956154,
"narHash": "sha256-jBYGPi4udQ9uo4KQp9m2J0546nJVQitG15gvZSaVVAs=",
"lastModified": 1778737229,
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "55bfb4d7437bd4e09ab7cba58a43bd68eb310044",
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1782959384,
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
"type": "github"
},
"original": {
@@ -118,18 +201,34 @@
"type": "github"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1782948114,
"narHash": "sha256-AXmz9ho4Lud5CsbrZsuSVwpQZ4o5FgZ1chxBn5cJ8+0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9e92285f211dad236540fd617d7e30e0b99bc0e1",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixvim": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs_3",
"systems": "systems"
"nixpkgs": "nixpkgs_4",
"systems": "systems_2"
},
"locked": {
"lastModified": 1792203519,
"narHash": "sha256-yJ072hxngo/srNdLTp4BlFeo0hedwnKicI3TGlSXLFE=",
"lastModified": 1783173302,
"narHash": "sha256-nlnOw/zsD2H2NHSZ5oNWwcjuM17vipyAapfXsO78GjY=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "453dc9c5037e6d1bec81d2a51ddacd58a2f44e9c",
"rev": "a402fdf2a1ef297d8ea7c95b90d6af0dbe90ab11",
"type": "github"
},
"original": {
@@ -141,38 +240,34 @@
"root": {
"inputs": {
"btc-clients": "btc-clients",
"nixpkgs": "nixpkgs_2",
"nix-bitcoin": "nix-bitcoin",
"nixpkgs": "nixpkgs_3",
"nixpkgs-stable": "nixpkgs-stable",
"nixvim": "nixvim",
"sovran-bitcoin": "sovran-bitcoin"
}
},
"sovran-bitcoin": {
"inputs": {
"nixpkgs": "nixpkgs_4"
},
"locked": {
"lastModified": 1792361662,
"narHash": "sha256-EySy/HbufH9trjH0kEuZ2nVVk0+Sn5FoxJI7YtHrspo=",
"owner": "naturallaw777",
"repo": "Sovran_Bitcoin",
"rev": "5c13bf5e01beccc4a225a36cde833f00b2595718",
"type": "github"
},
"original": {
"owner": "naturallaw777",
"repo": "Sovran_Bitcoin",
"type": "github"
"nixvim": "nixvim"
}
},
"systems": {
"flake": false,
"locked": {
"lastModified": 1791244623,
"narHash": "sha256-uuanbkFk8a313Zfrxdcz8Us/yQtaxNnKJtX8v0ZPpd8=",
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "d36eeed142f88b5a636f2e3856a53c61654cf29b",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1774449309,
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
"owner": "nix-systems",
"repo": "default",
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
"type": "github"
},
"original": {
Regular → Executable
+10 -10
View File
@@ -3,13 +3,13 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
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-26.05";
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
};
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, sovran-bitcoin, ... }:
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }:
let
overlay-stable = final: prev: {
@@ -22,7 +22,7 @@
{
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
modules = [
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
{ nixpkgs.hostPlatform = "x86_64-linux"; }
self.nixosModules.Sovran_SystemsOS
./hardware-configuration.nix
./role-state.nix
@@ -32,9 +32,10 @@
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
modules = [
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
{ nixpkgs.hostPlatform = "x86_64-linux"; }
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
./iso/common.nix
sovran-bitcoin.nixosModules.default
nix-bitcoin.nixosModules.default
nixvim.nixosModules.nixvim
];
};
@@ -45,15 +46,14 @@
nixpkgs.overlays = [ overlay-stable ];
})
./configuration.nix
sovran-bitcoin.nixosModules.default
./modules/sovran-bitcoin-integration.nix
nix-bitcoin.nixosModules.default
nixvim.nixosModules.nixvim
];
config = {
environment.systemPackages = with pkgs; [
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.bisq
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.bisq2
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.sparrow
btc-clients.packages.${pkgs.system}.bisq
btc-clients.packages.${pkgs.system}.bisq2
btc-clients.packages.${pkgs.system}.sparrow
];
};
};
+1 -10
View File
@@ -3,14 +3,6 @@
let
sovranSource = builtins.path { path = ../.; name = "sovran-systemsos"; };
# Read version from VERSION file if it exists, otherwise fall back to "dev"
versionFile = if builtins.pathExists ../VERSION
then builtins.readFile ../VERSION
else "dev";
# Clean version (remove 'v' prefix and newlines)
cleanVersion = builtins.replaceStrings ["v" "\n" "\r"] ["" "" ""] versionFile;
pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ps.pycairo ]);
installerPy = pkgs.writeShellScriptBin "sovran-install" ''
@@ -26,8 +18,7 @@ in
"${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix"
];
# Dynamic ISO filename with version
image.baseName = lib.mkForce "Sovran_SystemsOS-${cleanVersion}";
image.baseName = lib.mkForce "Sovran_SystemsOS";
isoImage.splashImage = ./assets/splash-logo.png;
services.gnome.gnome-initial-setup.enable = false;
+57 -191
View File
@@ -54,14 +54,9 @@ DICEWARE_WORDS = [
]
def generate_diceware_password():
# 4 words from a 96 word list plus 2 digits: 96^4 x 100 = ~8.5e9, about
# 33 bits. The old 3 words plus 1 digit was 96^3 x 10 = ~8.8e6, about 23
# bits, for a password that is simultaneously the desktop login, the
# 'free' account password and the only thing in front of a Hub that runs
# as root and hands out every stored credential.
words = [secrets.choice(DICEWARE_WORDS) for _ in range(4)]
digits = f"{secrets.randbelow(100):02d}"
return "-".join(words) + f"-{digits}"
words = [secrets.choice(DICEWARE_WORDS) for _ in range(3)]
digit = secrets.randbelow(10)
return "-".join(words) + f"-{digit}"
try:
logfile = open(LOG, "a")
@@ -108,81 +103,25 @@ def human_size(nbytes):
return f"{nbytes:.1f} PB"
def check_internet():
"""Return True if the machine can reach the internet.
Some VM NAT setups and managed networks block ICMP even when HTTPS works,
so keep the existing ping checks but fall back to small HTTPS requests.
"""
checks = [
["ping", "-c", "1", "-W", "5", "nixos.org"],
["curl", "--location", "--fail", "--silent", "--show-error", "--connect-timeout", "5", "--max-time", "10", "--output", "/dev/null", "https://cache.nixos.org/nix-cache-info"],
["curl", "--location", "--fail", "--silent", "--show-error", "--connect-timeout", "5", "--max-time", "10", "--output", "/dev/null", "https://nixos.org/"],
["ping", "-c", "1", "-W", "5", "1.1.1.1"],
]
for cmd in checks:
try:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
return True
log(f"Connectivity check failed ({' '.join(cmd)}): {result.stderr.strip() or result.stdout.strip()}")
except Exception as e:
log(f"Connectivity check failed ({' '.join(cmd)}): {e}")
return False
def detect_virtualization():
"""Return the VM technology name when running inside a VM, else None."""
"""Return True if the machine can reach the internet."""
try:
result = subprocess.run(
["systemd-detect-virt", "--vm"],
["ping", "-c", "1", "-W", "5", "nixos.org"],
capture_output=True, text=True
)
if result.returncode == 0:
name = result.stdout.strip()
if name == "none":
return None
return name or "virtual machine"
return True
except Exception:
pass
# Fallback for minimal environments where systemd-detect-virt is absent.
dmi_paths = [
"/sys/class/dmi/id/product_name",
"/sys/class/dmi/id/sys_vendor",
"/sys/class/dmi/id/board_vendor",
]
markers = (
"kvm", "qemu", "virtualbox", "vmware", "hyper-v",
"bhyve", "parallels", "xen", "bochs", "virtual",
)
for dmi_path in dmi_paths:
# Fallback: try a second host in case DNS for nixos.org is down
try:
value = open(dmi_path, "r", encoding="utf-8", errors="ignore").read().strip()
except OSError:
continue
lowered = value.lower()
if any(marker in lowered for marker in markers):
return value or "virtual machine"
return None
def detect_boot_mode():
"""Return the firmware mode used to boot the installer ISO."""
return "uefi" if os.path.isdir("/sys/firmware/efi") else "bios"
def boot_mode_label(mode):
if mode == "uefi":
return "UEFI (systemd-boot)"
return "Legacy BIOS (GRUB)"
def nix_string(value):
"""Quote a Python string for a simple Nix string literal."""
escaped = value.replace("\\", "\\\\")
escaped = escaped.replace('"', '\\"')
escaped = escaped.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
escaped = escaped.replace("${", "\\${")
return f'"{escaped}"'
result = subprocess.run(
["ping", "-c", "1", "-W", "5", "1.1.1.1"],
capture_output=True, text=True
)
return result.returncode == 0
except Exception:
return False
def symbolic_icon(name):
"""Create a crisp symbolic icon suitable for use as an ActionRow prefix."""
@@ -220,10 +159,6 @@ class InstallerWindow(Adw.ApplicationWindow):
self.data_size = None
self.data_drive_has_timechain = False
self.free_password = None
self.virtualization = detect_virtualization()
self.boot_mode = detect_boot_mode()
log(f"Installer environment: boot_mode={self.boot_mode}, virtualization={self.virtualization or 'none'}")
# Root navigation view
self.nav = Adw.NavigationView()
@@ -408,7 +343,7 @@ class InstallerWindow(Adw.ApplicationWindow):
if os.path.exists(LOGO):
try:
img = Gtk.Image.new_from_file(LOGO)
img.set_pixel_size(320 if self.virtualization else 480)
img.set_pixel_size(480)
hero.append(img)
except Exception:
pass
@@ -428,41 +363,39 @@ class InstallerWindow(Adw.ApplicationWindow):
sep.set_margin_end(40)
outer.append(sep)
if self.virtualization:
vm_frame = Gtk.Frame()
vm_frame.add_css_class("card")
vm_frame.set_margin_start(40)
vm_frame.set_margin_end(40)
vm_frame.set_margin_top(12)
vm_frame.set_margin_bottom(4)
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)
vm_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
vm_box.set_margin_top(12)
vm_box.set_margin_bottom(12)
vm_box.set_margin_start(16)
vm_box.set_margin_end(16)
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)
vm_icon = symbolic_icon("computer-symbolic")
vm_icon.set_valign(Gtk.Align.START)
vm_box.append(vm_icon)
notice_icon = symbolic_icon("dialog-information-symbolic")
notice_icon.set_valign(Gtk.Align.START)
notice_box.append(notice_icon)
vm_lbl = Gtk.Label()
vm_lbl.set_use_markup(True)
vm_lbl.set_wrap(True)
vm_lbl.set_xalign(0)
vm_lbl.set_halign(Gtk.Align.FILL)
vm_lbl.set_markup(
f"<span weight='bold'>Virtual machine detected: {GLib.markup_escape_text(self.virtualization)}</span>\n"
f"Boot mode: <span weight='bold'>{boot_mode_label(self.boot_mode)}</span>. "
"For the smoothest VM test, use 8+ GB RAM, NAT/bridged networking, "
"and a thin-provisioned virtual OS disk of at least 256 GB. "
"For Node or Server + Desktop, attach a second 2 TB virtual data disk; "
"otherwise choose Desktop Only."
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>"
)
vm_box.append(vm_lbl)
notice_box.append(notice_lbl)
vm_frame.set_child(vm_box)
outer.append(vm_frame)
notice_frame.set_child(notice_box)
outer.append(notice_frame)
# Role label
role_lbl = Gtk.Label()
@@ -476,10 +409,10 @@ class InstallerWindow(Adw.ApplicationWindow):
# Role cards
roles = [
("Server + Desktop",
"Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer. Heads-up: this makes your home IP address public.",
"Full sovereign experience: beautiful desktop, your own cloud, secure messaging, Bitcoin node, and more.",
"Server+Desktop"),
("Desktop Only",
"A beautiful, easy-to-use desktop without the background server applications. Includes the Mullvad VPN app.",
"A beautiful, easy-to-use desktop without the background server applications.",
"Desktop Only"),
("Node Only",
"Full Bitcoin node with Lightning and non-KYC buying and selling. No desktop.",
@@ -515,10 +448,7 @@ class InstallerWindow(Adw.ApplicationWindow):
available = has_second_drive or key not in NEEDS_DATA_DRIVE
if not available:
drive_hint = "⚠ Requires a second internal drive (not detected)"
if self.virtualization:
drive_hint += " — in a VM, attach a second virtual disk or choose Desktop Only"
card.set_subtitle(desc + f"\n{drive_hint}")
card.set_subtitle(desc + "\n⚠ Requires a second internal drive (not detected)")
card.set_sensitive(False)
else:
card.set_subtitle(desc)
@@ -601,10 +531,9 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── OS Drive group ────────────────────────────────────────────
os_group = Adw.PreferencesGroup()
os_group.set_title("OS Drive (NixOS Boot + Root)")
os_description = "Choose the drive for the NixOS installation. Minimum 256 GB required."
if self.virtualization:
os_description += " In a VM, a thin-provisioned virtual disk is OK."
os_group.set_description(os_description)
os_group.set_description(
"Choose the drive for the NixOS installation. Minimum 256 GB required."
)
os_group.set_margin_top(24)
os_group.set_margin_start(40)
os_group.set_margin_end(40)
@@ -617,8 +546,6 @@ class InstallerWindow(Adw.ApplicationWindow):
row.set_title(f"/dev/{name}")
type_label = tran.upper() if tran else "Disk"
meets = "✓ Meets 256 GB minimum" if size >= BYTES_256GB else "✗ Below 256 GB minimum"
if self.virtualization and size < BYTES_256GB:
meets += " (expand the virtual disk)"
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
@@ -643,14 +570,11 @@ class InstallerWindow(Adw.ApplicationWindow):
if self.role != "Desktop Only":
data_group = Adw.PreferencesGroup()
data_group.set_title("Bitcoin Timechain & Backups Drive")
data_description = (
data_group.set_description(
"💡 Tip: Always assign your LARGEST drive here. "
"The full Bitcoin timechain is over 700 GB and grows continuously — "
"a 2 TB or larger drive is required."
)
if self.virtualization:
data_description += " In a VM, attach a second 2 TB thin-provisioned virtual disk for Node or Server + Desktop."
data_group.set_description(data_description)
data_group.set_margin_top(20)
data_group.set_margin_start(40)
data_group.set_margin_end(40)
@@ -675,8 +599,6 @@ class InstallerWindow(Adw.ApplicationWindow):
row.set_title(f"/dev/{name}")
type_label = tran.upper() if tran else "Disk"
meets = "✓ Meets 2 TB minimum" if size >= BYTES_2TB else "✗ Below 2 TB minimum"
if self.virtualization and size < BYTES_2TB:
meets += " (use a 2 TB thin-provisioned virtual disk)"
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
@@ -733,16 +655,10 @@ class InstallerWindow(Adw.ApplicationWindow):
dlg = Adw.MessageDialog()
dlg.set_transient_for(self)
dlg.set_heading("OS Drive Too Small")
body = (
dlg.set_body(
f"The selected OS drive (/dev/{os_name}, {human_size(os_size)}) "
f"does not meet the 256 GB minimum. Please choose a larger drive."
)
if self.virtualization:
body += (
"\n\nVM tip: expand the virtual OS disk to at least 256 GB. "
"Thin-provisioned disks usually do not consume the full size immediately."
)
dlg.set_body(body)
dlg.add_response("ok", "OK")
dlg.present()
return
@@ -752,18 +668,12 @@ class InstallerWindow(Adw.ApplicationWindow):
dlg = Adw.MessageDialog()
dlg.set_transient_for(self)
dlg.set_heading("Bitcoin Drive Too Small")
body = (
dlg.set_body(
f"The selected Bitcoin Timechain & Backups drive "
f"(/dev/{data_name}, {human_size(data_size)}) "
f"does not meet the 2 TB minimum. "
f"Please choose a larger drive or select \"None\"."
)
if self.virtualization:
body += (
"\n\nVM tip: attach a second 2 TB thin-provisioned virtual disk "
"for Node or Server + Desktop test installs."
)
dlg.set_body(body)
dlg.add_response("ok", "OK")
dlg.present()
return
@@ -919,12 +829,7 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── Worker: partition ─────────────────────────────────────────────────
def partition_path(self, dev_path, num):
base = os.path.basename(dev_path)
needs_p = (
base.startswith(("nvme", "mmcblk", "loop", "md")) or
(base[-1:].isdigit())
)
return f"{dev_path}p{num}" if needs_p else f"{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)
@@ -995,19 +900,12 @@ class InstallerWindow(Adw.ApplicationWindow):
time.sleep(2)
# ── Partition boot disk ──
if self.boot_mode == "uefi":
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (UEFI) ===\n")
# ── Partition boot disk: 512M ESP + rest as root ──
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk ===\n")
run_stream(["sudo", "sgdisk",
"-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP",
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
boot_path], buf)
else:
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (Legacy BIOS) ===\n")
run_stream(["sudo", "sgdisk",
"-n", "1:1M:+1M", "-t", "1:EF02", "-c", "1:BIOS-boot",
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
boot_path], buf)
run_stream(["sudo", "partprobe", boot_path], buf)
time.sleep(2)
@@ -1027,10 +925,7 @@ class InstallerWindow(Adw.ApplicationWindow):
boot_p1 = self.partition_path(boot_path, 1)
boot_p2 = self.partition_path(boot_path, 2)
if self.boot_mode == "uefi":
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
else:
GLib.idle_add(append_text, buf, "Skipping ESP format for Legacy BIOS install.\n")
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
if data_path and not self.data_drive_has_timechain:
@@ -1040,11 +935,8 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── Mount filesystems ──
GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n")
run_stream(["sudo", "mount", boot_p2, "/mnt"], buf)
if self.boot_mode == "uefi":
run_stream(["sudo", "mkdir", "-p", "/mnt/boot/efi"], buf)
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
else:
GLib.idle_add(append_text, buf, "Legacy BIOS install: /boot/efi mount not required.\n")
if data_path:
data_p1 = self.partition_path(data_path, 1)
@@ -1074,33 +966,12 @@ class InstallerWindow(Adw.ApplicationWindow):
is_server = str(self.role == "Server+Desktop").lower()
is_desktop = str(self.role == "Desktop Only").lower()
is_node = str(self.role == "Node (Bitcoin-only)").lower()
boot_overrides = ""
if self.boot_mode == "bios":
grub_device = nix_string(f"/dev/{self.boot_disk}")
boot_overrides = f"""
# The installer ISO was booted in Legacy BIOS mode, so install GRUB to the
# target disk instead of using the default UEFI systemd-boot configuration.
boot.loader.systemd-boot.enable = lib.mkForce false;
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
boot.loader.grub.enable = lib.mkForce true;
boot.loader.grub.device = lib.mkForce {grub_device};
fileSystems."/boot/efi".enable = lib.mkForce false;
"""
elif self.virtualization:
boot_overrides = """
# VM UEFI firmware can reject or forget NVRAM boot-entry writes. Keep the
# normal UEFI systemd-boot layout, but use the fallback removable path instead
# of depending on EFI variable updates.
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
"""
content = f"""# 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};{boot_overrides}
sovran_systemsOS.roles.node = lib.mkDefault {is_node};
}}
"""
proc = subprocess.run(
@@ -1137,11 +1008,6 @@ class InstallerWindow(Adw.ApplicationWindow):
boot_row.set_subtitle(f"/dev/{self.boot_disk} — {human_size(self.boot_size)}")
details.add(boot_row)
boot_mode_row = Adw.ActionRow()
boot_mode_row.set_title("Boot Mode")
boot_mode_row.set_subtitle(boot_mode_label(self.boot_mode))
details.add(boot_mode_row)
if self.data_disk:
data_row = Adw.ActionRow()
data_row.set_title("Data Disk")
@@ -1303,7 +1169,7 @@ class InstallerWindow(Adw.ApplicationWindow):
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 = Gtk.Button(label="I Have Written Down My Password — Reboot Now")
reboot_btn.add_css_class("suggested-action")
reboot_btn.add_css_class("pill")
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
+5 -81
View File
@@ -12,11 +12,6 @@ Options:
--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
VM notes:
Desktop test installs still need a 256 GB OS disk (thin-provisioned is OK).
Node/server installs need a separate 2 TB data disk. UEFI is preferred; UEFI
VMs avoid NVRAM boot-entry writes, and legacy BIOS VMs use GRUB.
USAGE
}
@@ -30,8 +25,6 @@ DEPLOY_KEY=""
HEADSCALE_SERVER=""
HEADSCALE_KEY=""
DATA_DISK_HAS_TIMECHAIN=false
BOOT_MODE=""
VIRTUALIZATION=""
FLAKE="/etc/sovran/flake"
LOG="/tmp/sovran-headless-install.log"
@@ -76,22 +69,6 @@ case "$ROLE" in
*) die "--role must be one of: server, desktop, node" ;;
esac
# ── Detect firmware / VM environment ─────────────────────────────────────────
if [[ -d /sys/firmware/efi ]]; then
BOOT_MODE="uefi"
else
BOOT_MODE="bios"
fi
if command -v systemd-detect-virt >/dev/null 2>&1; then
if VIRT_RESULT=$(systemd-detect-virt --vm 2>/dev/null); then
[[ "$VIRT_RESULT" != "none" ]] && VIRTUALIZATION="$VIRT_RESULT"
fi
fi
log "Boot mode: ${BOOT_MODE} ($([[ "$BOOT_MODE" == uefi ]] && echo systemd-boot || echo GRUB))"
[[ -n "$VIRTUALIZATION" ]] && log "Virtual machine detected: ${VIRTUALIZATION}"
# ── Validate disk existence and size ─────────────────────────────────────────
log "=== Validating disks ==="
@@ -119,26 +96,13 @@ fi
# ── Helper: partition suffix ──────────────────────────────────────────────────
part_suffix() {
local dev="$1" n="$2"
local base
base=$(basename "$dev")
if [[ "$base" == nvme* || "$base" == mmcblk* || "$base" == loop* || "$base" == md* || "$base" =~ [0-9]$ ]]; then
if [[ "$dev" == *nvme* ]]; then
echo "${dev}p${n}"
else
echo "${dev}${n}"
fi
}
nix_string() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//$'\n'/\\n}"
value="${value//$'\r'/\\r}"
value="${value//$'\t'/\\t}"
value="${value//\$\{/\\\$\{}"
printf '"%s"' "$value"
}
# ── Detect existing Bitcoin timechain data on data disk ───────────────────────
if [[ -n "$DATA_DISK" ]]; then
DATA_P1=$(part_suffix "$DATA_DISK" 1)
@@ -178,19 +142,12 @@ partprobe "$DISK"
sleep 2
# ── Step 2: Partition OS disk ─────────────────────────────────────────────────
if [[ "$BOOT_MODE" == "uefi" ]]; then
log "=== Partitioning OS disk (UEFI) ==="
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"
else
log "=== Partitioning OS disk (Legacy BIOS) ==="
sgdisk \
-n "1:1M:+1M" -t "1:EF02" -c "1:BIOS-boot" \
-n "2:0:0" -t "2:8300" -c "2:root" \
"$DISK"
fi
partprobe "$DISK"
sleep 2
@@ -211,11 +168,7 @@ log "=== Formatting partitions ==="
BOOT_P1=$(part_suffix "$DISK" 1)
BOOT_P2=$(part_suffix "$DISK" 2)
if [[ "$BOOT_MODE" == "uefi" ]]; then
mkfs.vfat -F 32 "$BOOT_P1"
else
log "Skipping ESP format for Legacy BIOS install"
fi
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
@@ -227,12 +180,8 @@ fi
log "=== Mounting filesystems ==="
mount "$BOOT_P2" /mnt
if [[ "$BOOT_MODE" == "uefi" ]]; then
mkdir -p /mnt/boot/efi
mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi
else
log "Legacy BIOS install: /boot/efi mount not required"
fi
if [[ -n "$DATA_DISK" ]]; then
DATA_P1=$(part_suffix "$DATA_DISK" 1)
@@ -270,41 +219,16 @@ case "$ROLE" in
IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;;
esac
{
cat <<EOF
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
if [[ "$BOOT_MODE" == "bios" ]]; then
GRUB_DEVICE=$(nix_string "$DISK")
cat <<EOF
# The installer ISO was booted in Legacy BIOS mode, so install GRUB to the
# target disk instead of using the default UEFI systemd-boot configuration.
boot.loader.systemd-boot.enable = lib.mkForce false;
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
boot.loader.grub.enable = lib.mkForce true;
boot.loader.grub.device = lib.mkForce ${GRUB_DEVICE};
fileSystems."/boot/efi".enable = lib.mkForce false;
EOF
elif [[ -n "$VIRTUALIZATION" ]]; then
cat <<EOF
# VM UEFI firmware can reject or forget NVRAM boot-entry writes. Keep the
# normal UEFI systemd-boot layout, but use the fallback removable path instead
# of depending on EFI variable updates.
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
EOF
fi
echo "}"
} > /mnt/etc/nixos/role-state.nix
# ── Step 10: Write custom.nix with deploy config ──────────────────────────────
log "=== Writing custom.nix ==="
+7
View File
@@ -0,0 +1,7 @@
{ config, pkgs, lib, ... }:
lib.mkIf config.sovran_systemsOS.features.bitcoin-core {
services.bitcoind.package = lib.mkForce config.nix-bitcoin.pkgs.bitcoind;
}
+116
View File
@@ -0,0 +1,116 @@
{ config, pkgs, lib, ... }:
lib.mkIf config.sovran_systemsOS.services.bitcoin {
services.bitcoind = {
enable = true;
package = pkgs.bitcoind-knots;
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
txindex = true;
tor.proxy = true;
tor.enforce = true;
disablewallet = true;
extraConfig = ''
peerbloomfilters=1
server=1
'';
};
nix-bitcoin.onionServices.bitcoind.enable = true;
nix-bitcoin.onionServices.electrs.enable = true;
nix-bitcoin.onionServices.rtl.enable = true;
services.electrs = {
enable = true;
tor.enforce = true;
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data";
};
services.lnd = {
enable = true;
tor.enforce = true;
tor.proxy = true;
extraConfig = ''
protocol.option-scid-alias=true
'';
};
nix-bitcoin.onionServices.lnd.public = true;
services.lnd.lndconnect = {
enable = true;
onion = true;
};
services.rtl = {
enable = true;
tor.enforce = true;
port = 3050;
nightTheme = true;
nodes = {
lnd = {
enable = true;
};
};
};
services.btcpayserver = {
enable = config.sovran_systemsOS.web.btcpayserver;
};
services.btcpayserver.lightningBackend = "lnd";
nix-bitcoin.generateSecrets = true;
nix-bitcoin.nodeinfo.enable = true;
nix-bitcoin.operator = {
enable = true;
name = "free";
};
nix-bitcoin.useVersionLockedPkgs = false;
systemd.services.bitcoind = {
requires = [ "run-media-Second_Drive.mount" ];
after = [ "run-media-Second_Drive.mount" ];
serviceConfig.PrivateUsers = lib.mkForce false;
};
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"; }
];
}
+30 -61
View File
@@ -12,56 +12,14 @@ let
|| config.sovran_systemsOS.services.nextcloud
|| config.sovran_systemsOS.services.vaultwarden
|| config.sovran_systemsOS.features.haven
|| config.sovran_systemsOS.features."nwc-wallets"
|| config.sovran_systemsOS.features.element-calling;
# RTL and Mempool listen on loopback only: Sovran_Bitcoin binds them to
# 127.0.0.1, and RTL's unit is sandboxed to loopback besides. Caddy is how
# the local network reaches them (:3051 and :60847), so it has to run
# wherever they do. That includes Bitcoin Node Only, which has no
# domain-based service and so no other reason to run Caddy.
#
# The Hub is not one of these. It listens on 0.0.0.0:8937 itself, so it is
# served on its own port rather than through Caddy: the one service that
# runs as root has nothing in front of it that it does not need, and the
# public sites on ports 80/443 cannot be asked for it by Host header.
servesRtl = config.sovran_systemsOS.services.bitcoin;
servesMempool = servesRtl && config.sovran_systemsOS.features.mempool;
caddyEnabled = needsHttpsPorts || extraVhosts != "" || servesRtl;
# Sites for the local network, one per loopback-only service. Written after
# the public domain sites; each exists only where its service does.
#
# They do not filter by client address. A request can only reach them on
# their own ports, which no setup step asks you to forward, so forwarding
# 80/443 for public services does not expose them (a Host header on those
# ports cannot select a site that listens elsewhere). RTL has its own
# password and lockout, and Mempool shows public chain data. An address
# check here could not be made right for IPv6 anyway: a laptop's global
# address on the LAN looks exactly like a stranger's.
bitcoinUiSites =
lib.optionalString servesRtl ''
:3051 {
reverse_proxy :3050
encode gzip zstd
}
''
+ lib.optionalString servesMempool ''
:60847 {
reverse_proxy :60845
encode gzip zstd
}
'';
in
{
services.caddy = {
# Caddy runs when a domain-based service needs it, when the operator has
# defined custom vhosts, or when it is the way to reach RTL and Mempool.
# Desktop Only has none of those, so Caddy stays off there.
enable = caddyEnabled;
# 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";
};
@@ -112,7 +70,6 @@ in
BTCPAY=$(read_domain btcpayserver)
VAULTWARDEN=$(read_domain vaultwarden)
HAVEN=$(read_domain haven)
LIGHTNING=$(read_domain lightning)
ACME_EMAIL=$(read_domain sslemail)
# Start with global config — use ACME only when domain-based services are active
@@ -229,25 +186,37 @@ $HAVEN {
EOF
fi
# ── Lightning Wallet Connections LNURL ──────────────────────
if [ -n "$LIGHTNING" ]; then
# ── Sovran Hub (LAN access via mDNS) ────────────
cat >> /run/caddy/Caddyfile <<EOF
$LIGHTNING {
# LNURL discovery and callback are served by the dedicated
# nwc-lnurl service on loopback port 8181. Only these paths
# are proxied; the Alby Hub management port (18080) is never exposed.
reverse_proxy /.well-known/lnurlp/* http://127.0.0.1:8181
reverse_proxy /lnurlp/* http://127.0.0.1:8181
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
fi
# ── RTL and Mempool (local network) ─────────────
# Only where those services run; see bitcoinUiSites above.
cat >> /run/caddy/Caddyfile <<'LAN_SITES_EOF'
${bitcoinUiSites}
LAN_SITES_EOF
# ── RTL (LAN access) ────────────────────────────
cat >> /run/caddy/Caddyfile <<EOF
:3051 {
reverse_proxy :3050
encode gzip zstd
}
EOF
# ── Mempool (LAN access) ────────────────────────
cat >> /run/caddy/Caddyfile <<EOF
:60847 {
reverse_proxy :60845
encode gzip zstd
}
EOF
# ── Custom vhosts from custom.nix ──────────────
cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF'
-171
View File
@@ -1,171 +0,0 @@
{ 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 lightning; 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" ];
};
}
+17 -66
View File
@@ -1,81 +1,32 @@
{ config, pkgs, lib, ... }:
{
# The public-IP detector (STUN / OpenDNS / HTTPS echo) is gone: the public
# address is whatever Njal.la reports back for the DDNS update below, and
# nothing else on the system looks it up. Fail with a pointer, instead of
# silently ignoring them, if a custom.nix still sets one of its old options.
imports = map (opt:
lib.mkRemovedOptionModule [ "sovran_systemsOS" "publicIP" opt ]
"Sovran no longer looks up the public IP: the Njal.la DDNS update reports it (modules/core/njalla.nix). To force an address for Element Calling, set sovran_systemsOS.elementCalling.externalIP."
) [ "stunServer" "stunPort" "dnsResolver" "httpsEcho" "cacheTTL" ];
# ── Ensure njalla directory exists on every build ────────────────────────
# ── Ensure njalla directory and base script exist on every build ──
systemd.tmpfiles.rules = [
"d /var/lib/njalla 0750 root root -"
];
# ── Install the DDNS runner and the validator it shares with the Hub ─────
# Both files come straight from the Hub's source tree and are installed side
# by side as read-only system files. The runner imports the exact same
# _validate_ddns_url() the Hub API uses — no weaker inline copy.
environment.etc."sovran/security_helpers.py" = {
source = ../../app/sovran_systemsos_web/security_helpers.py;
mode = "0444";
user = "root";
group = "root";
};
environment.etc."sovran/ddns-update.py" = {
source = ../../app/sovran_systemsos_web/ddns_update.py;
mode = "0444";
user = "root";
group = "root";
};
# ── Safe DDNS update service ─────────────────────────────────────────────
# Reads DDNS update URLs from the JSON store written by the Hub API and
# invokes curl directly — no shell interpolation, no script execution.
# Njal.la is asked to use the address the request came from ("&auto") and
# reports it back; the runner saves it to /var/lib/secrets/external-ip,
# where LiveKit and the Hub read it. See app/sovran_systemsos_web/ddns_update.py.
systemd.services.sovran-ddns-update = {
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
# curl is not in a NixOS unit's default PATH (coreutils, findutils, grep,
# sed, systemd): without this the runner cannot start it.
path = [ pkgs.curl ];
# ── Create base njalla.sh if it doesn't exist yet ────────────
systemd.services.njalla-init = {
description = "Initialize Njal.la DDNS script if missing";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
User = "root";
ExecStart = "${pkgs.python3}/bin/python3 /etc/sovran/ddns-update.py";
# Harden the service — it needs network access, the URL store, and the
# file that receives the reported address.
NoNewPrivileges = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
ReadOnlyPaths = [ "/etc/sovran" ];
ProtectHome = true;
PrivateTmp = true;
# AF_UNIX: name lookups can go through nscd / systemd-resolved sockets.
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
RemainAfterExit = true;
};
unitConfig = {
ConditionPathExists = "!/var/lib/njalla/njalla.sh";
};
script = ''
cat > /var/lib/njalla/njalla.sh <<'SCRIPT'
#!/usr/bin/env bash
IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)
# Run the update every 15 minutes
systemd.timers.sovran-ddns-update = {
description = "Sovran Njal.la DDNS update timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "2min";
OnUnitActiveSec = "15min";
Persistent = true;
};
};
## Add DDNS entries below — one curl per line
## Managed via Sovran Hub web interface
SCRIPT
# The runner used to be written to /var/lib/sovran by this activation script,
# next to the old public-ip.py detector. Remove those stale copies.
system.activationScripts.sovran-ddns-update-script = ''
rm -f /var/lib/sovran/ddns-update.py /var/lib/sovran/public-ip.py
chmod 700 /var/lib/njalla/njalla.sh
'';
};
}
+4 -5
View File
@@ -3,9 +3,9 @@
{
config = lib.mkMerge [
# The Sovran_Bitcoin flake module is always imported (see flake.nix).
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
# without enabling any Bitcoin services.
# 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;
}
@@ -33,8 +33,7 @@
haven = lib.mkForce false;
mempool = lib.mkForce false;
element-calling = lib.mkForce false;
bitcoin-tor-gossip = lib.mkForce false;
"nwc-wallets" = lib.mkForce false;
bitcoin-core = lib.mkForce false;
};
sovran_systemsOS.web.btcpayserver = lib.mkForce false;
+21 -107
View File
@@ -45,80 +45,21 @@
haven = lib.mkEnableOption "Haven NOSTR relay";
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
bitcoin-tor-gossip = lib.mkEnableOption "Advertise the Bitcoin Core onion service through Bitcoin peer gossip";
# Compatibility shim for Hub-managed settings from releases where Core
# was an optional replacement for the default node. Core is now always
# selected when the Bitcoin service is enabled.
bitcoin-core = lib.mkOption {
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: Bitcoin Core is the default node implementation.";
};
"nwc-wallets" = lib.mkEnableOption "Lightning Wallet Connections";
rdp = lib.mkEnableOption "Gnome Remote Desktop";
sshd = lib.mkEnableOption "SSH remote access";
};
# ── Hub ───────────────────────────────────────────────────
hub = {
lanOnly = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Refuse Hub requests from clients that are not on this computer or on
the local network: loopback, private (10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16), VPN/CGNAT (100.64.0.0/10) and link-local addresses,
plus anything listed in sovran_systemsOS.hub.extraLanNetworks.
The Hub runs as root and can display stored credentials and reboot
the machine. Whether a packet may reach its port is up to the
firewall and your router; this check is the second lock, so that a
port forward or a firewall mistake does not put the Hub's login page
in front of the internet.
Set it to false only if this computer sits on a network that hands
out public addresses to your own devices and you would rather not
list them.
'';
};
directPort = lib.mkOption {
type = lib.types.bool;
default = !config.sovran_systemsOS.roles.desktop;
defaultText = lib.literalExpression "!config.sovran_systemsOS.roles.desktop";
description = ''
Open port 8937 on the firewall, so that other devices on the local
network can reach the Hub at http://sovransystemsos.local:8937.
On by default for Server + Desktop and Bitcoin Node Only. Off on
Desktop Only, the role most likely to be used away from home: there
nothing is published, and the Hub is reachable only from this
computer, through the desktop application window on localhost. Set it
to true in custom.nix if you do want to reach a Desktop Only Hub from
another device.
The Hub runs as root, so it checks every client itself (see
sovran_systemsOS.hub.lanOnly); the firewall opening only decides
whether a packet may reach it at all.
'';
};
extraLanNetworks = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "203.0.113.0/28" ];
description = ''
Extra networks, in CIDR notation, that the Hub should treat as local
in addition to the built-in ranges. Needed only if devices on your
local network use addresses outside the private ranges, for example a
public IPv4 block your provider routes onto your LAN.
Keep each entry as narrow as you can: every address inside it is let
through. To let everything through, set sovran_systemsOS.hub.lanOnly
to false instead; 0.0.0.0/0 and ::/0 are not accepted here.
'';
description = "(Deprecated, no-op) BIP-110 is now built into Bitcoin Knots.";
};
};
@@ -140,43 +81,6 @@
};
};
# ── Element Calling (video/audio) tuning ──────────────────
elementCalling = {
fullAccessHomeservers = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "matrix.peer.example.com" ];
description = ''
Additional Matrix server_names (beyond this server itself) that may
trigger LiveKit room creation on this server's SFU via lk-jwt-service.
Not needed for the common federated setup: each participant's client
always obtains its token from its own homeserver's JWT service and
publishes to its own SFU, and the participant who starts a call
creates the room on their own SFU — the remote user merely joins
(joining does not require full access).
Only set this for asymmetric cases: e.g. a peer homeserver that has
no focus of its own, or calls whose first participant lands on this
server's SFU but belongs to the peer.
'';
};
externalIP = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "203.0.113.10";
description = ''
Optional pin: force LiveKit to advertise this public IPv4 in its
host/TURN ICE candidates. Not required in normal operation — the
address is the one Njal.la reports for the DDNS update (set up in
the Hub's Domains page), and nothing on this system looks it up
anywhere else. Set it for a fixed public address with no Njal.la
DDNS entry, or to override the reported one (e.g. multi-WAN/VPN
setups).
'';
};
};
# ── Domain setup registry ─────────────────────────────────
domainRequirements = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
@@ -198,4 +102,14 @@
};
};
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.
''
];
};
}
+33 -224
View File
@@ -3,16 +3,6 @@
let
cfg = config.sovran_systemsOS;
# Read the OS version from the repo-root VERSION file at eval time so the
# Hub always ships a real, baked-in version string instead of relying on
# a runtime file lookup (which returns "dev" when /etc/nixos/VERSION is
# missing, e.g. in a dev checkout — this is the "vdev" bug in the Hub UI).
versionFile = ../../VERSION;
sovranVersion =
if builtins.pathExists versionFile
then builtins.replaceStrings [ "v" "\n" "\r" ] [ "" "" "" ] (builtins.readFile versionFile)
else "1.0.0";
monitoredServices =
# ── Infrastructure — System Passwords (always present) ─────
[
@@ -37,10 +27,13 @@ let
{ 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 ────────────────────────────────────────────
# ── Bitcoin Base (node implementations) ────────────────────
++ lib.optionals cfg.services.bitcoin [
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
{ label = "Tor Bitcoin P2P Address — Reachable only through Tor"; file = "/var/lib/tor/onion/bitcoind/hostname"; suffix = ":8333"; }
{ 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 — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
]; }
]
# ── Bitcoin Apps (services on top of the node) ─────────────
@@ -51,8 +44,8 @@ let
]; }
{ 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 Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; suffix = "/rtl/"; }
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051/rtl/"; }
{ 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"; }
]; }
@@ -62,14 +55,12 @@ let
]; }
{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
{ 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-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 = "Lightning Wallet Connections"; unit = "albyhub.service"; type = "system"; icon = "nwc"; enabled = cfg.features."nwc-wallets"; category = "bitcoin-apps"; credentials = [
{ label = "Lightning Address Domain"; file = "/var/lib/domains/lightning"; }
]; }
{ name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
{ 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"; }
@@ -85,7 +76,7 @@ let
{ label = "Test Username"; file = "/var/lib/secrets/matrix-test-username"; }
{ label = "Test Password"; file = "/var/lib/secrets/matrix-test-password"; }
]; }
{ name = "LiveKit (Element Call)"; unit = "livekit.service"; type = "system"; icon = "element-calling"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; }
{ name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "element-calling"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; }
]
# ── Self-Hosted Apps (server+desktop only) ─────────────────
++ lib.optionals cfg.roles.server_plus_desktop [
@@ -115,15 +106,6 @@ let
else if cfg.roles.node then "node"
else "server_plus_desktop";
# IPv4 a.b.c.d[/0-32] or IPv6 [/0-128], and never a /0 (that is "everyone").
octet = "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])";
lanNetworkOk = p:
(
builtins.match "${octet}(\\.${octet}){3}(/(3[0-2]|[12]?[0-9]))?" p != null
|| builtins.match "[0-9a-fA-F:]*:[0-9a-fA-F:]*(/(12[0-8]|1[01][0-9]|[1-9]?[0-9]))?" p != null
)
&& builtins.match ".*/0" p == null;
generatedConfig = pkgs.writeText "sovran-hub-config.json"
(builtins.toJSON {
refresh_interval = 5;
@@ -131,92 +113,8 @@ let
role = activeRole;
services = monitoredServices;
feature_manager = true;
# Read by LanOnlyMiddleware in server.py.
lan_only = cfg.hub.lanOnly;
lan_extra_networks = cfg.hub.extraLanNetworks;
feature_states = {
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
};
sovran_version = sovranVersion;
});
generatedVersions = pkgs.writeText "sovran-hub-versions.json" (builtins.toJSON {
"caddy.service" = if pkgs ? caddy then pkgs.caddy.version else "2.8.4";
"tor.service" = if pkgs ? tor then pkgs.tor.version else "0.4.8.12";
"gnome-remote-desktop.service" = if pkgs ? gnome-remote-desktop then pkgs.gnome-remote-desktop.version else "46.0";
"bitcoind.service" = if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0";
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
# Vendored packages come from the Sovran_Bitcoin flake overlay
# (pkgs.sovran-bitcoin.*). Evaluate them directly so the Hub shows
# the exact versions this system ships.
"rtl.service" = pkgs.sovran-bitcoin.rtl.version;
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
"albyhub.service" = pkgs.sovran-bitcoin.albyhub.version;
"mempool.service" = pkgs.sovran-bitcoin.mempool-backend.version;
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
# Nextcloud and WordPress are downloaded into /var/lib/www, so their
# deployed versions are read from their own PHP version files by the Hub.
"haven-relay.service" = if pkgs ? haven-relay then pkgs.haven-relay.version else (if pkgs ? haven then pkgs.haven.version else "0.1.0");
});
# Shared shell prelude used by both the update and rebuild wrapper scripts.
# A flake/package fetch that is interrupted (network blip, reboot
# mid-download, disk filled, hiccup on the remote) can leave a truncated
# tarball or partial git clone in Nix's download caches. Nix then reuses the
# corrupt archive on every retry and dies with "cannot read file from
# tarball: Truncated tar archive detected" — a failure that is NOT fixed by
# simply re-running, but IS fixed by clearing the fetch caches. run_step runs
# a command and, on the first failure that matches a download/cache
# signature, clears the caches and retries once. Real config errors never
# match, so they still fail loudly. Each sourcing script must define $LOG.
nix-self-heal-prelude = ''
transient_failure() {
grep -Eqi 'truncated tar|unexpected end of (file|archive)|unexpected eof|corrupt(ed)? (archive|nar|download|file)|could not (fetch|download)|download.*(failed|interrupted)|timed out|timeout|connection (reset|refused|timed out)|network is unreachable|temporary failure in name resolution|checksum mismatch|hash mismatch|nar hash|unable to download|store path.*is not valid|cannot read file from tarball|into the git cache' "$LOG"
}
clear_fetch_caches() {
echo "[SELF-HEAL] Clearing stale Nix download caches and verifying the Nix store…"
# Re-fetchable caches only; /nix/store generations and the running system
# are never touched here.
rm -rf /root/.cache/nix/tarballs /root/.cache/nix/vcs-cache /root/.cache/nix/git* /root/.cache/nix/flakes 2>/dev/null || true
# Fast closure-level repair only. A full --check-contents scan hashes
# every store path and can take tens of minutes on a big node; the cache
# clear above is the actual fix for truncated/corrupt downloads.
nix-store --verify --repair >/dev/null 2>&1 || true
echo "[SELF-HEAL] Caches cleared; retrying…"
echo ""
}
# run_step LABEL CMD [ARGS...] — run a build step; on a transient
# fetch/cache failure, heal once and retry. Returns the command exit code
# but leaves error messaging to the caller.
run_step() {
label="$1"; shift
rc=1
for try in 1 2; do
if [ "$try" -eq 2 ]; then
echo "── $label — retry after cache repair ──"
fi
"$@"
rc=$?
if [ "$rc" -eq 0 ]; then
return 0
fi
if [ "$try" -eq 1 ] && transient_failure; then
echo ""
echo "[SELF-HEAL] $label failed on a download/cache error (see above)."
clear_fetch_caches
continue
fi
return "$rc"
done
return "$rc"
}
'';
# ── Update wrapper script ──────────────────────────────────────
update-script = pkgs.writeShellScript "sovran-hub-update.sh" ''
set -uo pipefail
@@ -224,10 +122,8 @@ let
LOG="/var/log/sovran-hub-update.log"
STATUS="/var/log/sovran-hub-update.status"
GENERATION="/var/log/sovran-hub-update.generation"
echo "RUNNING" > "$STATUS"
rm -f "$GENERATION"
: > "$LOG"
exec > >(tee -a "$LOG") 2>&1
@@ -238,14 +134,12 @@ let
RC=0
${nix-self-heal-prelude}
echo "── Step 1/3: nix flake update ────────────────────"
if ! run_step "nix flake update" nix flake update --flake /etc/nixos --print-build-logs \
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; then
--option fallback true 2>&1; then
echo "[ERROR] nix flake update failed"
RC=1
fi
@@ -253,21 +147,14 @@ let
if [ "$RC" -eq 0 ]; then
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
# Stream output straight into $LOG (see rebuild-script) so the Hub UI
# shows live progress instead of an empty log during long builds.
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
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; then
if ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
# The marker is informational only. The Hub derives pending-reboot
# state from the NixOS system profile itself, so failing to record
# the marker must not fail an otherwise successful update.
echo "[WARNING] update succeeded but its staged generation could not be recorded"
rm -f "$GENERATION"
fi
else
--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
@@ -314,35 +201,29 @@ let
echo " Sovran_SystemsOS Rebuild — $(date)"
echo "══════════════════════════════════════════════════"
echo ""
${nix-self-heal-prelude}
echo "── Rebuilding system configuration ──────────────"
# Stream output straight into $LOG (tee'd by the exec redirect above) so
# the Hub UI shows live progress. Capturing the output in a variable
# kept the log empty for the entire build+activation, which made long
# rebuilds can otherwise look like a hang.
run_step "nixos-rebuild switch" nixos-rebuild switch --flake /etc/nixos --print-build-logs \
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
--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 grep -q "switchInhibitors\|Pre-switch checks failed" "$LOG"; then
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 run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
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; then
--option fallback true 2>&1; then
echo "REBOOT_REQUIRED" > "$STATUS"
else
echo "[ERROR] nixos-rebuild boot also failed"
@@ -350,7 +231,6 @@ let
exit 1
fi
else
echo "[ERROR] nixos-rebuild switch failed"
echo ""
echo "══════════════════════════════════════════════════"
echo " ✗ Rebuild failed — see errors above"
@@ -360,31 +240,17 @@ let
fi
'';
# ── Brave Origin launcher wrapper: a *persistent* per-user profile dir.
# It must NOT be wiped on exit: the Hub's logout marker cookie
# (hub_manual_logout) and the session cookie live in this profile.
# Launching from a fresh/ephemeral profile every time throws away the
# marker, so /auto-login would mint a new session and silently log the
# user straight back in after they signed out and reopened the window.
# A stable directory also keeps the Wayland app_id deterministic so
# GNOME Shell can match the window to the .desktop entry (dock icon).
# ── 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-origin pkgs.coreutils ]}:$PATH"
# Per-user, persistent browser state. $XDG_STATE_HOME keeps it out of the
# way of backups and survives reboots and window close/reopen.
if [ -n "$XDG_STATE_HOME" ]; then
HUB_DATA="$XDG_STATE_HOME/sovran-hub-browser"
else
HUB_DATA="$HOME/.local/state/sovran-hub-browser"
fi
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"
# With a persistent profile Brave Origin's one-time "Proceed with Origin
# for free on Linux" onboarding dialog only appears once; keep skipping it
# anyway so it can never block auto-login (Linux-only switch).
brave-origin --app=http://localhost:8937/auto-login \
--skip-origin-startup-dialog \
brave --app=http://localhost:8937/auto-login \
--class=sovran-hub \
--user-data-dir="$HUB_DATA" \
--password-store=basic \
@@ -419,8 +285,7 @@ let
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
pname = "sovran-systemsos-hub-web";
# Keep the package metadata in lockstep with the version shown in the Hub.
version = sovranVersion;
version = "1.0.0";
format = "other";
src = ../../app;
@@ -443,8 +308,6 @@ let
cp -r sovran_systemsos_web $out/lib/sovran-hub-web/
cp ${generatedConfig} $out/lib/sovran-hub-web/config.json
cp ${generatedVersions} $out/lib/sovran-hub-web/versions.json
printf '%s' "${sovranVersion}" > $out/lib/sovran-hub-web/VERSION
install -d $out/share/sovran-hub/icons
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
@@ -478,21 +341,11 @@ DESKTOP
import os, sys
base = os.path.join("$out", "lib", "sovran-hub-web")
sys.path.insert(0, base)
# Canonical NWC implementation — single source of truth from the
# Sovran_Bitcoin flake (imported directly by server.py).
sys.path.insert(0, os.path.join("${pkgs.sovran-bitcoin.nwc}", "lib", "sovran-nwc"))
os.environ["SOVRAN_HUB_CONFIG"] = os.path.join(base, "config.json")
os.environ["SOVRAN_HUB_VERSIONS"] = os.path.join(base, "versions.json")
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
import uvicorn
uvicorn.run(
"sovran_systemsos_web.server:app",
# IPv4 only, on purpose. The desktop launcher uses "localhost", which
# falls back to 127.0.0.1, and other devices reach the Hub over IPv4 too.
# An IPv6 listener would admit clients whose global addresses the Hub's own
# check cannot tell from a stranger's (see LanPolicy). Which devices may
# connect is up to the firewall (hub.directPort) and that check
# (hub.lanOnly), not this bind.
host="0.0.0.0",
port=8937,
log_level="info",
@@ -500,9 +353,6 @@ uvicorn.run(
LAUNCHER
chmod +x $out/bin/sovran-hub-web
# nwc-wallet / nwc-lnurl binaries are no longer shipped here: the
# Sovran_Bitcoin flake provides them (env-wrapped nwc-wallet via
# albyhub.nix, nwc-lnurl.service via lnurl.nix) — single source of truth.
runHook postInstall
'';
@@ -514,29 +364,7 @@ LAUNCHER
in
{
options.services.sovranHub.webPackage = lib.mkOption {
type = lib.types.package;
default = sovran-hub-web;
description = "The sovran-hub-web Python application package. Other modules use this to reference Hub-installed scripts without duplicating the Python path setup.";
};
config = {
# Catch a typo'd network at build time. The Hub ignores an entry it cannot
# parse (it must never widen its policy by guessing), so without this the
# only symptom would be a client that is refused for no visible reason.
assertions = [
{
assertion = builtins.all lanNetworkOk cfg.hub.extraLanNetworks;
message = ''
sovran_systemsOS.hub.extraLanNetworks must be a list of IPv4 or IPv6
networks in CIDR notation, for example [ "203.0.113.0/28" ]. A /0
prefix is not accepted; set sovran_systemsOS.hub.lanOnly = false to
let every client through. Got:
${builtins.toJSON cfg.hub.extraLanNetworks}
'';
}
];
systemd.services.sovran-hub-web = {
description = "Sovran_SystemsOS Hub Web Interface";
wantedBy = [ "multi-user.target" ];
@@ -554,25 +382,13 @@ in
};
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 ];
] ++ lib.optional cfg.services.bitcoin config.services.bitcoind.package;
};
systemd.services.sovran-hub-update = {
@@ -605,14 +421,7 @@ in
environment.systemPackages = [ sovran-hub-web ];
# The Hub is served on its own port, not through Caddy (see caddy.nix).
# Nothing here filters by client address: that is the Hub's own check
# (sovran_systemsOS.hub.lanOnly), and which networks can route to this
# computer at all is the router's call.
# 60847 is where Caddy serves Mempool, so it is open only when Mempool is.
networking.firewall.allowedTCPPorts =
lib.optionals cfg.hub.directPort [ 8937 ]
++ lib.optionals (cfg.services.bitcoin && cfg.features.mempool) [ 60847 ];
networking.firewall.allowedTCPPorts = [ 8937 60847 ];
# ── Auto-launch Hub in browser on login ───────────────────────
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
-175
View File
@@ -1,175 +0,0 @@
#!/usr/bin/env python3
"""Sovran restricted journal helper.
A root-owned, non-user-writable diagnostic tool that wraps journalctl with
a strict allowlist of safe flags. Replaces the ``journalctl *`` sudo rule
in tech-support.nix.
Accepted flags:
--unit / -u <name> must be one of the explicitly approved service units
--lines / -n <N> positive integer (max 10000)
--priority / -p <level> 0-7 or emerg/alert/crit/err/warning/notice/info/debug
--since <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
--until <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
--output / -o <format> short | short-iso | cat | json | verbose
At least one ``--unit`` flag is required; whole-journal queries are rejected.
All other flags, paths, directories, roots, namespaces, and output
destinations are rejected with a non-zero exit code.
"""
import re
import subprocess
import sys
# ── Allowlists ────────────────────────────────────────────────────────────────
# Explicit approved units. Only these four services may be queried through
# the restricted journal helper. Any other unit is rejected.
_APPROVED_UNITS: frozenset[str] = frozenset([
"sovran-hub-web.service",
"caddy.service",
"bitcoind.service",
"lnd.service",
])
_ALLOWED_PRIORITIES = frozenset([
"0", "1", "2", "3", "4", "5", "6", "7",
"emerg", "alert", "crit", "err", "warning", "notice", "info", "debug",
])
_ALLOWED_OUTPUT_FORMATS = frozenset([
"short", "short-iso", "cat", "json", "verbose",
])
# ISO 8601 date or datetime: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (no paths)
_DATETIME_RE = re.compile(r'^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?$')
_MAX_LINES = 10000
# ── Argument parser ───────────────────────────────────────────────────────────
def _die(msg: str) -> None:
print(f"sovran-journal-helper: {msg}", file=sys.stderr)
sys.exit(1)
def _validate_unit(val: str) -> str:
if val not in _APPROVED_UNITS:
_die(
f"rejected unit name: {val!r} "
f"(allowed: {', '.join(sorted(_APPROVED_UNITS))})"
)
return val
def _validate_lines(val: str) -> str:
try:
n = int(val)
except ValueError:
_die(f"rejected: --lines must be a positive integer, got {val!r}")
if n <= 0 or n > _MAX_LINES:
_die(f"rejected: --lines must be between 1 and {_MAX_LINES}, got {n}")
return str(n)
def _validate_priority(val: str) -> str:
if val not in _ALLOWED_PRIORITIES:
_die(f"rejected priority: {val!r}")
return val
def _validate_datetime(val: str) -> str:
if not _DATETIME_RE.match(val):
_die(f"rejected: datetime {val!r} (must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)")
return val
def _validate_output(val: str) -> str:
if val not in _ALLOWED_OUTPUT_FORMATS:
_die(f"rejected output format: {val!r}")
return val
def main() -> None:
args = sys.argv[1:]
cmd = ["journalctl"]
unit_count = 0
i = 0
while i < len(args):
arg = args[i]
if arg in ("--unit", "-u"):
i += 1
if i >= len(args):
_die("--unit requires a value")
cmd += ["--unit", _validate_unit(args[i])]
unit_count += 1
elif arg.startswith("--unit="):
cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
unit_count += 1
elif arg in ("--lines", "-n"):
i += 1
if i >= len(args):
_die("--lines requires a value")
cmd += ["--lines", _validate_lines(args[i])]
elif arg.startswith("--lines="):
cmd += ["--lines", _validate_lines(arg[len("--lines="):])]
elif re.match(r'^-n\d+$', arg):
cmd += ["--lines", _validate_lines(arg[2:])]
elif arg in ("--priority", "-p"):
i += 1
if i >= len(args):
_die("--priority requires a value")
cmd += ["--priority", _validate_priority(args[i])]
elif arg.startswith("--priority="):
cmd += ["--priority", _validate_priority(arg[len("--priority="):])]
elif arg == "--since":
i += 1
if i >= len(args):
_die("--since requires a value")
cmd += ["--since", _validate_datetime(args[i])]
elif arg.startswith("--since="):
cmd += ["--since", _validate_datetime(arg[len("--since="):])]
elif arg == "--until":
i += 1
if i >= len(args):
_die("--until requires a value")
cmd += ["--until", _validate_datetime(args[i])]
elif arg.startswith("--until="):
cmd += ["--until", _validate_datetime(arg[len("--until="):])]
elif arg in ("--output", "-o"):
i += 1
if i >= len(args):
_die("--output requires a value")
cmd += ["--output", _validate_output(args[i])]
elif arg.startswith("--output="):
cmd += ["--output", _validate_output(arg[len("--output="):])]
else:
_die(
f"rejected flag: {arg!r}. "
"Allowed flags: --unit, --lines, --priority, --since, --until, --output"
)
i += 1
if unit_count == 0:
_die(
"at least one --unit flag is required; "
f"allowed units: {', '.join(sorted(_APPROVED_UNITS))}"
)
result = subprocess.run(cmd)
sys.exit(result.returncode)
if __name__ == "__main__":
main()
+15 -44
View File
@@ -44,35 +44,6 @@ let
fi
fi
# ── Brave Origin migration (existing installs upgrading from `brave`) ──
# The old package's brave-browser.desktop no longer exists, so GNOME drops
# it from the dock and the Browsers folder. Swap the id in place, but only
# when the stale value is still present (never touch user customizations).
if [ -f "$USER_DB" ]; then
FAVS="$(${pkgs.dconf}/bin/dconf read /org/gnome/shell/favorite-apps 2>/dev/null || true)"
if [ -n "$FAVS" ]; then
NEW_FAVS="''${FAVS//brave-browser.desktop/brave-origin.desktop}"
if [ "$NEW_FAVS" != "$FAVS" ]; then
${pkgs.dconf}/bin/dconf write /org/gnome/shell/favorite-apps "$NEW_FAVS"
fi
fi
BROWSER_APPS="$(${pkgs.dconf}/bin/dconf read /org/gnome/desktop/app-folders/folders/Browsers/apps 2>/dev/null || true)"
if [ -n "$BROWSER_APPS" ]; then
NEW_BROWSER_APPS="''${BROWSER_APPS//brave-browser.desktop/brave-origin.desktop}"
if [ "$NEW_BROWSER_APPS" != "$BROWSER_APPS" ]; then
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/app-folders/folders/Browsers/apps "$NEW_BROWSER_APPS"
fi
fi
fi
# A previous fresh install wrote the user's mimeapps.list with the old id;
# XDG gives it precedence over the new system-wide default, so rewrite it.
MIME_LIST="$HOME/.config/mimeapps.list"
if [ -f "$MIME_LIST" ] && ${pkgs.gnugrep}/bin/grep -q 'brave-browser\.desktop' "$MIME_LIST"; then
${pkgs.gnused}/bin/sed -i 's/brave-browser\.desktop/brave-origin.desktop/g' "$MIME_LIST"
fi
# Already applied — skip
if [ -f "$STAMP" ]; then
exit 0
@@ -90,11 +61,11 @@ let
mkdir -p "$HOME/.config"
cat > "$HOME/.config/mimeapps.list" << EOF
[Default Applications]
text/html=brave-origin.desktop
x-scheme-handler/http=brave-origin.desktop
x-scheme-handler/https=brave-origin.desktop
x-scheme-handler/about=brave-origin.desktop
x-scheme-handler/unknown=brave-origin.desktop
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
@@ -133,7 +104,7 @@ 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-origin.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']
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]
@@ -141,7 +112,7 @@ folder-children=['Browsers', 'Office', 'Terminal', 'Chat', 'Bitcoin', 'Media', '
[org/gnome/desktop/app-folders/folders/Browsers]
name='Browsers'
apps=['brave-origin.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
apps=['brave-browser.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
[org/gnome/desktop/app-folders/folders/Office]
name='Office'
@@ -298,7 +269,7 @@ in
];
favorite-apps = [
"brave-origin.desktop"
"brave-browser.desktop"
"org.gnome.Settings.desktop"
"org.gnome.Nautilus.desktop"
"sovran-hub.desktop"
@@ -321,7 +292,7 @@ in
"org/gnome/desktop/app-folders/folders/Browsers" = {
name = "Browsers";
apps = [
"brave-origin.desktop"
"brave-browser.desktop"
"firefox.desktop"
"org.gnome.Epiphany.desktop"
];
@@ -462,13 +433,13 @@ in
];
xdg.mime.defaultApplications = {
"text/html" = "brave-origin.desktop";
"x-scheme-handler/http" = "brave-origin.desktop";
"x-scheme-handler/https" = "brave-origin.desktop";
"x-scheme-handler/about" = "brave-origin.desktop";
"x-scheme-handler/unknown" = "brave-origin.desktop";
"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-origin";
environment.sessionVariables.BROWSER = "brave-browser";
}
-7
View File
@@ -9,13 +9,6 @@
services.openssh = {
enable = true;
# sshd listens on 127.0.0.1 only here, so there is nothing for the firewall
# to let in. NixOS opens sshd's ports by default (openFirewall = true)
# whether or not sshd listens on them, which left port 22 open on every
# role, Desktop Only included. The roles that do publish SSH open it
# themselves: the sshd feature (sshd.nix) and remote deploy
# (remote-deploy.nix) both add 22 explicitly.
openFirewall = lib.mkDefault false;
listenAddresses = lib.mkDefault [
{ addr = "127.0.0.1"; port = 22; }
];
+12 -33
View File
@@ -11,10 +11,11 @@
# (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 restart specific services and
# read logs — without full root, wallet access, Nix editing, or rebuilds.
# • journalctl access is provided only through the root-owned
# sovran-journal-helper script (see below) with an allowlist of safe flags.
# • 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.
{
# ── System packages ────────────────────────────────────────────────────────
environment.systemPackages = [ pkgs.acl ];
@@ -41,40 +42,18 @@
"d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -"
];
# ── Restricted journal helper ─────────────────────────────────────────────
# The helper is root-owned, not writable by any user, and accepts only a
# narrow allowlist of safe journalctl flags. It is the sole mechanism by
# which the support user may read journal logs.
environment.etc."sovran/sovran-journal-helper.py" = {
source = ./sovran-journal-helper.py;
mode = "0500";
user = "root";
group = "root";
};
# ── Scoped sudo rules for support staff ───────────────────────────────────
# Grants only the minimum privileges needed for diagnostic support.
# Editing Nix configuration and running nixos-rebuild are intentionally
# excluded: combining those two permissions provides a trivial path to
# arbitrary root code execution. Systemctl access is limited to a small
# allowlist of named service restart operations. journalctl is available
# only through the restricted helper above.
# 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/systemctl restart sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart caddy.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart bitcoind.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart lnd.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status caddy.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status bitcoind.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status lnd.service"; options = [ "NOPASSWD" ]; }
# Restricted journal helper: accepts only safe flags (--unit, --lines,
# --priority, --since, --until, --output). Rejects paths, directories,
# namespaces, roots, and arbitrary output destinations.
{ command = "/run/current-system/sw/bin/python3 /etc/sovran/sovran-journal-helper.py *"; options = [ "NOPASSWD" ]; }
{ 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" ]; }
];
}
];
+160
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
{
@@ -73,4 +76,161 @@ in
command passwd $argv
end
'';
# ── 1. Auto-Generate Root Password (Runs once) ─────────────
systemd.services.root-password-setup = {
description = "Generate and set a random root password";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
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
# 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. Generate random 'free' password on first boot ──────
systemd.services.free-password-setup = {
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.shadow pkgs.coreutils ];
script = ''
set -euo pipefail
SECRET_FILE="/var/lib/secrets/free-password"
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"
'';
};
}
+30 -349
View File
@@ -33,15 +33,9 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
'';
};
####### ENSURE SERVICES START AFTER KEY & NETWORK EXIST #######
# Ordering against network-online.target matters: livekit-turn-setup detects
# the primary interface from the IPv4 default route. If it runs before the
# network is up (no default route yet) it exits 1 and, being a hard
# dependency of livekit.service, takes livekit down with it — the Hub then
# shows a "failed" red dot until livekit is restarted manually. See the
# livekit-turn-setup block for the matching network-online ordering.
systemd.services.livekit.after = [ "network-online.target" "livekit-key-setup.service" "livekit-turn-setup.service" ];
systemd.services.livekit.wants = [ "network-online.target" "livekit-key-setup.service" "livekit-turn-setup.service" ];
####### ENSURE SERVICES START AFTER KEY EXISTS #######
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" ];
@@ -78,30 +72,17 @@ $MATRIX {
}
$ELEMENT_CALLING {
# 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 {
handle /livekit/jwt/sfu/get {
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 {
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
}
}
reverse_proxy localhost:7880
}
}
EOF
@@ -113,19 +94,11 @@ EOF
# * 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.
# * writes a complete LiveKit config (with turn.domain substituted) that the
# overridden ExecStart loads.
systemd.services.livekit-turn-setup = {
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
# Wait for a default IPv4 route before detecting the interface, and for
# Caddy to have started (cert generation is async, so also see the retry
# loop below). Otherwise on a cold boot this unit can fail / produce empty
# certs, which breaks livekit.service (requiredBy) and shows a red dot.
after = [ "network-online.target" "caddy.service" "livekit-key-setup.service" ];
wants = [ "network-online.target" ];
after = [ "caddy.service" "livekit-key-setup.service" ];
before = [ "livekit.service" ];
requiredBy = [ "livekit.service" ];
wantedBy = [ "multi-user.target" ];
@@ -136,180 +109,62 @@ EOF
unitConfig = {
ConditionPathExists = "/var/lib/domains/element-calling";
};
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk pkgs.python3 ];
path = [ pkgs.coreutils pkgs.findutils ];
script = ''
MATRIX=$(cat /var/lib/domains/matrix)
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
mkdir -p /run/livekit
# 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.
# Caddy issues ACME certs asynchronously, so on a fresh boot the cert may
# not exist yet. Retry (bounded) so we never write an empty turn.crt/key;
# otherwise embedded TURN silently breaks until the next livekit restart.
CRT=""
KEY=""
for _ in $(seq 1 30); do
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
if [ -n "$CRT" ] && [ -n "$KEY" ] \
&& [ -s "$CRT" ] && [ -s "$KEY" ]; then
break
fi
CRT=""
KEY=""
echo "Waiting for Caddy to issue the $MATRIX ACME certificate..."
sleep 2
done
if [ -z "$CRT" ] || [ -z "$KEY" ]; then
echo "ERROR: Caddy ACME certificate for $MATRIX not available after retries; TURN will not be enabled for LiveKit." >&2
else
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
fi
# 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"
# Derive the LAN subnet this box sits on so the embedded TURN relay
# is allowed to hand media to LiveKit's LAN host candidate (see the
# allow_restricted_peer_cidrs block below). Computed from the primary
# interface's own address, so it always matches the subnet the LAN
# clients (phones on Wi-Fi) actually live on.
LAN_CIDR=$(ip -4 -o addr show dev "$IFACE" | awk '{print $4}' | grep -vE '^(127\.|169\.254\.)' | head -n1 | python3 -c 'import sys, ipaddress; s = sys.stdin.read().strip(); print(str(ipaddress.ip_network(s, strict=False)) if s else "")' 2>/dev/null)
if [ -n "$LAN_CIDR" ]; then echo "Derived LAN CIDR for TURN relay: $LAN_CIDR"; else echo "Derived LAN CIDR for TURN relay: <none>"; fi
# 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.
#
# Determine the public IPv4 to advertise in LiveKit ICE candidates.
# Remote peers must be able to reach this address, so it must be the
# server's public IP — or the router's WAN IP when the server is behind
# NAT with port-forwarding. It does not need to be assigned to this box,
# and it may be dynamic.
#
# Nothing here looks the address up. Priority:
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
# 2. /var/lib/secrets/external-ip — the address Njal.la reported for the
# last DDNS update (modules/core/njalla.nix). The runner rewrites that
# file only when the address changes, and livekit-external-ip.path
# then re-runs this script.
# With neither, or with an address that is not public, this unit fails with
# a clear message instead of guessing: advertising a wrong or private
# address is what produces "call connects but no video".
EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}'
PUBLIC_IP="$EXTERNAL_IP"
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
fi
if [ -z "$PUBLIC_IP" ]; then
echo "ERROR: no public IP is known for LiveKit yet." >&2
echo "ERROR: It is recorded after the first successful Njal.la DDNS update (Hub, Domains)." >&2
echo "ERROR: To use a fixed address instead, set sovran_systemsOS.elementCalling.externalIP." >&2
exit 1
fi
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
if printf '%s' "$PUBLIC_IP" | grep -qE \
'^(0\.|127\.|10\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
echo "ERROR: $PUBLIC_IP is not a public address, so remote peers cannot reach LiveKit there." >&2
exit 1
fi
# Generate the full LiveKit config the daemon will load. turn.domain is
# only known at runtime, so it is 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: false
node_ip: $PUBLIC_IP
advertise_internal_ip: true
tcp_port: 7881
use_external_ip: true
udp_port: 7882
interfaces:
includes:
- $IFACE
EOF
echo "LiveKit will advertise public IP: $PUBLIC_IP"
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
# signature against the same key file it issues tokens with, and uses
# the events (participant_left / room_finished) to detect abruptly
# disconnected participants instead of waiting for the delayed-event
# timeout. The URL hits local Caddy via the /etc/hosts loopback
# override and is routed to the JWT service by the element-calling
# vhost (/livekit/jwt/sfu_webhook → 8073).
LK_KEY=$(cut -d: -f1 < ${livekitKeyFile} | tr -d '[:space:]')
# TURN/TLS is intentionally not configured (no tls_port): LiveKit
# advertises turns:<domain>:443 to clients regardless of tls_port, so
# a 5349 TURN/TLS listener would be unreachable and only adds attack
# surface. The staged cert/key stay for a future TURN/TLS-on-443
# (Caddy layer4 SNI) setup.
cat >> /run/livekit/livekit.yaml <<EOF
port_range_start: 30000
port_range_end: 40000
room:
auto_create: false
turn:
enabled: true
domain: $MATRIX
tls_port: 5349
udp_port: 3478
relay_range_start: 40000
relay_range_end: 40099
cert_file: /run/credentials/livekit.service/turn-cert
key_file: /run/credentials/livekit.service/turn-key
EOF
# By default the embedded TURN relay refuses to send media to
# private/loopback peers. That would force its final hop to the
# public/WAN IP (hairpin NAT) — exactly what breaks calls on routers
# without NAT loopback. Allow the LAN subnet so the relay can deliver
# directly to LiveKit's LAN host candidate instead.
if [ -n "$LAN_CIDR" ]; then
cat >> /run/livekit/livekit.yaml <<EOF
allow_restricted_peer_cidrs:
- $LAN_CIDR
EOF
fi
cat >> /run/livekit/livekit.yaml <<EOF
webhook:
api_key: $LK_KEY
urls:
- https://$ELEMENT_CALLING/livekit/jwt/sfu_webhook
EOF
chmod 644 /run/livekit/livekit.yaml
'';
};
####### LIVEKIT SERVICE #######
# NOTE: the runtime config (rtc ports, TURN, webhook, node_ip) is generated
# by livekit-turn-setup and delivered via LoadCredential; the upstream
# module's `settings` block is therefore intentionally NOT used (it would
# be dead config that silently diverges from what LiveKit actually loads).
# The firewall ports are opened explicitly below; openFirewall is left off
# so the upstream module does not also open 7880/tcp publicly (Caddy fronts
# the SFU on this host).
services.livekit = {
enable = true;
openFirewall = false;
openFirewall = true;
keyFile = livekitKeyFile;
settings = {
rtc.use_external_ip = true;
rtc.udp_port = 7882;
rtc.port_range_start = 30000;
rtc.port_range_end = 40000;
room.auto_create = false;
turn = {
enabled = true;
tls_port = 5349;
udp_port = 3478;
};
};
};
# Override ExecStart to load the runtime-generated config (which carries the
@@ -329,15 +184,8 @@ EOF
"turn-key:/var/lib/livekit/turn.key"
];
# 5349/TCP (TURN/TLS) is deliberately absent — see livekit-turn-setup. RTC
# media uses the single UDP mux (7882); the 30000-40000 range is gone so
# media is no longer spread across 10000 ports. The TURN relay allocation
# range (40000-40099) is kept separate from the media mux.
networking.firewall.allowedTCPPorts = [ 7881 ];
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
networking.firewall.allowedUDPPortRanges = [
{ from = 40000; to = 40099; } # LiveKit embedded TURN relay allocation range
];
####### JWT SERVICE RUNTIME CONFIG #######
systemd.services.lk-jwt-service-runtime-config = {
@@ -356,27 +204,11 @@ EOF
path = [ pkgs.coreutils ];
script = ''
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
MATRIX=$(cat /var/lib/domains/matrix)
FULL_ACCESS_HOMESERVERS="$MATRIX"
# Federated peers may also be granted LiveKit room-creation (full access)
# on this SFU via sovran_systemsOS.elementCalling.fullAccessHomeservers.
# Without this, remote users can join existing calls but cannot be the
# first to start one on your SFU.
EXTRA_HS='${lib.concatStringsSep "," config.sovran_systemsOS.elementCalling.fullAccessHomeservers}'
if [ -n "$EXTRA_HS" ]; then
FULL_ACCESS_HOMESERVERS="$FULL_ACCESS_HOMESERVERS,$EXTRA_HS"
fi
mkdir -p /run/lk-jwt-service
cat > /run/lk-jwt-service/env <<EOF
LIVEKIT_URL=wss://$ELEMENT_CALLING
LIVEKIT_FULL_ACCESS_HOMESERVERS=$FULL_ACCESS_HOMESERVERS
# Re-check, every 60s, that connected participants are still on the SFU;
# guards against missed SFU webhooks (e.g. an SFU restart) leaving stale
# call members in Matrix rooms.
LIVEKIT_SANITY_CHECK_INTERVAL_SECONDS=60
EOF
chmod 640 /run/lk-jwt-service/env
@@ -388,9 +220,6 @@ EOF
enable = true;
port = 8073;
keyFile = livekitKeyFile;
# Required by the upstream module's option type, but overridden at runtime
# by EnvironmentFile (/run/lk-jwt-service/env, generated above from the
# element-calling domain). Kept as a harmless placeholder.
livekitUrl = "wss://placeholder.local";
};
@@ -398,143 +227,6 @@ EOF
"/run/lk-jwt-service/env"
];
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
# configs (new domains, externalIP, full-access list), mirroring the domain
# change flow.
systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
# Follow a changing public IP. ddns-update.py rewrites
# /var/lib/secrets/external-ip only when Njal.la reports a different address;
# this path unit then re-runs livekit-turn-setup (new node_ip and TURN
# address) and starts LiveKit if it is not running, e.g. because no address
# was known yet at boot. restartTriggers cannot do this: it is evaluated when
# the system is built, so it cannot watch a file that changes at runtime.
systemd.paths.livekit-external-ip = {
description = "Watch the public IP recorded by the Njal.la DDNS runner";
wantedBy = [ "multi-user.target" ];
pathConfig.PathChanged = "/var/lib/secrets/external-ip";
};
systemd.services.livekit-external-ip = {
description = "Re-run LiveKit setup for a changed public IP";
serviceConfig.Type = "oneshot";
unitConfig.ConditionPathExists = "/var/lib/domains/element-calling";
script = ''
# livekit.service requires livekit-turn-setup, so it restarts with it.
systemctl restart livekit-turn-setup.service
# No-op if LiveKit is already running; starts it after an earlier failure.
systemctl start livekit.service
'';
};
####### PUBLIC REACHABILITY SELF-CHECK #######
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
# classic "call connects but no media" setup errors at boot instead of at
# call time:
# * the element-calling domain having no public records (or resolving to
# loopback/link-local/CGNAT for remote peers),
# * the lk-jwt-service being unreachable through Caddy,
# * the MatrixRTC transports endpoint being absent (Element X cannot
# discover calling and shows MISSING_MATRIX_RTC_TRANSPORT).
# The check queries only the operator's own DNS provider (the domain's
# authoritative nameservers, resolved via the local resolver) plus the
# server's own Caddy and public IP — no third-party resolver or service is
# contacted. dig queries resolvers directly, so the /etc/hosts loopback
# overrides (modules/core/local-domain-loopback.nix) do not influence the
# result.
systemd.services.element-calling-public-check = {
description = "Verify Element Calling domain, JWT service and MatrixRTC transports endpoint are publicly reachable";
after = [ "network-online.target" "caddy.service" "livekit.service" "lk-jwt-service.service" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
unitConfig = {
ConditionPathExists = "/var/lib/domains/element-calling";
};
path = [ pkgs.coreutils pkgs.gawk pkgs.dnsutils pkgs.curl ];
script = ''
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
MATRIX=$(cat /var/lib/domains/matrix)
FAIL=0
echo "── Element Calling public reachability self-check ──"
# 1) Authoritative DNS view (bypassing the /etc/hosts loopback
# overrides). Resolve the domain's own nameservers via the local
# resolver, then query those nameservers directly — the only party
# that sees the query is the DNS provider the operator already uses
# for the domain.
NS_LIST=$(dig +short NS "$ELEMENT_CALLING" 2>/dev/null | tr '\n' ' ')
if [ -n "$NS_LIST" ]; then
IPS=""
for NSRV in $NS_LIST; do
IPS=$( { dig +short A "$ELEMENT_CALLING" "@$NSRV" 2>/dev/null; dig +short AAAA "$ELEMENT_CALLING" "@$NSRV" 2>/dev/null; } | tr '\n' ' ' )
[ -n "$IPS" ] && break
done
echo "Authoritative nameservers for $ELEMENT_CALLING: $NS_LIST"
else
echo "WARNING: could not resolve nameservers for $ELEMENT_CALLING via the local resolver; using the local resolver's answer instead." >&2
IPS=$( { dig +short A "$ELEMENT_CALLING" 2>/dev/null; dig +short AAAA "$ELEMENT_CALLING" 2>/dev/null; } | tr '\n' ' ' )
fi
if [ -z "$IPS" ]; then
echo "ERROR: no A/AAAA records for $ELEMENT_CALLING at its authoritative nameservers. Remote peers cannot reach this LiveKit; calls will connect without media." >&2
FAIL=1
else
echo "Public DNS for $ELEMENT_CALLING: $IPS"
for IP in $IPS; do
case "$IP" in
0.*|127.*|169.254.*|100.64.*|::1|fe80:*|fc*:*|fd*:*)
echo "ERROR: $ELEMENT_CALLING resolves to $IP (loopback/link-local/CGNAT). Remote peers cannot reach it." >&2
FAIL=1 ;;
esac
done
fi
# 2) lk-jwt-service healthz through Caddy (validates the proxy chain).
if curl -fsS --max-time 10 "https://$ELEMENT_CALLING/livekit/jwt/healthz" >/dev/null 2>&1; then
echo "OK: https://$ELEMENT_CALLING/livekit/jwt/healthz responds"
else
echo "ERROR: https://$ELEMENT_CALLING/livekit/jwt/healthz not reachable through Caddy." >&2
FAIL=1
fi
# 3) Same healthz via the first public IP (tests the full NAT path).
# NOTE: if this box is behind the same NAT you are testing through,
# routers without hairpin NAT will fail this step — the warning is
# then expected and harmless; verify from an external device instead.
if [ -n "$IPS" ]; then
PUBIP=$(echo "$IPS" | awk '{print $1}')
if curl -fsS --max-time 15 --resolve "$ELEMENT_CALLING:443:$PUBIP" "https://$ELEMENT_CALLING/livekit/jwt/healthz" >/dev/null 2>&1; then
echo "OK: healthz reachable via public IP $PUBIP (NAT path works)"
else
echo "WARNING: healthz NOT reachable via public IP $PUBIP — check router port-forwarding (443/TCP) and NAT hairpin. Expected if the router lacks hairpin NAT; verify from an external device." >&2
fi
fi
# 4) MatrixRTC transports registry (MSC4519) — required by Element X.
CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "https://$MATRIX/_matrix/client/unstable/org.matrix.msc4143/rtc/transports")
case "$CODE" in
401|200)
echo "OK: MatrixRTC transports endpoint present (HTTP $CODE; auth required is expected)" ;;
404)
echo "ERROR: /_matrix/client/unstable/org.matrix.msc4143/rtc/transports missing (HTTP 404) — Element X cannot discover calling. Enable msc4143_enabled and matrix_rtc.transports in Synapse." >&2
FAIL=1 ;;
*)
echo "WARNING: transports endpoint returned HTTP $CODE" >&2 ;;
esac
if [ "$FAIL" -eq 1 ]; then
echo "── Element Calling self-check FAILED — see errors above ──" >&2
exit 1
fi
echo "── Element Calling self-check passed ──"
'';
};
####### SYNAPSE RUNTIME CONFIG (element-calling additions) #######
systemd.services.element-calling-synapse-config = {
description = "Generate Synapse runtime config for Element Calling";
@@ -551,7 +243,6 @@ EOF
path = [ pkgs.coreutils ];
script = ''
MATRIX=$(cat /var/lib/domains/matrix)
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
mkdir -p /run/matrix-synapse
@@ -561,17 +252,7 @@ public_baseurl: "https://$MATRIX"
serve_server_wellknown: true
experimental_features:
msc3266_enabled: true
# MSC4143: enables the MatrixRTC transports registry endpoint
# (/_matrix/client/unstable/org.matrix.msc4143/rtc/transports, MSC4519).
# Element X requires this endpoint to discover the LiveKit focus; without it
# mobile clients fail with MISSING_MATRIX_RTC_TRANSPORT / cannot start calls.
msc4143_enabled: true
msc4222_enabled: true
# MSC4519: advertise this site's LiveKit focus via the transports registry.
matrix_rtc:
transports:
- type: livekit
livekit_service_url: "https://$ELEMENT_CALLING/livekit/jwt"
max_event_delay_duration: "24h"
rc_message:
per_second: 0.5
+14
View File
@@ -0,0 +1,14 @@
{ config, pkgs, lib, ... }:
lib.mkIf config.sovran_systemsOS.features.mempool {
services.mempool = {
enable = true;
frontend.enable = true;
};
services.mysql.package = lib.mkForce pkgs.mariadb;
nix-bitcoin.onionServices.mempool-frontend.enable = true;
}
+3 -5
View File
@@ -16,27 +16,25 @@
./core/remote-deploy.nix
./core/no-sleep.nix
./core/cpu-performance.nix
./core/local-domain-loopback.nix
# ── Always on (no flag) ───────────────────────────────────
./php.nix
./credentials.nix
./password-model.nix
# ── Services (default ON — disable in custom.nix) ─────────
./synapse.nix
./wordpress.nix
./nextcloud.nix
./vaultwarden.nix
./bitcoinecosystem.nix
./wallet-autoconnect.nix
# ── Features (default OFF — enable in custom.nix) ─────────
./haven.nix
./element-calling.nix
./mempool.nix
./bitcoin-core.nix
./rdp.nix
./sshd.nix
# ── Desktop Role only (gated inside the module) ───────────
./mullvad.nix
];
}
-28
View File
@@ -1,28 +0,0 @@
# ── modules/mullvad.nix ───────────────────────────────────────────────────────
# Mullvad VPN client — Desktop Role only.
#
# A plain install: the daemon (pkgs.mullvad) and the graphical app
# (pkgs.mullvad-vpn) exactly as NixOS ships them, enabled and nothing more. The
# account, the relay, the kill switch and every other setting are the app's
# business; nothing here configures them, and nothing here is wired into the
# Hub or into any other part of the system.
#
# The role gate is the whole point: Server + Desktop and Bitcoin Node are
# machines that serve, and a VPN client with its own DNS and routing policy is
# not something their services asked for. Only roles.desktop builds it.
#
# Turning it off on a Desktop machine: uncomment
# services.mullvad-vpn.enable = lib.mkForce false;
# in custom.nix (the mkForce is needed because this module sets it too).
{ config, lib, ... }:
lib.mkIf config.sovran_systemsOS.roles.desktop {
services.mullvad-vpn = {
# The daemon and its systemd service.
enable = true;
# The desktop app.
gui.enable = true;
};
}
+3 -112
View File
@@ -3,22 +3,10 @@
lib.mkIf config.sovran_systemsOS.services.nextcloud {
# ── PostgreSQL database ───────────────────────────────────
# Cluster-wide tuning (shared_buffers, autovacuum) lives in
# configuration.nix so it is shared with Matrix Synapse.
services.postgresql = {
enable = true;
};
# ── Redis for Nextcloud distributed cache + file locking ───
# Nextcloud does not recommend APCu for memcache.locking in production.
# TCP on localhost avoids unix-socket permission juggling with the caddy user.
# Scoped to Nextcloud only — Synapse / MariaDB / Bitcoin are unaffected.
services.redis.servers.nextcloud = {
enable = true;
bind = "127.0.0.1";
port = 6379;
};
# ── Auto-generate DB password and initialize ──────────────
systemd.services.nextcloud-db-init = {
description = "Initialize Nextcloud PostgreSQL database with auto-generated password";
@@ -59,20 +47,14 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
if ! psql -U postgres -lqt | cut -d \| -f 1 | grep -qw "nextclouddb"; then
psql -U postgres -c "CREATE DATABASE nextclouddb WITH OWNER ncusr TEMPLATE template0 LC_COLLATE = 'C' LC_CTYPE = 'C';"
fi
# NOTE: autovacuum GUCs are SIGHUP-context, so they cannot be set
# per-database — ALTER DATABASE ... SET rejects them with
# 'parameter "..." cannot be changed now'. They are set
# cluster-wide in configuration.nix instead, which already covers
# both nextclouddb and matrix-synapse.
'';
};
# ── Fully automated Nextcloud setup ───────────────────────
systemd.services.nextcloud-init = {
description = "Download, extract, and fully configure Nextcloud";
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" "redis-nextcloud.service" ];
wants = [ "network-online.target" "redis-nextcloud.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" ];
@@ -168,11 +150,7 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
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.distributed --value='\OC\Memcache\Redis'
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
php $INSTALL_DIR/occ config:system:set redis host --value='127.0.0.1'
php $INSTALL_DIR/occ config:system:set redis port --type=integer --value=6379
php $INSTALL_DIR/occ config:system:set redis timeout --value='1.5'
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
"
@@ -269,93 +247,6 @@ CREDS
'';
};
# ── Migrate existing installs to Redis locking ────────────
# nextcloud-init only runs on fresh installs (ConditionPathExists
# !config.php), so pre-existing / pre-Sovran installs would keep
# APCu locking forever. This one-shot is idempotent and safe to
# re-run on every boot — occ just overwrites the same values.
systemd.services.nextcloud-redis-migrate = {
description = "Point existing Nextcloud installs at Redis locking";
after = [ "postgresql.service" "redis-nextcloud.service" "phpfpm-nextcloud.service" ];
wants = [ "redis-nextcloud.service" ];
wantedBy = [ "multi-user.target" ];
unitConfig = {
ConditionPathExists = [
"/var/lib/www/nextcloud/occ"
"/var/lib/www/nextcloud/config/config.php"
];
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = with pkgs; [ coreutils shadow ];
script = ''
set -euo pipefail
INSTALL_DIR="/var/lib/www/nextcloud"
# Wait briefly for Redis (TCP localhost:6379).
for i in $(seq 1 15); do
if (echo > /dev/tcp/127.0.0.1/6379) >/dev/null 2>&1; then
break
fi
sleep 2
done
/run/wrappers/bin/su -s /bin/sh caddy -c "
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
php $INSTALL_DIR/occ config:system:set memcache.distributed --value='\OC\Memcache\Redis'
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
php $INSTALL_DIR/occ config:system:set redis host --value='127.0.0.1'
php $INSTALL_DIR/occ config:system:set redis port --type=integer --value=6379
php $INSTALL_DIR/occ config:system:set redis timeout --value='1.5'
"
'';
};
# ── Recurring DB maintenance (Nextcloud 35 checks) ───────────
# nextcloud-init runs db:add-missing-indices exactly once. Upgrades
# (e.g. to NC35) and later app installs (Mail, Guests) add tables
# like oc_mail_tags / oc_guests_users that then seq-scan forever.
# Weekly: VACUUM ANALYZE (dead tuples) + backfill missing indices.
# Scoped to nextclouddb only — matrix-synapse is untouched.
systemd.services.nextcloud-db-maintenance = {
description = "Nextcloud DB maintenance: VACUUM + missing indices";
after = [ "postgresql.service" "redis-nextcloud.service" "phpfpm-nextcloud.service" ];
wants = [ "postgresql.service" ];
unitConfig = {
ConditionPathExists = [
"/var/lib/www/nextcloud/occ"
"/var/lib/www/nextcloud/config/config.php"
];
};
serviceConfig = {
Type = "oneshot";
};
path = [ config.services.postgresql.package pkgs.coreutils pkgs.shadow ];
script = ''
set -euo pipefail
INSTALL_DIR="/var/lib/www/nextcloud"
echo "Vacuuming nextclouddb..."
psql -U postgres -d nextclouddb -c "VACUUM (ANALYZE);"
echo "Backfilling Nextcloud indices..."
/run/wrappers/bin/su -s /bin/sh caddy -c "
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
"
echo "Nextcloud DB maintenance complete."
'';
};
systemd.timers.nextcloud-db-maintenance = {
description = "Weekly Nextcloud DB maintenance";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "Sun 03:30";
Persistent = true;
RandomizedDelaySec = "30m";
};
};
services.cron.systemCronJobs = [
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
];
-127
View File
@@ -1,127 +0,0 @@
{ config, pkgs, lib, ... }:
# Sovran_SystemsOS -- password model cleanup (GNOME 50 / gnome-keyring 50)
#
# * /etc/shadow is the only source of truth for authentication (Hub login == GDM login).
# * /var/lib/secrets/{free,root}-password are root-only DISPLAY copies for the Hub, written
# by exactly one module (system_password.py) and verified against shadow before shown.
# * The GNOME login keyring is decoupled from the login password: it is created with a
# blank password and pam_gnome_keyring never hands the login password to the daemon.
# => no "unlock your login keyring" prompts, ever (also on the autologin live ISO);
# Brave's cookie/password key survives every password change; no *.keyring wiping.
# * Legacy machines (password unknown to the Hub) are handled by the Hub's existing
# onboarding "migration password" step -- not by a boot service any more.
#
# Import from configuration.nix and DELETE these lines there:
# security.pam.services.gdm-password.enableGnomeKeyring = true;
# security.pam.services.gdm-autologin.enableGnomeKeyring = true;
# the four systemd.tmpfiles.rules for /home/free/.local/share/keyrings/{,default}
# and in modules/credentials.nix delete root-password-setup, free-password-setup,
# free-password-migration and the `rm -f /home/free/.local/share/keyrings/*.keyring` lines.
let
user = "free";
home = "/home/${user}";
# A login keyring with a blank password. gnome-keyring stores those in this textual
# format and auto-unlocks them on demand without any prompt (verified on 48; the rule in
# gkm-secret-collection.c that picks the textual format for a blank master is unchanged
# in 50 and the 50.0 release notes contain no behavioural changes).
blankLoginKeyring = pkgs.writeText "login.keyring" ''
[keyring]
display-name=login
ctime=0
mtime=0
lock-on-idle=false
lock-after=false
'';
# Apply the installer's plaintext password ONCE: only while the account still has no
# usable hash (nixos-install --no-root-password leaves both accounts locked). After that
# the file is display-only and the Hub (system_password.py) is the only thing that
# writes it. This replaces the old services, which re-applied the file on EVERY boot
# and silently reverted any password changed elsewhere.
# On already-installed machines the case below matches `*)` (real hash) -> no-op:
# an upgrade can never change or generate a password.
applyOnce = { account, fallback ? false }: ''
set -euo pipefail
f=/var/lib/secrets/${account}-password
hash="$(getent shadow ${account} | cut -d: -f2 || true)"
case "$hash" in
""|"!"*|"*"*)
if [ -s "$f" ] && ! grep -qE '^[0-9a-f]{32}:[0-9a-f]{64,}$' "$f"; then
echo "${account}:$(head -n1 "$f")" | chpasswd
${lib.optionalString fallback ''
else
# Installer left no usable file: generate one so GDM is always reachable
# (the user reads it in the Hub). Never runs once the account has a password.
W="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"
pw=$(echo $W | tr ' ' '\n' | shuf -n4 | paste -sd- -)-$(printf '%02d' $((RANDOM % 100)))
umask 077; printf '%s\n' "$pw" > "$f"
echo "${account}:$pw" | chpasswd
''}
fi ;;
*) ;; # password already set: never touch it here again
esac
'';
in
{
# ── 1. Keyring: never learns the login password ───────────────────────────
# nixpkgs' gnome-keyring module turns this on for `login` (gdm-password includes it).
# If the daemon ever receives the login password it re-encrypts the blank keyring with
# it on the next save -- so it must be off everywhere.
security.pam.services.login.enableGnomeKeyring = lib.mkForce false;
security.pam.services.gdm-password.enableGnomeKeyring = lib.mkForce false;
security.pam.services.gdm-autologin.enableGnomeKeyring = lib.mkForce false;
security.pam.services.passwd.enableGnomeKeyring = lib.mkForce false;
# Seed the blank keyring. `C` copies only if the file does not exist, so an existing
# keyring is never overwritten -- which is also what makes a reset's `rm *.keyring`
# harmless: the next boot re-seeds a fresh blank keyring.
systemd.tmpfiles.rules = [
"d ${home}/.local 0700 ${user} users -"
"d ${home}/.local/share 0700 ${user} users -"
"d ${home}/.local/share/keyrings 0700 ${user} users -"
"C ${home}/.local/share/keyrings/login.keyring 0600 ${user} users - ${blankLoginKeyring}"
];
# One-time migration for machines that already have a keyring encrypted with the
# (old) login password: move it aside and seed the blank one. Runs before GDM; the old
# file is kept. Same cost users pay today on every password change (Brave logs you out
# of sites once) -- but only once, ever.
system.activationScripts.sovranBlankKeyring = lib.stringAfter [ "users" ] ''
d=${home}/.local/share/keyrings
if [ -f "$d/login.keyring" ] && ! head -c 9 "$d/login.keyring" | grep -q '^\[keyring\]'; then
mv "$d/login.keyring" "$d/login.keyring.encrypted-$(date +%Y%m%d)"
install -o ${user} -g users -m 0600 ${blankLoginKeyring} "$d/login.keyring"
fi
'';
# ── 2. First-boot password hand-off (unit names kept: the Hub's "System Passwords"
# service card is bound to root-password-setup.service) ───────────────────────
systemd.services.free-password-setup = {
description = "Apply the installer's 'free' password (first boot only)";
wantedBy = [ "multi-user.target" ];
before = [ "display-manager.service" ];
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; };
path = [ pkgs.shadow pkgs.coreutils pkgs.gnugrep pkgs.getent ];
script = applyOnce { account = user; fallback = true; };
};
systemd.services.root-password-setup = {
description = "Apply the installer's root password (first boot only)";
wantedBy = [ "multi-user.target" ];
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; };
path = [ pkgs.shadow pkgs.coreutils pkgs.gnugrep pkgs.getent ];
script = applyOnce { account = "root"; };
};
# Legacy detection now lives in the Hub (system_password.ensure_migration_pending).
systemd.services.free-password-migration.enable = false;
# ── 3. The Hub verifies passwords against /etc/shadow via libxcrypt ───────
systemd.services.sovran-hub-web.environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1";
}
+11 -53
View File
@@ -1,70 +1,29 @@
{ config, pkgs, lib, ... }:
# ── Shared PHP for Nextcloud + WordPress ──────────────────────────────────────
#
# One interpreter (with one extension set and one php.ini) is shared by the
# phpfpm-nextcloud and phpfpm-wordpress pools, the Nextcloud cron job and the
# occ / wp-cli helper scripts. Every consumer must reference
# config.sovran_systemsOS.phpPackage (or /run/current-system/sw/bin/php) so
# that the CLI and the FPM pools always run the *same* PHP.
#
# Version policy (September 2026):
# • Nextcloud 35 supports PHP 8.3 / 8.4 / 8.5 and recommends 8.5. Its setup
# check flags 8.3 as "deprecated since Nextcloud 35" and warns that
# Nextcloud 36 may require at least 8.4.
# • WordPress 6.9 / 7.0 fully support PHP 8.4 and 8.5.
# • PHP 8.3 has been security-only since 2025-12-31; PHP 8.4 leaves active
# support on 2026-12-31; PHP 8.5 is actively supported until 2027-12-31.
#
# To fall back to PHP 8.4 (nixpkgs' current default `pkgs.php`) change only
# the `phpBase` line below.
let
phpBase = pkgs.php85;
custom-php = phpBase.buildEnv {
# `enabled` is nixpkgs' default extension set. It already contains every
# module Nextcloud lists as required or recommended (ctype, curl, dom,
# fileinfo, gd, intl, mbstring, openssl, posix, session, simplexml,
# xmlreader, xmlwriter, zip, zlib, pdo_pgsql, pdo_mysql, bcmath, gmp,
# exif, sodium, sysvsem, pcntl, ...). OPcache is compiled into PHP >= 8.5
# and no longer appears as a separate extension.
extensions = { enabled, all }: enabled ++ (with all; [
bz2 # Nextcloud: bz2 archive support
apcu # Nextcloud: memcache.local (apc.enable_cli=1 below is mandatory for occ + cron)
redis # Nextcloud: memcache.distributed / file locking once a Redis server is configured
imagick # Nextcloud: previews + theming (nixpkgs ImageMagick is built with SVG support)
memcached # WordPress object-cache plugins (legacy option for Nextcloud)
]);
custom-php = pkgs.php83.buildEnv {
extensions = { enabled, all }: enabled ++ (with all; [ bz2 apcu redis imagick memcached ]);
extraConfig = ''
; ── Error handling (production) ─────────────────────────────────
display_errors = Off
display_startup_errors = Off
log_errors = On
; ── Limits ──────────────────────────────────────────────────────
display_errors = On
display_startup_errors = On
max_execution_time = 10000
max_input_time = 3000
memory_limit = 1G
memory_limit = 1G;
opcache.enable=1;
opcache.memory_consumption=512;
opcache_revalidate_freq = 240;
opcache.max_accelerated_files=20000;
post_max_size = 3G
upload_max_filesize = 3G
; ── OPcache (Nextcloud "Server tuning" recommendations) ─────────
opcache.enable = 1
opcache.memory_consumption = 512
opcache.interned_strings_buffer = 192
opcache.max_accelerated_files = 20000
opcache.revalidate_freq = 240
opcache.save_comments = 1
; ── APCu ────────────────────────────────────────────────────────
apc.enable_cli=1
; ── phpredis session locking (only used with session.save_handler = redis)
opcache.interned_strings_buffer = 192
redis.session.locking_enabled=1
redis.session.lock_retries=-1
redis.session.lock_wait_time=10000
'';
};
in
@@ -96,4 +55,3 @@ in
];
};
}
Regular → Executable
+55 -120
View File
@@ -2,104 +2,70 @@
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
# Ensure the service actually starts and waits for setup to complete
systemd.services.gnome-remote-desktop = {
wantedBy = [ "graphical.target" ];
after = [ "gnome-remote-desktop-setup.service" ];
requires = [ "gnome-remote-desktop-setup.service" ];
wants = [ "gnome-remote-desktop-setup.service" ];
};
systemd.tmpfiles.rules = [
"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 -"
"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 -"
];
systemd.services.gnome-remote-desktop-setup = {
description = "Configure GNOME Remote Desktop RDP";
wantedBy = [ "graphical.target" ];
wantedBy = [ "multi-user.target" ];
before = [ "gnome-remote-desktop.service" ];
after = [
"dbus.service"
"systemd-tmpfiles-setup.service"
"network-online.target"
"gnome-remote-desktop-configuration.service"
];
wants = [
"network-online.target"
"gnome-remote-desktop-configuration.service"
];
after = [ "systemd-tmpfiles-setup.service" "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
TimeoutStartSec = "2min";
RemainAfterExit = true;
};
path = [
pkgs.coreutils
pkgs.gawk
pkgs.gnome-remote-desktop
pkgs.hostname
pkgs.polkit
pkgs.openssl
pkgs.systemd
pkgs.hostname
pkgs.gawk
];
script = ''
set -euo pipefail
# 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
# 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"
TLS_DIR="/var/lib/gnome-remote-desktop/tls"
CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials"
# 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:%G' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop:gnome-remote-desktop" ]; then
elif [ "$(stat -c '%U' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "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 \
@@ -109,59 +75,39 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
echo "Generated new RDP TLS certificate"
fi
chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
# Always fix ownership and permissions (handles re-enable after disable)
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
chmod 600 "$TLS_DIR/rdp-tls.key"
chmod 644 "$TLS_DIR/rdp-tls.crt"
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"
# 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 "$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"
# 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)
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: $USERNAME
Username: sovran
Password: $PASSWORD
Connect from any RDP client to:
@@ -170,22 +116,11 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
========================================
EOF
chown gnome-remote-desktop:gnome-remote-desktop "$CRED_FILE"
chmod 600 "$CRED_FILE"
# 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"
# Enable RDP backend and set credentials
grdctl --system rdp enable
grdctl --system rdp set-credentials sovran "$PASSWORD"
echo "GNOME Remote Desktop RDP configured successfully"
'';
-186
View File
@@ -1,186 +0,0 @@
# Sovran Bitcoin integration layer — bridges Sovran_SystemsOS options to the
# Sovran_Bitcoin flake module.
#
# This is the ONLY place where OS-specific Bitcoin customizations live.
# All bitcoin service modules (bitcoind, electrs, lnd, rtl, btcpayserver,
# mempool, albyhub, lnurl) and vendored packages come from the Sovran_Bitcoin
# flake input.
#
# What this file does:
# 1. Maps sovran_systemsOS.services.bitcoin → sovran-bitcoin.enable
# 2. Maps sovran_systemsOS.features.* → sovran-bitcoin.features.*
# 3. Applies OS-specific overrides (Second_Drive paths, operator "free",
# forced wallet enable, Hub firewall port, domain requirements)
# 4. Wires the Sovran Hub's NWC environment to the Alby Hub service
{ config, pkgs, lib, options, ... }:
let
cfg = config.sovran_systemsOS;
# ── NWC environment for the Sovran Hub web app ─────────────────
# The Hub's web package provides nwc-wallet and nwc-lnurl binaries that
# need to know where Alby Hub and LND are. Sovran_Bitcoin's modules
# handle the base Alby Hub service; this layers on the Hub-specific
# tooling environment.
lndRpcAddress = config.services.lnd.rpcAddress or "127.0.0.1";
lndRpcPort = toString (config.services.lnd.rpcPort or 10009);
lndCertPath = config.services.lnd.certPath or "/var/lib/lnd/tls.cert";
hubNwcEnvironment = {
NWC_ALBY_HUB_API_BASE = "http://127.0.0.1:18080";
NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
NWC_LND_CERT_FILE = lndCertPath;
NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
NWC_RELAY =
if cfg.features.haven && config.networking.domain != null && config.networking.domain != ""
then "wss://haven.${config.networking.domain}/nostr"
else "wss://relay.getalby.com,wss://relay2.getalby.com";
};
in {
# ── 1. Map OS options → Sovran_Bitcoin options ─────────────────
sovran-bitcoin = lib.mkIf cfg.services.bitcoin {
enable = true;
operatorName = "free";
bitcoindTorGossip = cfg.features.bitcoin-tor-gossip;
features = {
electrs = true;
lnd = true;
rtl = true;
btcpayserver = cfg.web.btcpayserver;
mempool = cfg.features.mempool;
nwc = cfg.features."nwc-wallets";
lnurl = cfg.features."nwc-wallets";
};
};
# ── 2. Second_Drive data paths (OS-specific) ──────────────────
services.bitcoind = lib.mkIf cfg.services.bitcoin ({
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
# The OS always enables the bitcoind wallet — the Hub and BTCPay need it.
disablewallet = lib.mkForce false;
# electrs 0.12.0 (bindex) requires bitcoind's REST interface (rest=1).
extraConfig = lib.optionalString (!(options.services.bitcoind ? rest)) "rest=1";
} // lib.optionalAttrs cfg.web.btcpayserver {
rpc.users.btcpayserver.rpcwhitelist = lib.mkForce (lib.unique (config.services.bitcoind.rpc.users.public.rpcwhitelist ++ [
"setban"
"generatetoaddress"
"getrpcinfo"
"gettransaction"
"getwalletinfo"
"getnewaddress"
"listunspent"
"listtransactions"
"listreceivedbyaddress"
"importmulti"
"importdescriptors"
"importaddress"
"importprivkey"
"importpubkey"
"rescanblockchain"
"createwallet"
"loadwallet"
"signrawtransactionwithwallet"
]));
});
services.electrs = lib.mkIf cfg.services.bitcoin {
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data";
};
# ── 3. Second_Drive mount dependencies ─────────────────────────
systemd.services.bitcoind = lib.mkIf cfg.services.bitcoin {
requires = [ "run-media-Second_Drive.mount" ];
after = [ "run-media-Second_Drive.mount" ];
serviceConfig.PrivateUsers = lib.mkForce false;
};
systemd.services.electrs = lib.mkIf cfg.services.bitcoin (
let
bitcoind = config.services.bitcoind;
electrsCfg = config.services.electrs;
nbLib = config.nix-bitcoin.lib;
in {
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
wants = [ "bitcoind.service" ];
} // lib.optionalAttrs (!(options.services.bitcoind ? rest)) {
# Fallback when locked against Sovran_Bitcoin <= 1.1.1:
# electrs 0.12.0 removed --daemon-p2p-addr and switched to bindex (format v1).
preStart = lib.mkAfter ''
dbNetDir='${electrsCfg.dataDir}/${bitcoind.makeNetworkName "bitcoin" "regtest"}'
if [[ -d "$dbNetDir" ]] && grep -q 'prefix_extractor=rocksdb\.FixedPrefix\.8' "$dbNetDir"/OPTIONS-* 2>/dev/null; then
echo "electrs: removing pre-0.12.0 database at $dbNetDir for bindex 0.12.0 reindex"
rm -rf "$dbNetDir"
fi
'';
serviceConfig.ExecStart = lib.mkForce ''
${pkgs.electrs}/bin/electrs \
--log-filters=INFO \
--network=${bitcoind.makeNetworkName "bitcoin" "regtest"} \
--db-dir='${electrsCfg.dataDir}' \
--daemon-dir='${bitcoind.dataDir}' \
--electrum-rpc-addr=${electrsCfg.address}:${toString electrsCfg.port} \
--monitoring-addr=${electrsCfg.address}:${toString electrsCfg.monitoringPort} \
--daemon-rpc-addr=${nbLib.addressWithPort bitcoind.rpc.address bitcoind.rpc.port} \
${electrsCfg.extraArgs}
'';
}
);
systemd.services.lnd = lib.mkIf cfg.services.bitcoin {
wants = [ "bitcoind.service" ];
# nix-bitcoin sets `requires = [ "bitcoind.service" ]`; the OS removes it
# so LND can start even if bitcoind is temporarily down.
requires = lib.mkForce [ ];
};
# ── 4. Permission fixup for Second_Drive ───────────────────────
systemd.services.sovran-btc-permissions = lib.mkIf cfg.services.bitcoin {
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
'';
};
# ── 5. Firewall — RTL ──────────────────────────────────────────
# RTL is a web app served by Caddy over TCP on 3051. The matching UDP rule
# that used to sit here was carried over from the TCP line and opened a port
# nothing listens on.
networking.firewall.allowedTCPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
# ── 6. NWC / LNURL — Sovran Hub integration ───────────────────
# Sovran_Bitcoin's albyhub.nix and lnurl.nix handle the base services.
# This section wires the Hub's web app environment so the Hub can
# display NWC status and the nwc-wallet CLI works from the Hub shell.
services.sovran-lnurl = lib.mkIf cfg.features."nwc-wallets" {
# The Hub writes the Lightning Address domain to this file.
domainFile = "/var/lib/domains/lightning";
};
systemd.services.sovran-hub-web.environment = lib.mkIf cfg.features."nwc-wallets"
hubNwcEnvironment;
# ── 7. Domain requirements ─────────────────────────────────────
sovran_systemsOS.domainRequirements = lib.mkIf cfg.services.bitcoin (
[
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
]
++ lib.optionals cfg.features."nwc-wallets" [
{ name = "lightning"; label = "Lightning Address Domain"; example = "pay.yourdomain.com"; needsDDNS = true; }
]
);
}
-57
View File
@@ -94,23 +94,16 @@ EOF
# ── Synapse service ─────────────────────────────────────────
services.matrix-synapse = {
enable = true;
# cache-memory provides cache-size statistics for the autotuning below
# (in addition to the NixOS defaults).
extras = [ "systemd" "postgres" "url-preview" "cache-memory" ];
extraConfigFiles = [
"/run/matrix-synapse/runtime-config.yaml"
];
settings = {
database = {
name = "psycopg2";
# Recycle pooled connections less often (fewer reconnects).
txn_limit = 10000;
args = {
host = "localhost";
database = "matrix-synapse";
user = "matrix-synapse";
cp_min = 5;
cp_max = 15;
};
};
push.include_content = false;
@@ -127,32 +120,6 @@ EOF
];
presence.enabled = true;
enable_registration = false;
# ── Performance (32 GB Server + Desktop) ─────────────────
# Synapse trades RAM for fewer Postgres round-trips; most RAM goes
# to caches. Stock is global_factor 0.5 + 10K event cache, which
# leaves syncs hitting the database on every request.
# Deliberately unchanged: presence and URL previews stay enabled —
# disabling them is faster but changes user-visible behavior.
event_cache_size = "100K";
caches = {
global_factor = 4.0;
expire_caches = true;
cache_entry_ttl = "30m";
sync_response_cache_duration = "2m";
cache_autotuning = {
max_cache_memory_usage = "2G";
target_cache_memory_usage = "1G";
min_cache_ttl = "30s";
};
per_cache_factors = {
# Hot paths for /sync and room joins.
get_users_in_room = 3.0;
get_current_state_ids = 3.0;
get_unread_event_push_actions_by_room_for_user = 5.0;
};
};
# Fewer GC pauses at the cost of a little more memory.
gc_thresholds = [ 1500 20 10 ];
listeners = [
{
port = 8008;
@@ -262,30 +229,6 @@ CREDS
chmod 600 "$CREDS_FILE"
fi
# Provision a private, random Matrix administrator for the Hub's Admin
# API calls. This is deliberately separate from the visible bootstrap
# admin account: installations where @admin pre-dated Sovran do not have
# its password, but Hub user management must still work. Keeping the
# generated localpart in this root-only file makes the operation
# idempotent without exposing this service credential in the Hub UI.
HUB_ADMIN_CREDS="/var/lib/secrets/matrix-hub-admin"
if [ ! -s "$HUB_ADMIN_CREDS" ]; then
HUB_ADMIN_USER="sovran-hub-$(pwgen -sA0 20 1)"
HUB_ADMIN_PASS=$(pwgen -s 32 1)
if register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
-u "$HUB_ADMIN_USER" -p "$HUB_ADMIN_PASS" -a http://localhost:8008; then
(umask 077; cat > "$HUB_ADMIN_CREDS" <<CREDS
username=$HUB_ADMIN_USER
password=$HUB_ADMIN_PASS
CREDS
)
echo "Created private Matrix Hub service administrator."
else
echo "Failed to create the private Matrix Hub service administrator." >&2
exit 1
fi
fi
# Always write individual credential files for the hub UI, even if the bulk
# credentials file already existed from a prior run (umask 077 ensures mode 600).
# If passwords were not freshly generated above, parse them from the bulk file.
+7 -22
View File
@@ -47,44 +47,29 @@ EOF
systemd.services.zeus-connect-setup = {
description = "Save Zeus lndconnect URL";
wantedBy = [ "multi-user.target" ];
after = [ "lnd.service" "onion-addresses.service" ];
wants = [ "lnd.service" "onion-addresses.service" ];
after = [ "lnd.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
# sudo is required: the lndconnect wrapper re-execs as the lnd user so it
# can read admin.macaroon (not group-readable).
path = [ pkgs.coreutils pkgs.gnugrep pkgs.sudo "/run/current-system/sw" ];
path = [ pkgs.coreutils "/run/current-system/sw" ];
script = ''
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
mkdir -p /var/lib/secrets
# LND may still be creating the wallet / macaroon, and the dedicated
# lnd-rest onion hostname is published by onion-addresses.service.
URL=""
ATTEMPTS=0
while [ "$ATTEMPTS" -lt 60 ]; do
if command -v lndconnect >/dev/null 2>&1; then
URL=$(lndconnect --url 2>/dev/null | tr -d '\r' | tail -n 1 || true)
URL=$(lndconnect --url 2>/dev/null || true)
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
fi
# Zeus LND REST over Tor: lndconnect://<v3-onion>:8080?macaroon=...
if echo "$URL" | grep -q '^lndconnect://' \
&& echo "$URL" | grep -q '\.onion' \
&& echo "$URL" | grep -q 'macaroon='; then
break
fi
URL=""
ATTEMPTS=$((ATTEMPTS + 1))
sleep 2
done
if [ -n "$URL" ]; then
printf '%s\n' "$URL" > "$SECRET_FILE"
echo "$URL" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
echo "Zeus connect URL saved."
else
echo "No valid lndconnect URL available yet."
echo "No lndconnect URL available yet."
fi
'';
};
-563
View File
@@ -1,563 +0,0 @@
#!/usr/bin/env bash
#
# release-stable.sh
# Automated stable release script for Sovran_SystemsOS
#
# Features:
# - Detects latest tag and suggests next version
# - Pushes main → stable on Gitea
# - Creates annotated tag
# - Auto-updates CHANGELOG.md
# - Creates releases on both GitHub and Gitea via API
#
# Usage:
# ./scripts/release-stable.sh [version] [--message "text"]
#
# Requirements:
# - gh CLI (for GitHub releases)
# - GITEA_TOKEN env var (for Gitea releases)
#
set -euo pipefail
# Always operate from the repository root, regardless of the caller's cwd.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
# Configuration
GITEA_REMOTE_DEFAULT="gitea"
GITHUB_REMOTE_DEFAULT="origin"
GITEA_API_URL="https://git.sovransystems.com/api/v1"
CHANGELOG_FILE="CHANGELOG.md"
# Auto-detect remotes
detect_remote() {
local preferred="$1"
local keyword="$2"
if git remote | grep -q -x "$preferred"; then
echo "$preferred"
return
fi
local found
found=$(git remote -v | grep -i "$keyword" | head -n 1 | awk '{print $1}')
if [[ -n "$found" ]]; then
echo "$found"
return
fi
echo "$preferred"
}
GITEA_REMOTE=$(detect_remote "$GITEA_REMOTE_DEFAULT" "sovransystems\|gitea")
GITHUB_REMOTE=$(detect_remote "$GITHUB_REMOTE_DEFAULT" "github")
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Sovran_SystemsOS Automated Stable Release Script ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
echo
# ─────────────────────────────────────────────────────────────────────────────
# Helper: Get latest tag
# ─────────────────────────────────────────────────────────────────────────────
# GitHub release tags are fetched into a private namespace instead of
# refs/tags/. GitHub and Gitea contain some same-named historical tags that
# point to different objects; sharing refs/tags/ would make either fetch fail
# with "would clobber existing tag".
GITHUB_TAG_NAMESPACE="refs/release-tags/github"
get_latest_tag() {
local exclude="${1:-}"
local tag
while IFS= read -r tag; do
if [[ -n "$exclude" && "$tag" == "v${exclude#v}" ]]; then
continue
fi
printf '%s\n' "$tag"
return 0
done < <(git for-each-ref \
--sort=-version:refname \
--format='%(refname:strip=3)' \
"${GITHUB_TAG_NAMESPACE}/v*")
return 0
}
# ─────────────────────────────────────────────────────────────────────────────
# Helper: Suggest next version (patch bump)
# ─────────────────────────────────────────────────────────────────────────────
suggest_next_version() {
local current="$1"
if [[ -z "$current" ]]; then
echo "1.0.0"
return
fi
# Remove 'v' prefix
local ver="${current#v}"
IFS='.' read -r major minor patch <<< "$ver"
# Default: bump patch
echo "${major}.$((minor)).$((patch + 1))"
}
# ─────────────────────────────────────────────────────────────────────────────
# Step 0: Preflight and fetch everything
# ─────────────────────────────────────────────────────────────────────────────
echo -e "${BLUE}Step 0: Running release preflight...${NC}"
# A release must start from an exact, committed, reproducible tree. This also
# prevents release metadata generated below from being mixed with local work.
if [[ -n "$(git status --porcelain)" ]]; then
echo -e "${RED}Error: the working tree is not clean. Commit, stash, or remove local changes first.${NC}" >&2
git status --short >&2
exit 1
fi
for remote in "$GITHUB_REMOTE" "$GITEA_REMOTE"; do
if ! git remote | grep -q -x "$remote"; then
echo -e "${RED}Error: required remote '$remote' is not configured.${NC}" >&2
exit 1
fi
done
for command_name in gh curl; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo -e "${RED}Error: required command '$command_name' is not installed.${NC}" >&2
exit 1
fi
done
if ! gh auth status >/dev/null 2>&1; then
echo -e "${RED}Error: GitHub CLI is not authenticated. Run 'gh auth login' first.${NC}" >&2
exit 1
fi
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo
read -rsp "Enter your Gitea API token (input hidden): " GITEA_TOKEN
echo
fi
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo -e "${RED}Error: a GITEA_TOKEN with write:repository scope is required.${NC}" >&2
exit 1
fi
echo -e " Fetching GitHub (${GITHUB_REMOTE}) and Gitea (${GITEA_REMOTE})..."
# Never fetch either host's tags into refs/tags/. Historical tags with the same
# name differ between the hosts, and Git correctly refuses to clobber them.
git fetch "$GITHUB_REMOTE" --prune --no-tags
git fetch "$GITEA_REMOTE" --prune --no-tags
git fetch "$GITHUB_REMOTE" --prune --no-tags \
"+refs/tags/*:${GITHUB_TAG_NAMESPACE}/*"
GITHUB_MAIN_REF="refs/remotes/${GITHUB_REMOTE}/main"
GITEA_STAGING_REF="refs/remotes/${GITEA_REMOTE}/staging-dev"
if ! git rev-parse --verify "$GITHUB_MAIN_REF" >/dev/null 2>&1; then
echo -e "${RED}Error: cannot resolve GitHub main at ${GITHUB_MAIN_REF}.${NC}" >&2
exit 1
fi
if ! git rev-parse --verify "$GITEA_STAGING_REF" >/dev/null 2>&1; then
echo -e "${RED}Error: cannot resolve Gitea staging-dev at ${GITEA_STAGING_REF}.${NC}" >&2
exit 1
fi
HEAD_COMMIT=$(git rev-parse HEAD)
GITHUB_MAIN_COMMIT=$(git rev-parse "$GITHUB_MAIN_REF")
GITEA_STAGING_COMMIT=$(git rev-parse "$GITEA_STAGING_REF")
if [[ "$GITHUB_MAIN_COMMIT" != "$GITEA_STAGING_COMMIT" ]]; then
echo -e "${RED}Error: GitHub main and Gitea staging-dev are not synchronized.${NC}" >&2
echo " GitHub main : $GITHUB_MAIN_COMMIT" >&2
echo " Gitea staging-dev : $GITEA_STAGING_COMMIT" >&2
exit 1
fi
if [[ "$HEAD_COMMIT" != "$GITHUB_MAIN_COMMIT" ]]; then
echo -e "${RED}Error: local HEAD is not the synchronized release candidate.${NC}" >&2
echo " Local HEAD : $HEAD_COMMIT" >&2
echo " Remote HEAD : $GITHUB_MAIN_COMMIT" >&2
echo "Update/check out the synchronized commit, then run the script again." >&2
exit 1
fi
echo -e " ${GREEN}✓${NC} Clean tree; GitHub main and Gitea staging-dev match local HEAD"
LATEST_TAG=$(get_latest_tag)
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
echo -e " Latest tag : ${CYAN}${LATEST_TAG:-none}${NC}"
echo -e " Suggested : ${GREEN}v${NEXT_VERSION}${NC}"
echo
# ─────────────────────────────────────────────────────────────────────────────
# Get version from user or argument
# ─────────────────────────────────────────────────────────────────────────────
if [ $# -ge 1 ] && [[ "$1" != --* ]]; then
VERSION="$1"
shift
else
read -rp "Enter version to release (default: ${NEXT_VERSION}): " input_version
VERSION="${input_version:-$NEXT_VERSION}"
fi
# Strip 'v' if user added it
VERSION="${VERSION#v}"
TAG="v${VERSION}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${RED}Error: version must use MAJOR.MINOR.PATCH format (for example, 1.1.1).${NC}" >&2
exit 1
fi
remote_has_tag() {
local remote="$1"
[[ -n "$(git ls-remote --tags "$remote" "refs/tags/${TAG}" "refs/tags/${TAG}^{}")" ]]
}
if git show-ref --verify --quiet "refs/tags/${TAG}" || \
git show-ref --verify --quiet "${GITHUB_TAG_NAMESPACE}/${TAG}" || \
remote_has_tag "$GITHUB_REMOTE" || remote_has_tag "$GITEA_REMOTE"; then
echo -e "${RED}Error: tag ${TAG} already exists locally or on a remote.${NC}" >&2
echo "Refusing to move or overwrite an existing release tag." >&2
exit 1
fi
# ─────────────────────────────────────────────────────────────────────────────
# Get release message
# ─────────────────────────────────────────────────────────────────────────────
RELEASE_MESSAGE=""
while [[ $# -gt 0 ]]; do
case $1 in
--message|-m)
RELEASE_MESSAGE="$2"
shift 2
;;
*)
shift
;;
esac
done
if [[ -z "$RELEASE_MESSAGE" ]]; then
echo
read -rp "Enter release headline (or press Enter for default): " input_msg
if [[ -n "$input_msg" ]]; then
RELEASE_MESSAGE="$input_msg"
else
RELEASE_MESSAGE="Sovran_SystemsOS ${TAG} — Stable Release"
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
# Helper: Generate categorized release notes from commit history
# Groups commits since the last tag into Keep-a-Changelog sections based on
# conventional-commit prefixes (feat/fix/docs/security/etc.) and keywords.
# ─────────────────────────────────────────────────────────────────────────────
generate_release_notes() {
local range="$1"
local added="" changed="" fixed="" security="" docs=""
while IFS= read -r subject; do
# Skip noise commits
case "$subject" in
"Initial plan"|"initial plan") continue ;;
"Merge pull request"*|"Merge branch"*) continue ;;
"chore: bump VERSION"*|"docs: update CHANGELOG"*) continue ;;
"Address code review"*|"Address review"*|"Address validation"*) continue ;;
esac
# Strip conventional-commit prefix for display
local clean
clean="$(echo "$subject" | sed -E 's/^(feat|fix|docs|chore|refactor|test|security|perf|style|ci|build)(\([^)]*\))?!?:[[:space:]]*//')"
# Capitalize first letter
clean="$(echo "${clean:0:1}" | tr '[:lower:]' '[:upper:]')${clean:1}"
case "$subject" in
security:*|security\(*) security+="- ${clean}"$'\n' ;;
feat:*|feat\(*) added+="- ${clean}"$'\n' ;;
fix:*|fix\(*|Fix\ *|fixed\ *) fixed+="- ${clean}"$'\n' ;;
docs:*|docs\(*) docs+="- ${clean}"$'\n' ;;
refactor:*|refactor\(*|chore:*|chore\(*|removed\ *|Updated\ *|updated\ *) changed+="- ${clean}"$'\n' ;;
test:*|test\(*) continue ;;
*) added+="- ${clean}"$'\n' ;;
esac
done < <(git log --no-merges --format='%s' "$range" 2>/dev/null | awk '!seen[$0]++')
local notes=""
if [[ -n "$added" ]]; then notes+=$'### Added\n'"$added"$'\n'; fi
if [[ -n "$changed" ]]; then notes+=$'### Changed\n'"$changed"$'\n'; fi
if [[ -n "$fixed" ]]; then notes+=$'### Fixed\n'"$fixed"$'\n'; fi
if [[ -n "$security" ]]; then notes+=$'### Security\n'"$security"$'\n'; fi
if [[ -n "$docs" ]]; then notes+=$'### Documentation\n'"$docs"$'\n'; fi
if [[ -z "$notes" ]]; then
notes=$'### Changed\n- Incremental stable updates\n'
fi
printf '%s' "$notes"
}
# Build notes from the previous canonical GitHub tag. Use its private ref so a
# conflicting local or Gitea tag with the same name cannot select the wrong
# commit.
PREV_TAG=$(get_latest_tag "$TAG")
PREV_TAG_REF="${GITHUB_TAG_NAMESPACE}/${PREV_TAG}"
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG_REF" >/dev/null; then
COMMIT_RANGE="${PREV_TAG_REF}..HEAD"
COMMIT_RANGE_DISPLAY="${PREV_TAG}..HEAD"
else
COMMIT_RANGE="HEAD"
COMMIT_RANGE_DISPLAY="HEAD"
fi
echo
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE_DISPLAY}...${NC}"
RELEASE_NOTES="$(generate_release_notes "$COMMIT_RANGE")"
# Let the user review/edit the generated notes before publishing
NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")"
trap 'rm -f "$NOTES_FILE"' EXIT
{
echo "## Sovran_SystemsOS ${TAG}"
echo
echo "${RELEASE_MESSAGE}"
echo
echo "$RELEASE_NOTES"
echo "**Full changelog:** [CHANGELOG.md](https://github.com/naturallaw777/Sovran_SystemsOS/blob/main/CHANGELOG.md)"
} > "$NOTES_FILE"
echo -e " ${GREEN}✓${NC} Draft notes written to: ${CYAN}${NOTES_FILE}${NC}"
echo
echo "──────────────── Draft Release Notes ────────────────"
cat "$NOTES_FILE"
echo "──────────────────────────────────────────────────────"
echo
read -rp "Edit the notes before publishing? (y/N): " edit_confirm
if [[ "$edit_confirm" =~ ^[Yy]$ ]]; then
"${EDITOR:-nano}" "$NOTES_FILE"
RELEASE_NOTES="$(sed -n '/^###/,$p' "$NOTES_FILE" | sed '/^\*\*Full changelog/d')"
fi
RELEASE_BODY="$(cat "$NOTES_FILE")"
echo
echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Preparing Release${NC}"
echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}"
echo " Version : ${TAG}"
echo " Message : ${RELEASE_MESSAGE}"
echo " Gitea Remote : ${GITEA_REMOTE}"
echo " GitHub Remote : ${GITHUB_REMOTE}"
echo
read -rp "Proceed with this release? (y/N): " confirm
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
# ─────────────────────────────────────────────────────────────────────────────
# Step 1: Prepare and commit all release metadata
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 1: Preparing release metadata for ${TAG}...${NC}"
# VERSION drives ISO naming.
echo "${VERSION}" > VERSION
# Update every versioned ISO filename in README.md.
README_FILE="README.md"
if [[ ! -f "$README_FILE" ]]; then
echo -e "${RED}Error: ${README_FILE} not found.${NC}" >&2
exit 1
fi
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" \
| head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
if [[ -z "$OLD_ISO_VER" ]]; then
echo -e "${RED}Error: no versioned ISO reference found in ${README_FILE}.${NC}" >&2
exit 1
fi
if [[ "$OLD_ISO_VER" != "$VERSION" ]]; then
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
"$README_FILE" > "$README_FILE.tmp"
mv "$README_FILE.tmp" "$README_FILE"
fi
# Add the changelog entry before tagging so the tag and stable branch contain it.
TODAY=$(date +%Y-%m-%d)
NEW_ENTRY="## [${VERSION}] - ${TODAY}
${RELEASE_NOTES}
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
"
if [[ ! -f "$CHANGELOG_FILE" ]]; then
echo -e "${RED}Error: ${CHANGELOG_FILE} not found.${NC}" >&2
exit 1
fi
awk -v new_entry="$NEW_ENTRY" '
BEGIN { printed=0 }
/^---$/ && !printed {
print
print ""
print new_entry
printed=1
next
}
{ print }
END { if (!printed) exit 2 }
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" || {
rm -f "${CHANGELOG_FILE}.tmp"
echo -e "${RED}Error: could not find the changelog insertion marker.${NC}" >&2
exit 1
}
mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
git add VERSION "$README_FILE" "$CHANGELOG_FILE"
if git diff --cached --quiet; then
echo -e "${RED}Error: release preparation produced no changes.${NC}" >&2
exit 1
fi
git commit -m "chore(release): prepare ${TAG}"
RELEASE_COMMIT=$(git rev-parse HEAD)
echo -e " ${GREEN}✓${NC} VERSION, README, and CHANGELOG committed"
echo -e " Release commit: ${CYAN}${RELEASE_COMMIT}${NC}"
# ─────────────────────────────────────────────────────────────────────────────
# Step 2: Publish the final release commit to every release branch
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 2: Publishing the final release commit...${NC}"
# GitHub main and Gitea staging-dev were verified equal during preflight, so
# these are normal fast-forward pushes. Stable is an intentional promotion and
# uses a lease to prevent overwriting a branch changed since the fetch.
git push "$GITHUB_REMOTE" HEAD:main
git push "$GITEA_REMOTE" HEAD:staging-dev
git push "$GITEA_REMOTE" HEAD:stable --force-with-lease
# Verify all three branch tips before creating an immutable release tag.
remote_branch_commit() {
local remote="$1"
local branch="$2"
git ls-remote "$remote" "refs/heads/${branch}" | awk 'NR == 1 { print $1 }'
}
PUBLISHED_GITHUB=$(remote_branch_commit "$GITHUB_REMOTE" main)
PUBLISHED_STAGING=$(remote_branch_commit "$GITEA_REMOTE" staging-dev)
PUBLISHED_STABLE=$(remote_branch_commit "$GITEA_REMOTE" stable)
if [[ "$PUBLISHED_GITHUB" != "$RELEASE_COMMIT" || \
"$PUBLISHED_STAGING" != "$RELEASE_COMMIT" || \
"$PUBLISHED_STABLE" != "$RELEASE_COMMIT" ]]; then
echo -e "${RED}Error: post-push verification failed; no release tag was created.${NC}" >&2
echo " Expected : $RELEASE_COMMIT" >&2
echo " GitHub main : ${PUBLISHED_GITHUB:-missing}" >&2
echo " Gitea staging-dev : ${PUBLISHED_STAGING:-missing}" >&2
echo " Gitea stable : ${PUBLISHED_STABLE:-missing}" >&2
exit 1
fi
echo -e " ${GREEN}✓${NC} GitHub main, Gitea staging-dev, and Gitea stable all match"
# ─────────────────────────────────────────────────────────────────────────────
# Step 3: Tag the final release commit and publish the tag
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 3: Creating annotated tag ${TAG} on ${RELEASE_COMMIT}...${NC}"
git tag -a "$TAG" -m "${RELEASE_MESSAGE}
- Stable release of Sovran_SystemsOS
- See CHANGELOG.md for full details" "$RELEASE_COMMIT"
git push "$GITHUB_REMOTE" "$TAG"
git push "$GITEA_REMOTE" "$TAG"
# For an annotated tag, ^{} resolves the commit referenced by the tag object.
remote_tag_commit() {
local remote="$1"
git ls-remote "$remote" "refs/tags/${TAG}^{}" | awk 'NR == 1 { print $1 }'
}
GITHUB_TAG_COMMIT=$(remote_tag_commit "$GITHUB_REMOTE")
GITEA_TAG_COMMIT=$(remote_tag_commit "$GITEA_REMOTE")
if [[ "$GITHUB_TAG_COMMIT" != "$RELEASE_COMMIT" || "$GITEA_TAG_COMMIT" != "$RELEASE_COMMIT" ]]; then
echo -e "${RED}Error: published tag verification failed.${NC}" >&2
exit 1
fi
echo -e " ${GREEN}✓${NC} ${TAG} points to the final release commit on GitHub and Gitea"
# ─────────────────────────────────────────────────────────────────────────────
# Step 4: Create GitHub release via gh CLI
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 4: Creating GitHub release...${NC}"
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
gh release create "$TAG" \
--repo naturallaw777/Sovran_SystemsOS \
--title "${TAG} — ${title_suffix}" \
--notes-file "$NOTES_FILE"
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
# ─────────────────────────────────────────────────────────────────────────────
# Step 5: Create Gitea release via API
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 5: Creating Gitea release via API...${NC}"
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
# Build JSON safely because release notes may contain quotes and newlines.
if command -v jq >/dev/null 2>&1; then
PAYLOAD=$(jq -n \
--arg tag "$TAG" \
--arg name "$TAG — Stable Release" \
--arg body "$RELEASE_BODY" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
else
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': sys.argv[1] + ' — Stable Release', 'body': open(sys.argv[2]).read(), 'draft': False, 'prerelease': False}))" "$TAG" "$NOTES_FILE")
fi
RESPONSE_FILE=$(mktemp "/tmp/gitea-release-${TAG}.XXXXXX.json")
HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases")
if [[ "$HTTP_STATUS" != "201" ]]; then
echo -e "${RED}Error: Gitea release creation failed (HTTP ${HTTP_STATUS}).${NC}" >&2
cat "$RESPONSE_FILE" >&2
rm -f "$RESPONSE_FILE"
exit 1
fi
rm -f "$RESPONSE_FILE"
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
# ─────────────────────────────────────────────────────────────────────────────
# Final Summary
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ✅ Release ${TAG} completed successfully! ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
echo
echo "Next manual steps (recommended):"
echo " • Build the installer ISO:"
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
echo " • Package, verify, and upload ISO to CDN:"
echo " ./scripts/upload-cdn.sh --upload"
echo " • Verify the ISO download and checksum from the public CDN"
echo " • Verify releases on both GitHub and Gitea"
echo
echo -e "${CYAN}Tag created: ${TAG}${NC}"
git show "${TAG}" --quiet
# Clean up temp notes file
rm -f "${NOTES_FILE}"
-168
View File
@@ -1,168 +0,0 @@
#!/usr/bin/env bash
#
# upload-cdn.sh
# Copies built ISO out of the nix store, generates versioned SHA-256 checksum,
# verifies it, and optionally uploads to CDN.
#
# Usage:
# ./scripts/upload-cdn.sh [--upload]
#
# EVERYTHING this script creates lives OUTSIDE the repository, even when you
# run it from inside the repo:
# - the nix build output symlink -> $ISO_OUT_DIR/result
# - the ISO and .sha256 files -> $ISO_OUT_DIR
# The repo working tree stays completely clean.
#
# Environment variables (optional):
# ISO_OUT_DIR - dir for the build symlink + ISO + checksum
# (default: ~/Sovran-builds)
# CDN_RSYNC_TARGET - rsync destination (e.g. user@server:/var/www/downloads/)
# CDN_RCLONE_REMOTE - rclone remote target (e.g. s3:my-bucket/downloads/)
# CDN_UPLOAD_CMD - custom upload command
#
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
# Always operate from the repo root, no matter where the script is invoked from
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
# Read version from VERSION file
if [ ! -f VERSION ]; then
echo -e "${RED}Error: VERSION file not found in $REPO_ROOT.${NC}" >&2
exit 1
fi
VERSION=$(cat VERSION | tr -d '\n\r ')
ISO_NAME="Sovran_SystemsOS-${VERSION}.iso"
SHA_NAME="${ISO_NAME}.sha256"
# Output directory OUTSIDE the repo (override with ISO_OUT_DIR)
OUT_DIR="${ISO_OUT_DIR:-$HOME/Sovran-builds}"
mkdir -p "$OUT_DIR"
OUT_DIR="$(cd "$OUT_DIR" && pwd)"
ISO_PATH="$OUT_DIR/${ISO_NAME}"
SHA_PATH="$OUT_DIR/${SHA_NAME}"
RESULT_LINK="$OUT_DIR/result"
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Sovran_SystemsOS CDN ISO Packaging & Upload Tool ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
echo -e " Version : ${CYAN}${VERSION}${NC}"
echo -e " ISO : ${CYAN}${ISO_NAME}${NC}"
echo -e " Repo : ${CYAN}${REPO_ROOT}${NC} (left untouched)"
echo -e " Output : ${CYAN}${OUT_DIR}${NC} (outside the repo)"
echo
# Locate or build ISO
SRC_ISO=""
if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
elif [ -f "$RESULT_LINK/iso/Sovran_SystemsOS.iso" ]; then
SRC_ISO="$RESULT_LINK/iso/Sovran_SystemsOS.iso"
elif [ -f "result/iso/${ISO_NAME}" ]; then
SRC_ISO="result/iso/${ISO_NAME}" # legacy: from an older in-repo build
elif [ -f "result/iso/Sovran_SystemsOS.iso" ]; then
SRC_ISO="result/iso/Sovran_SystemsOS.iso"
else
FOUND=$(find "$RESULT_LINK" result -name "*.iso" 2>/dev/null | head -n 1 || true)
if [ -n "$FOUND" ]; then
SRC_ISO="$FOUND"
fi
fi
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
echo -e "${YELLOW}Built ISO not found. Building via Nix (result link goes outside the repo)...${NC}"
rm -f "$RESULT_LINK"
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage \
--out-link "$RESULT_LINK"
if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
else
FOUND=$(find "$RESULT_LINK" -name "*.iso" 2>/dev/null | head -n 1 || true)
if [ -n "$FOUND" ]; then
SRC_ISO="$FOUND"
fi
fi
fi
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
echo -e "${RED}Error: Failed to locate built ISO.${NC}" >&2
exit 1
fi
echo -e "${BLUE}Step 1: Copying built ISO to output dir (outside repo)...${NC}"
cp -v "$SRC_ISO" "$ISO_PATH"
echo -e " ${GREEN}✓${NC} Copied to $ISO_PATH"
echo
echo -e "${BLUE}Step 2: Generating versioned SHA-256 checksum...${NC}"
sha256sum "$ISO_PATH" > "$SHA_PATH"
echo -e " ${GREEN}✓${NC} Generated $SHA_PATH"
cat "$SHA_PATH"
echo
echo -e "${BLUE}Step 3: Verifying checksum...${NC}"
sha256sum --check "$SHA_PATH"
echo -e " ${GREEN}✓${NC} Checksum verified successfully"
# Parse arguments for upload
DO_UPLOAD=0
for arg in "$@"; do
case $arg in
--upload)
DO_UPLOAD=1
shift
;;
esac
done
if [ "$DO_UPLOAD" -eq 1 ]; then
echo
echo -e "${BLUE}Step 4: Uploading to CDN...${NC}"
UPLOADED=0
if [ -n "${CDN_UPLOAD_CMD:-}" ]; then
echo -e " Running custom CDN_UPLOAD_CMD..."
eval "$CDN_UPLOAD_CMD"
UPLOADED=1
fi
if [ -n "${CDN_RSYNC_TARGET:-}" ]; then
echo -e " Uploading via rsync to ${CDN_RSYNC_TARGET}..."
rsync -avP "$ISO_PATH" "$SHA_PATH" "${CDN_RSYNC_TARGET}"
UPLOADED=1
fi
if [ -n "${CDN_RCLONE_REMOTE:-}" ]; then
echo -e " Uploading via rclone to ${CDN_RCLONE_REMOTE}..."
rclone copy "$ISO_PATH" "$SHA_PATH" "${CDN_RCLONE_REMOTE}"
UPLOADED=1
fi
if [ "$UPLOADED" -eq 0 ]; then
echo -e " ${YELLOW}⚠ Warning: --upload requested, but no upload method specified.${NC}"
echo -e " Set CDN_RSYNC_TARGET, CDN_RCLONE_REMOTE, or CDN_UPLOAD_CMD."
else
echo -e " ${GREEN}✓${NC} Upload complete."
fi
else
echo
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ✅ ISO packaging & verification complete! ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
echo -e "Ready files (in ${CYAN}${OUT_DIR}${NC}, outside the repo):"
ls -lh "$ISO_PATH" "$SHA_PATH"
echo
echo "To upload to CDN, run:"
echo " ./scripts/upload-cdn.sh --upload"
echo "(configure CDN_RSYNC_TARGET, CDN_RCLONE_REMOTE, or CDN_UPLOAD_CMD)"
fi
-73
View File
@@ -1,73 +0,0 @@
"""Regression tests for the Bitcoin Core Tor IBD gossip Hub option.
These tests intentionally avoid importing the FastAPI application so they can run
in the repository's lightweight test environment without NixOS service access.
"""
import ast
import os
import unittest
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _read(*parts: str) -> str:
with open(os.path.join(_REPO_ROOT, *parts), encoding="utf-8") as source:
return source.read()
def _literal_assignment(source: str, name: str):
tree = ast.parse(source)
for node in tree.body:
if isinstance(node, ast.Assign):
if any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
return ast.literal_eval(node.value)
raise AssertionError(f"Assignment {name} was not found")
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
def test_integration_layer_maps_tor_gossip_option(self):
"""The integration layer must bridge sovran_systemsOS.features.bitcoin-tor-gossip
to sovran-bitcoin.bitcoindTorGossip."""
integration = _read("modules", "sovran-bitcoin-integration.nix")
self.assertIn("bitcoindTorGossip", integration)
self.assertIn("bitcoin-tor-gossip", integration)
def test_hub_option_and_evaluated_state_are_declared(self):
roles = _read("modules", "core", "roles.nix")
hub = _read("modules", "core", "sovran-hub.nix")
self.assertIn("bitcoin-tor-gossip = lib.mkEnableOption", roles)
self.assertIn("bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;", hub)
class TestBitcoinTorGossipHubWiring(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server_source = _read("app", "sovran_systemsos_web", "server.py")
cls.registry = _literal_assignment(cls.server_source, "FEATURE_REGISTRY")
def test_feature_is_modal_only_and_explains_risk(self):
feature = next(item for item in self.registry if item["id"] == "bitcoin-tor-gossip")
self.assertTrue(feature["modal_only"])
self.assertIn("bitcoin-service", feature["requires"])
self.assertTrue(any("clearnet" in detail for detail in feature["details"]))
self.assertTrue(any("bandwidth" in detail for detail in feature["details"]))
def test_backend_rejects_gossip_without_bitcoin_service(self):
self.assertIn(
"Enable the Bitcoin service before advertising its Tor IBD service.",
self.server_source,
)
def test_core_modal_renders_and_controls_the_option(self):
frontend = _read(
"app", "sovran_systemsos_web", "static", "js", "service-detail.js"
)
self.assertIn("Tor IBD Service Advertising", frontend)
self.assertIn("svc-detail-related-feature-btn", frontend)
self.assertIn("handleFeatureToggle(relatedFeat", frontend)
if __name__ == "__main__":
unittest.main()
-249
View File
@@ -1,249 +0,0 @@
"""Tests for the DDNS runner (sovran_systemsos_web.ddns_update).
The runner asks Njal.la to use the address the request came from ("&auto"),
reads back the address Njal.la recorded and saves it for LiveKit and the Hub.
Tests must never:
- access the network (curl is replaced by a fake ``run``)
- write to system paths (the URL and IP files live in a temp dir)
"""
import contextlib
import io
import json
import os
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
if _APP_PARENT not in sys.path:
sys.path.insert(0, _APP_PARENT)
from sovran_systemsos_web import ddns_update as d # noqa: E402
KEY = "SECRETKEY123"
AUTO_URL = f"https://njal.la/update/?h=sub.example.com&k={KEY}&auto"
LEGACY_URL = f"https://njal.la/update/?h=sub.example.com&k={KEY}&a=${{IP}}"
PUBLIC_IP = "93.184.216.34"
OTHER_IP = "8.8.4.4"
def reply(ip=PUBLIC_IP, status=200):
return json.dumps({"status": status, "message": "record updated", "value": {"A": ip}})
class FakeRun:
"""Stands in for subprocess.run; records every command it is given."""
def __init__(self, stdout=None, returncode=0, raises=None):
self.stdout = reply() if stdout is None else stdout
self.returncode = returncode
self.raises = raises
self.calls = []
def __call__(self, cmd, **kwargs):
self.calls.append(cmd)
if self.raises:
raise self.raises
return subprocess.CompletedProcess(cmd, self.returncode, stdout=self.stdout, stderr="")
class NormaliseUrlTests(unittest.TestCase):
def test_legacy_placeholder_becomes_auto(self):
self.assertEqual(d.normalise_url(LEGACY_URL), AUTO_URL)
def test_quiet_is_dropped_so_the_reply_can_be_read(self):
self.assertEqual(d.normalise_url(AUTO_URL + "&quiet"), AUTO_URL)
def test_plain_auto_is_unchanged(self):
self.assertEqual(d.normalise_url(AUTO_URL), AUTO_URL)
def test_explicit_address_is_unchanged(self):
url = "https://njal.la/update/?h=a.example.com&k=K&a=93.184.216.34"
self.assertEqual(d.normalise_url(url), url)
class IsPublicIpv4Tests(unittest.TestCase):
def test_public_addresses(self):
for ip in ("93.184.216.34", "8.8.8.8", " 1.1.1.1\n"):
self.assertTrue(d.is_public_ipv4(ip), ip)
def test_everything_else_is_rejected(self):
for ip in ("10.0.0.1", "192.168.1.5", "172.16.0.9", "127.0.0.1", "169.254.1.1",
"100.64.0.1", "0.0.0.0", "224.0.0.1", "::1", "2001:4860:4860::8888",
"not-an-ip", "", None):
self.assertFalse(d.is_public_ipv4(ip), ip)
class ParseReplyTests(unittest.TestCase):
def test_success_returns_the_recorded_address(self):
self.assertEqual(d.parse_reply(reply()), PUBLIC_IP)
def test_status_may_be_a_string(self):
self.assertEqual(d.parse_reply(reply(status="200")), PUBLIC_IP)
def test_error_status_is_rejected(self):
body = json.dumps({"status": 401, "message": "invalid host or key"})
self.assertIsNone(d.parse_reply(body))
def test_garbage_is_rejected(self):
for body in ("", "not json", "[]", "null", "{}", json.dumps({"status": 200})):
self.assertIsNone(d.parse_reply(body), body)
def test_non_public_or_non_ipv4_address_is_rejected(self):
for ip in ("10.1.2.3", "100.64.9.9", "127.0.0.1", "::1", "2001:4860:4860::8888", "x"):
self.assertIsNone(d.parse_reply(reply(ip)), ip)
class IpFileTests(unittest.TestCase):
def test_write_then_read(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "secrets", "external-ip")
d.write_ip_file(PUBLIC_IP, path)
self.assertEqual(d.read_ip_file(path), PUBLIC_IP)
self.assertEqual(open(path).read(), PUBLIC_IP) # no trailing newline
self.assertEqual(os.stat(path).st_mode & 0o777, 0o644)
def test_replace_is_atomic_and_leaves_no_temp_files(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "external-ip")
d.write_ip_file(PUBLIC_IP, path)
d.write_ip_file(OTHER_IP, path)
self.assertEqual(d.read_ip_file(path), OTHER_IP)
self.assertEqual(os.listdir(tmp), ["external-ip"])
def test_missing_file_reads_as_none(self):
with tempfile.TemporaryDirectory() as tmp:
self.assertIsNone(d.read_ip_file(os.path.join(tmp, "nope")))
class UpdateAllTests(unittest.TestCase):
def run_update(self, urls, fake):
out = io.StringIO()
with contextlib.redirect_stdout(out):
result = d.update_all(urls, run=fake)
return result, out.getvalue()
def test_curl_is_called_directly_with_ipv4_and_no_redirects(self):
fake = FakeRun()
result, _ = self.run_update([AUTO_URL], fake)
self.assertEqual(result, PUBLIC_IP)
self.assertEqual(len(fake.calls), 1)
cmd = fake.calls[0]
self.assertEqual(cmd[0], "curl")
for flag in ("--ipv4", "--no-location", "--fail", "--silent"):
self.assertIn(flag, cmd)
self.assertEqual(cmd[-1], AUTO_URL)
def test_legacy_entry_and_its_auto_twin_are_one_call(self):
fake = FakeRun()
self.run_update([LEGACY_URL, AUTO_URL], fake)
self.assertEqual(fake.calls[0][-1], AUTO_URL)
self.assertEqual(len(fake.calls), 1)
def test_url_for_another_host_is_never_called(self):
fake = FakeRun()
result, _ = self.run_update([f"https://evil.example/update/?h=x&k={KEY}&auto"], fake)
self.assertIsNone(result)
self.assertEqual(fake.calls, [])
def test_failed_curl_yields_nothing(self):
result, _ = self.run_update([AUTO_URL], FakeRun(returncode=22))
self.assertIsNone(result)
def test_reply_without_an_address_yields_nothing(self):
result, _ = self.run_update([AUTO_URL], FakeRun(stdout=json.dumps({"status": 200})))
self.assertIsNone(result)
def test_missing_curl_is_survived(self):
result, out = self.run_update([AUTO_URL], FakeRun(raises=FileNotFoundError("curl")))
self.assertIsNone(result)
self.assertIn("skipped", out)
def test_first_reported_address_wins(self):
calls = iter([reply(PUBLIC_IP), reply(OTHER_IP)])
def fake(cmd, **kwargs):
return subprocess.CompletedProcess(cmd, 0, stdout=next(calls), stderr="")
other = f"https://njal.la/update/?h=other.example.com&k={KEY}&auto"
out = io.StringIO()
with contextlib.redirect_stdout(out):
result = d.update_all([AUTO_URL, other], run=fake)
self.assertEqual(result, PUBLIC_IP)
def test_the_key_is_never_printed(self):
for fake in (FakeRun(), FakeRun(returncode=22), FakeRun(stdout="junk"),
FakeRun(raises=FileNotFoundError("curl"))):
_, out = self.run_update([AUTO_URL, LEGACY_URL], fake)
self.assertNotIn(KEY, out)
class MainTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.urls_file = os.path.join(self.tmp.name, "ddns_urls.json")
self.ip_file = os.path.join(self.tmp.name, "secrets", "external-ip")
for patch in (mock.patch.object(d, "URLS_FILE", self.urls_file),
mock.patch.object(d, "IP_FILE", self.ip_file)):
patch.start()
self.addCleanup(patch.stop)
def store(self, urls):
with open(self.urls_file, "w") as f:
json.dump(urls, f)
def main(self, fake):
out = io.StringIO()
with mock.patch.object(d.subprocess, "run", fake), contextlib.redirect_stdout(out):
code = d.main()
self.assertEqual(code, 0)
return out.getvalue()
def test_first_update_records_the_address(self):
self.store([LEGACY_URL])
out = self.main(FakeRun())
self.assertEqual(d.read_ip_file(self.ip_file), PUBLIC_IP)
self.assertIn("now " + PUBLIC_IP, out)
self.assertNotIn(KEY, out)
def test_unchanged_address_does_not_touch_the_file(self):
# A path unit restarts LiveKit whenever the file is written, so an
# unchanged address must not rewrite it.
self.store([AUTO_URL])
self.main(FakeRun())
before = os.stat(self.ip_file)
out = self.main(FakeRun())
after = os.stat(self.ip_file)
self.assertEqual((before.st_ino, before.st_mtime_ns), (after.st_ino, after.st_mtime_ns))
self.assertIn("unchanged", out)
def test_changed_address_is_recorded(self):
self.store([AUTO_URL])
self.main(FakeRun(stdout=reply(PUBLIC_IP)))
out = self.main(FakeRun(stdout=reply(OTHER_IP)))
self.assertEqual(d.read_ip_file(self.ip_file), OTHER_IP)
self.assertIn(f"now {OTHER_IP} (was {PUBLIC_IP})", out)
def test_failed_update_keeps_the_last_known_address(self):
self.store([AUTO_URL])
self.main(FakeRun(stdout=reply(PUBLIC_IP)))
self.main(FakeRun(returncode=7))
self.assertEqual(d.read_ip_file(self.ip_file), PUBLIC_IP)
def test_nothing_configured_does_nothing(self):
fake = FakeRun()
self.main(fake) # no URL file at all
self.store([])
self.main(fake) # empty list
self.assertEqual(fake.calls, [])
self.assertFalse(os.path.exists(self.ip_file))
if __name__ == "__main__":
unittest.main()
-68
View File
@@ -1,68 +0,0 @@
"""Guards for the home-IP warnings.
Server + Desktop publishes the home IP address (the domain points at it), so every
place that offers Server + Desktop must say so, and the README section they point
at must exist.
These read the shipped source files like the nix-file checks in test_security.py:
nothing is run and nothing touches the network.
"""
import os
import re
import unittest
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
_PHRASE = "home ip address"
def _read(*parts):
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
return f.read()
def _github_slug(heading):
slug = re.sub(r"[^\w\- ]", "", heading.strip().lower())
return slug.replace(" ", "-")
class HomeIpWarnings(unittest.TestCase):
def test_installer_role_card(self):
src = _read("iso", "installer.py")
card = re.search(r'\("Server \+ Desktop",\s*"((?:[^"\\]|\\.)*)"', src, re.S)
self.assertIsNotNone(card, "Server + Desktop role card not found")
self.assertIn(_PHRASE, card.group(1).lower())
def test_hub_domain_setup_text(self):
# domain-prereqs.js is the single source for onboarding, feature setup
# and domain reconfiguration; the notice must follow every variant.
js = _read("app", "sovran_systemsos_web", "static", "js", "domain-prereqs.js")
body = js[js.index("function renderDomainNeedsHtml"):]
body = body[:body.index("\n}\n")]
self.assertIn(_PHRASE, body.lower())
self.assertGreater(body.lower().index(_PHRASE), body.rindex("} else {"),
"the notice must come after the last variant, not inside one")
def test_hub_upgrade_dialog(self):
html = _read("app", "sovran_systemsos_web", "templates", "index.html")
dialog = html[html.index('id="upgrade-modal"'):html.index("Security Reset overlay")]
self.assertIn(_PHRASE, " ".join(dialog.lower().split()))
def test_readme_and_security_policy(self):
self.assertIn(_PHRASE, _read("README.md").lower())
self.assertIn(_PHRASE, " ".join(_read("SECURITY.md").lower().split()))
def test_links_to_the_readme_section_resolve(self):
readme = _read("README.md")
slugs = {_github_slug(m.group(2))
for m in re.finditer(r"^(#{1,6})\s+(.+?)\s*$", readme, re.M)}
links = re.findall(r"\]\(#(server--desktop[^)]*)\)", readme)
links += re.findall(r"README\.md#(server--desktop[^)\s]*)", _read("SECURITY.md"))
self.assertTrue(links, "expected links to the home-IP section")
for anchor in links:
self.assertIn(anchor, slugs)
if __name__ == "__main__":
unittest.main()
-193
View File
@@ -1,193 +0,0 @@
"""Guards for serving the Hub on its own port instead of through Caddy.
The Hub is the one service that runs as root. It listens on 0.0.0.0:8937
itself, so Caddy adds nothing it needs: not TLS (the site was plain http), not
authentication, not cache headers (the app sets its own). What it did add was a
second door: with ports 80/443 forwarded for public services, a Host header on
those ports reached the Hub. The Hub is therefore served on port 8937 only, and
Caddy keeps the two services it is actually needed for, because they listen on
loopback only: Ride The Lightning (:3051) and Mempool (:60847).
Like the other nix-file checks these read the modules as text: nothing is run
and nothing touches the network.
"""
import os
import re
import unittest
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _read(*parts):
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
return f.read()
def _without_comments(src):
"""The Nix source with `#` comment lines removed."""
return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
def _binding(src, name):
"""The right-hand side of a top-level `name = ...;` binding in a let/attrset."""
m = re.search(r"^\s*" + re.escape(name) + r"\s*=\s*(?P<v>.*?);\s*$", src, re.M | re.S)
assert m, f"{name} not found"
return m.group("v")
class HubIsNotACaddySite(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.caddy = _read("modules", "core", "caddy.nix")
cls.code = _without_comments(cls.caddy)
def test_there_is_no_site_for_the_hub(self):
self.assertNotRegex(self.code, r"sovransystemsos\.local")
self.assertNotIn("8937", self.code)
def test_the_public_ports_still_belong_to_the_public_sites(self):
# Caddy now also runs to bridge RTL and Mempool (even on Node Only),
# which must not open 80/443 by itself: those follow the domain-based
# services and nothing else.
self.assertRegex(
self.code,
r"networking\.firewall\.allowedTCPPorts\s*=\s*lib\.mkIf\s+needsHttpsPorts\s*\[\s*80\s+443\s*\]",
)
class CaddyDoesNoAddressFiltering(unittest.TestCase):
"""Caddy is a bridge for RTL and Mempool and a TLS front for public sites.
It used to carry a client-address guard (sovran_lan_only). That guard was
never aimed at these two sites: the bug was a Host header on ports 80/443
reaching the Hub, and RTL and Mempool sit on ports of their own. It also
could not be made right for IPv6, where a laptop's global address on the
LAN is indistinguishable from a stranger's.
"""
@classmethod
def setUpClass(cls):
cls.caddy = _read("modules", "core", "caddy.nix")
cls.code = _without_comments(cls.caddy)
def test_there_is_no_address_filter(self):
# (private_ranges is deliberately not on this list: the Nextcloud site
# uses it for trusted_proxies, which is not a filter on who may connect.)
for needle in ("sovran_lan_only", "remote_ip", "abort @"):
with self.subTest(needle=needle):
self.assertNotIn(needle, self.code)
def test_the_bitcoin_sites_are_plain_proxies(self):
for site, upstream in ((":3051", ":3050"), (":60847", ":60845")):
with self.subTest(site=site):
m = re.search(r"^" + re.escape(site) + r" \{\n(.*?)^\}$", self.code, re.S | re.M)
self.assertIsNotNone(m, f"{site} site not found")
directives = [l.strip() for l in m.group(1).splitlines() if l.strip()]
self.assertEqual(directives, [f"reverse_proxy {upstream}", "encode gzip zstd"])
def test_the_options_for_a_declared_prefix_are_gone(self):
# Never needed once Caddy stops guessing: neither the option nor its
# build-time assertion may linger half-wired.
roles = _read("modules", "core", "roles.nix")
self.assertNotIn("lanIPv6Prefixes", roles)
self.assertNotIn("lanIPv6Prefixes", self.caddy)
class CaddyRunsWhereItIsNeeded(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.caddy = _read("modules", "core", "caddy.nix")
cls.code = _without_comments(cls.caddy)
def test_it_runs_for_domains_vhosts_or_the_bitcoin_uis(self):
self.assertRegex(
self.code,
r"caddyEnabled\s*=\s*needsHttpsPorts\s*\|\|\s*extraVhosts\s*!=\s*\"\"\s*\|\|\s*servesRtl\s*;",
)
self.assertRegex(self.code, r"enable\s*=\s*caddyEnabled\s*;")
def test_rtl_and_mempool_follow_their_services(self):
self.assertRegex(self.code,
r"servesRtl\s*=\s*config\.sovran_systemsOS\.services\.bitcoin\s*;")
self.assertRegex(
self.code,
r"servesMempool\s*=\s*servesRtl\s*&&\s*config\.sovran_systemsOS\.features\.mempool\s*;",
)
def test_each_site_exists_only_where_its_service_does(self):
self.assertRegex(self.code, r"lib\.optionalString\s+servesRtl\s*''\s*\n+:3051 \{")
self.assertRegex(self.code, r"lib\.optionalString\s+servesMempool\s*''\s*\n+:60847 \{")
# ... and they are written into the Caddyfile from that one place
self.assertIn("${bitcoinUiSites}", self.caddy)
def test_rtl_and_mempool_still_proxy_to_their_loopback_ports(self):
self.assertRegex(self.code, r":3051 \{[^}]*reverse_proxy :3050")
self.assertRegex(self.code, r":60847 \{[^}]*reverse_proxy :60845")
class HubPortExposure(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.hub = _read("modules", "core", "sovran-hub.nix")
cls.roles = _read("modules", "core", "roles.nix")
def _firewall_value(self):
m = re.search(
r"^ networking\.firewall\.allowedTCPPorts =\s*(?P<value>.*?);\s*$",
self.hub, re.S | re.M,
)
self.assertIsNotNone(m, "networking.firewall.allowedTCPPorts not found")
return m.group("value")
def test_the_hub_port_is_never_opened_unconditionally(self):
# Regression: this used to be `allowedTCPPorts = [ 8937 60847 ]` with
# no mkIf and no option gate, on every role, Desktop Only included.
value = self._firewall_value()
self.assertNotRegex(value, r"^\s*\[")
self.assertRegex(value, r"lib\.optionals\s+cfg\.hub\.directPort\s+\[\s*8937\s*\]")
def test_the_mempool_port_follows_mempool(self):
self.assertRegex(
self._firewall_value(),
r"lib\.optionals\s+\(cfg\.services\.bitcoin\s*&&\s*cfg\.features\.mempool\)\s+\[\s*60847\s*\]",
)
def test_nothing_else_is_opened_here(self):
ports = re.findall(r"\[\s*(\d+)\s*\]", self._firewall_value())
self.assertEqual(sorted(ports), ["60847", "8937"])
def test_direct_port_is_on_for_the_server_roles_and_off_for_desktop_only(self):
m = re.search(r"directPort\s*=\s*lib\.mkOption\s*\{(.*?)\n \};", self.roles, re.S)
self.assertIsNotNone(m, "hub.directPort option not found")
self.assertRegex(m.group(1), r"default\s*=\s*!config\.sovran_systemsOS\.roles\.desktop\s*;")
def test_the_bind_is_ipv4_only_on_purpose(self):
# IPv6 clients cannot reach the Hub, so the question of which IPv6
# addresses are "local" never comes up. Widening the bind reopens it.
self.assertIn('host="0.0.0.0"', self.hub)
self.assertNotRegex(self.hub, r'host="::"')
self.assertNotRegex(self.hub, r"both IPv4 and IPv6")
class TheHubIsDocumentedAtItsPort(unittest.TestCase):
def test_no_document_still_sends_people_to_port_80(self):
for name in (("README.md",), ("SECURITY.md",),
("app", "sovran_systemsos_web", "templates", "index.html")):
with self.subTest(file=name[-1]):
text = _read(*name)
self.assertNotRegex(text, r"sovransystemsos\.local(?!:8937)(?![a-z])",
f"{name[-1]} sends people to sovransystemsos.local without :8937")
def test_the_documents_name_the_port(self):
self.assertIn("http://sovransystemsos.local:8937", _read("README.md"))
self.assertIn("http://sovransystemsos.local:8937", _read("SECURITY.md"))
self.assertIn("hub.directPort", _read("SECURITY.md"))
if __name__ == "__main__":
unittest.main()
-54
View File
@@ -1,54 +0,0 @@
"""Guards for the Hub checking its own clients.
The Hub runs as root. Whether a packet may reach its port is up to the firewall
and the router; the application adds a second lock by answering only this
computer and the local network. These read the modules as text, like the other
nix-file checks: nothing is run and nothing touches the network.
"""
import os
import re
import unittest
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _read(*parts):
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
return f.read()
def _option(src, name):
m = re.search(name + r"\s*=\s*lib\.mkOption\s*\{(.*?)\n \};", src, re.S)
return m.group(1) if m else None
class HubChecksItsOwnClients(unittest.TestCase):
def test_lan_only_option_exists_and_defaults_on(self):
body = _option(_read("modules", "core", "roles.nix"), "lanOnly")
self.assertIsNotNone(body, "hub.lanOnly option not found")
self.assertRegex(body, r"default\s*=\s*true")
def test_extra_networks_option_exists_and_defaults_empty(self):
body = _option(_read("modules", "core", "roles.nix"), "extraLanNetworks")
self.assertIsNotNone(body, "hub.extraLanNetworks option not found")
self.assertRegex(body, r"default\s*=\s*\[\s*\]")
def test_policy_is_baked_into_the_generated_config(self):
hub = _read("modules", "core", "sovran-hub.nix")
self.assertRegex(hub, r"lan_only\s*=\s*cfg\.hub\.lanOnly\s*;")
self.assertRegex(hub, r"lan_extra_networks\s*=\s*cfg\.hub\.extraLanNetworks\s*;")
def test_a_typo_is_caught_at_build_time(self):
hub = _read("modules", "core", "sovran-hub.nix")
self.assertIn("builtins.all lanNetworkOk cfg.hub.extraLanNetworks", hub)
def test_the_hub_enforces_it_in_its_own_middleware(self):
server = _read("app", "sovran_systemsos_web", "server.py")
self.assertIn("class LanOnlyMiddleware(BaseHTTPMiddleware)", server)
self.assertIn("app.add_middleware(LanOnlyMiddleware", server)
if __name__ == "__main__":
unittest.main()
-230
View File
@@ -1,230 +0,0 @@
"""Tests for the Hub's local-network policy and the middleware that enforces it.
The Hub runs as root, so it answers this computer and the local network and
nobody else: LanPolicy in security_helpers decides, LanOnlyMiddleware in
server.py enforces it before authentication is considered.
LanPolicy is exercised directly. The middleware is exercised over real HTTP
where the environment allows it; server.py cannot be imported from this repo
(it needs sovran_nwc from the Sovran_Bitcoin flake), so those tests skip rather
than fail, and the wiring is additionally asserted from source so it is always
checked.
"""
import logging
import os
import sys
import unittest
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
if _APP_PARENT not in sys.path:
sys.path.insert(0, _APP_PARENT)
from sovran_systemsos_web.security_helpers import ( # noqa: E402
LanPolicy,
LAN_ONLY_IPV4,
LAN_ONLY_IPV6,
)
_LOCAL = (
"127.0.0.1", "10.0.0.1", "172.16.0.1", "172.31.255.254", "192.168.1.10",
"100.64.0.1", "169.254.1.1",
"::1", "fd12:3456::1", "fc00::1", "fe80::1",
)
_REMOTE = (
"8.8.8.8", "1.1.1.1", "203.0.113.9", "9.255.255.255",
"172.15.255.255", "172.32.0.1", "192.169.0.1", "100.63.255.255",
# public IPv6 — every address in 2000::/3 is on the internet
"2001:4860:4860::8888", "2606:4700:4700::1111",
"2a00:1450:4001::1", "2400:cb00::1",
)
class LanPolicyMatrix(unittest.TestCase):
def test_local_addresses_are_allowed(self):
policy = LanPolicy()
for address in _LOCAL:
with self.subTest(local=address):
self.assertTrue(policy.allows(address))
def test_remote_addresses_are_refused(self):
policy = LanPolicy()
for address in _REMOTE:
with self.subTest(remote=address):
self.assertFalse(policy.allows(address))
def test_ipv6_global_is_not_whitelisted(self):
# 2000::/3 is the whole IPv6 global unicast space: allowing it would
# let every public IPv6 address through.
joined = " ".join(LAN_ONLY_IPV4 + LAN_ONLY_IPV6)
self.assertNotIn("2000::/3", joined)
for net in LanPolicy().networks:
if net.version == 6:
with self.subTest(range=str(net)):
self.assertTrue(str(net).startswith(("::1", "fc00", "fe80")),
f"{net} is not a local-only IPv6 range")
class LanPolicyConfiguration(unittest.TestCase):
def test_declared_networks_are_allowed(self):
policy = LanPolicy(extra_networks=["203.0.113.0/28", "2001:db8:abcd::/48"])
self.assertTrue(policy.allows("203.0.113.9"))
self.assertFalse(policy.allows("203.0.113.16"))
self.assertTrue(policy.allows("2001:db8:abcd::5"))
self.assertFalse(policy.allows("2001:db8:abce::5"))
def test_a_bare_address_is_a_single_host(self):
policy = LanPolicy(extra_networks=["203.0.113.9"])
self.assertTrue(policy.allows("203.0.113.9"))
self.assertFalse(policy.allows("203.0.113.10"))
def test_disabled_allows_everything(self):
policy = LanPolicy(enabled=False)
for address in _REMOTE:
with self.subTest(remote=address):
self.assertTrue(policy.allows(address))
def test_malformed_network_does_not_widen_the_policy(self):
# A typo must fail closed, not open the Hub to everything.
policy = LanPolicy(extra_networks=["not-a-network", "203.0.113.0/28"])
self.assertTrue(policy.allows("203.0.113.9"))
self.assertFalse(policy.allows("8.8.8.8"))
def test_a_zero_length_prefix_is_not_a_network(self):
# 0.0.0.0/0 and ::/0 mean "everyone". That is lan_only = false and it
# has to be asked for by name rather than arrive as a "network".
policy = LanPolicy(extra_networks=["0.0.0.0/0", "::/0"])
for address in _REMOTE:
with self.subTest(remote=address):
self.assertFalse(policy.allows(address))
def test_missing_or_unparseable_client_is_refused(self):
policy = LanPolicy()
for address in (None, "", "testclient", "not-an-ip"):
with self.subTest(client=address):
self.assertFalse(policy.allows(address))
def test_a_dual_stack_socket_does_not_hide_the_ipv4_client(self):
# With an IPv6 listener, IPv4 clients arrive as ::ffff:a.b.c.d. The
# address that counts is the IPv4 one inside it, both ways round.
policy = LanPolicy()
for address in ("::ffff:192.168.1.5", "::ffff:127.0.0.1", "::ffff:10.1.2.3"):
with self.subTest(local=address):
self.assertTrue(policy.allows(address))
for address in ("::ffff:8.8.8.8", "::ffff:203.0.113.9"):
with self.subTest(remote=address):
self.assertFalse(policy.allows(address))
# ── Middleware ───────────────────────────────────────────────────────────────
try:
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from sovran_systemsos_web.server import LanOnlyMiddleware # noqa: E402
HAVE_MIDDLEWARE = True
except Exception: # fastapi / sovran_nwc unavailable from this repo
HAVE_MIDDLEWARE = False
def _app_with(policy):
app = FastAPI()
@app.get("/ping")
async def ping():
return {"ok": True}
app.add_middleware(LanOnlyMiddleware, policy=policy)
return app
@unittest.skipUnless(HAVE_MIDDLEWARE, "server.py is not importable here")
class LanOnlyMiddlewareOverHttp(unittest.TestCase):
def _status(self, policy, client_ip):
client = TestClient(_app_with(policy), client=(client_ip, 51234))
return client.get("/ping").status_code
def test_local_client_is_served(self):
for address in ("127.0.0.1", "192.168.1.10", "10.0.0.1"):
with self.subTest(local=address):
self.assertEqual(self._status(LanPolicy(), address), 200)
def test_remote_client_is_refused(self):
for address in ("203.0.113.9", "8.8.8.8", "2001:4860:4860::8888"):
with self.subTest(remote=address):
self.assertEqual(self._status(LanPolicy(), address), 403)
def test_refusal_says_nothing_about_the_configuration(self):
# An outsider learns that the answer is no, not why or what to change.
client = TestClient(_app_with(LanPolicy()), client=("203.0.113.9", 51234))
response = client.get("/ping")
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json(), {"detail": "Not available from this network"})
def test_disabled_policy_admits_remote_clients(self):
self.assertEqual(self._status(LanPolicy(enabled=False), "203.0.113.9"), 200)
def test_a_refused_address_is_logged_once(self):
# The operator whose own device is refused needs to find out why; a
# scanner must not be able to fill the journal.
client = TestClient(_app_with(LanPolicy()), client=("203.0.113.9", 51234))
with self.assertLogs("sovran_systemsos_web.server", level="WARNING") as seen:
for _ in range(5):
client.get("/ping")
self.assertEqual(len(seen.records), 1)
message = seen.records[0].getMessage()
self.assertIn("203.0.113.9", message)
self.assertIn("sovran_systemsOS.hub.extraLanNetworks", message)
def test_a_served_client_is_not_logged(self):
records = []
class _Collect(logging.Handler):
def emit(self, record):
records.append(record)
logger = logging.getLogger("sovran_systemsos_web.server")
handler = _Collect(level=logging.WARNING)
logger.addHandler(handler)
try:
client = TestClient(_app_with(LanPolicy()), client=("192.168.1.10", 51234))
client.get("/ping")
finally:
logger.removeHandler(handler)
self.assertEqual(records, [])
# ── Wiring, checked from source so it always runs ─────────────────────────────
def _server_source():
with open(os.path.join(_APP_PARENT, "sovran_systemsos_web", "server.py"),
encoding="utf-8") as f:
return f.read()
class LanOnlyWiring(unittest.TestCase):
def test_middleware_is_registered_outermost(self):
# Starlette makes the last-registered middleware the outermost one, so
# an off-network client is turned away before auth is considered.
src = _server_source()
auth = src.index("app.add_middleware(AuthMiddleware)")
nocache = src.index("app.add_middleware(NoCacheMiddleware)")
lan = src.index("app.add_middleware(LanOnlyMiddleware")
self.assertLess(auth, nocache)
self.assertLess(nocache, lan)
def test_policy_comes_from_the_generated_config(self):
src = _server_source()
self.assertIn("LanPolicy(", src)
self.assertIn('_hub_cfg.get("lan_only", True)', src)
self.assertIn('_hub_cfg.get("lan_extra_networks")', src)
if __name__ == "__main__":
unittest.main()
-196
View File
@@ -1,196 +0,0 @@
"""Tests for the Hub's login throttling.
These exercise the exact production implementation in
sovran_systemsos_web.security_helpers.LoginThrottle. The clock and the sleep are
injected, so the tests cover hours of lockout behaviour instantly.
No network access, no filesystem writes, no real delays.
"""
import os
import sys
import unittest
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
if _APP_PARENT not in sys.path:
sys.path.insert(0, _APP_PARENT)
from sovran_systemsos_web.security_helpers import ( # noqa: E402
LoginThrottle,
LOGIN_FAIL_DELAY,
LOGIN_FAIL_MAX_DELAY,
LOGIN_FAIL_WINDOW,
LOGIN_FAIL_MAX,
LOGIN_LOCKOUT_SECONDS,
)
class FakeClock:
"""A clock that only moves when the test says so."""
def __init__(self):
self.now = 1000.0
def __call__(self):
return self.now
def advance(self, seconds):
self.now += seconds
class FakeSleeper:
"""Records the delays it was asked to apply instead of sleeping."""
def __init__(self, clock):
self.clock = clock
self.calls = []
def __call__(self, seconds):
self.calls.append(seconds)
self.clock.advance(seconds)
def _make(**kwargs):
clock = kwargs.pop("clock", None) or FakeClock()
sleep = kwargs.pop("sleep", None) or FakeSleeper(clock)
return LoginThrottle(clock=clock, sleep=sleep, **kwargs), clock, sleep
def _trip(throttle, ip="203.0.113.9"):
"""Fail LOGIN_FAIL_MAX times. The fake sleeper advances the clock for us."""
for _ in range(LOGIN_FAIL_MAX):
throttle.record_failure(ip)
class DelayRamp(unittest.TestCase):
def test_delay_ramps_with_the_failure_count(self):
throttle, _, _ = _make()
self.assertEqual(throttle.delay_for(0), 0.0)
self.assertEqual(throttle.delay_for(1), LOGIN_FAIL_DELAY)
self.assertEqual(throttle.delay_for(3), LOGIN_FAIL_DELAY * 3)
def test_delay_is_capped(self):
# Unbounded ramping would let a single client park a thread-pool worker
# for minutes at a time.
throttle, _, _ = _make()
self.assertLessEqual(throttle.delay_for(999), LOGIN_FAIL_MAX_DELAY)
self.assertEqual(throttle.delay_for(999), LOGIN_FAIL_MAX_DELAY)
def test_first_failure_is_not_delayed_much(self):
throttle, _, sleep = _make()
delay = throttle.record_failure("203.0.113.9")
self.assertEqual(delay, LOGIN_FAIL_DELAY)
self.assertEqual(sleep.calls, [LOGIN_FAIL_DELAY])
class Lockout(unittest.TestCase):
def test_not_locked_out_initially(self):
throttle, _, _ = _make()
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
self.assertEqual(throttle.remaining_lockout("203.0.113.9"), 0.0)
def test_reaching_the_limit_locks_the_address_out(self):
throttle, _, _ = _make()
_trip(throttle)
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
def test_the_limit_is_reachable_inside_the_window(self):
# Regression guard for the old 60s window: with a ramping delay it
# takes ~80s to reach LOGIN_FAIL_MAX, so a 60s window expired the
# earliest failures first and the lockout could never fire.
throttle, clock, _ = _make()
start = clock.now
_trip(throttle)
self.assertLess(clock.now - start, LOGIN_FAIL_WINDOW)
self.assertEqual(throttle.failure_count("203.0.113.9"), LOGIN_FAIL_MAX)
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
def test_one_failure_short_of_the_limit_is_not_a_lockout(self):
throttle, _, _ = _make()
for _ in range(LOGIN_FAIL_MAX - 1):
throttle.record_failure("203.0.113.9")
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
def test_lockout_expires(self):
throttle, clock, _ = _make()
_trip(throttle)
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
clock.advance(LOGIN_LOCKOUT_SECONDS + 1)
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
def test_remaining_lockout_counts_down(self):
throttle, clock, _ = _make()
_trip(throttle)
full = throttle.remaining_lockout("203.0.113.9")
# the final record_failure applied a delay, which the fake clock has
# already advanced, so what is left is the lockout minus that delay
self.assertAlmostEqual(full, LOGIN_LOCKOUT_SECONDS,
delta=LOGIN_FAIL_MAX_DELAY + 1.0)
clock.advance(full / 2)
self.assertLess(throttle.remaining_lockout("203.0.113.9"), full)
self.assertGreater(throttle.remaining_lockout("203.0.113.9"), 0.0)
def test_further_failures_while_locked_out_extend_it(self):
throttle, clock, _ = _make()
_trip(throttle)
clock.advance(LOGIN_LOCKOUT_SECONDS - 1)
throttle.record_failure("203.0.113.9")
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
class Isolation(unittest.TestCase):
def test_one_address_does_not_lock_out_another(self):
throttle, _, _ = _make()
_trip(throttle, "203.0.113.9")
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
self.assertFalse(throttle.is_locked_out("198.51.100.7"))
def test_successful_login_clears_the_address(self):
throttle, _, _ = _make()
for _ in range(LOGIN_FAIL_MAX - 1):
throttle.record_failure("203.0.113.9")
throttle.clear("203.0.113.9")
self.assertEqual(throttle.failure_count("203.0.113.9"), 0)
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
def test_old_failures_age_out_of_the_window(self):
throttle, clock, _ = _make()
throttle.record_failure("203.0.113.9")
clock.advance(LOGIN_FAIL_WINDOW + 1)
self.assertEqual(throttle.failure_count("203.0.113.9"), 0)
class BoundedMemory(unittest.TestCase):
def test_tracked_addresses_are_evicted(self):
throttle, clock, _ = _make(max_tracked_ips=8)
for i in range(64):
throttle.record_failure(f"198.51.100.{i}")
clock.advance(LOGIN_FAIL_WINDOW + LOGIN_LOCKOUT_SECONDS + 1)
throttle.record_failure("203.0.113.9")
self.assertLessEqual(throttle.tracked_addresses(), 8)
def test_sleep_is_never_called_under_the_lock(self):
# If the lock were held across the sleep, one slow client would stall
# every other login — a self-inflicted DoS.
throttle, clock, _ = _make()
order = []
def spy(seconds):
order.append("sleep:start")
clock.advance(seconds)
order.append("sleep:end")
throttle._sleep = spy
throttle.record_failure("203.0.113.9")
self.assertEqual(order, ["sleep:start", "sleep:end"])
# A second address can still be recorded while the first is "sleeping".
self.assertEqual(throttle.failure_count("198.51.100.7"), 0)
if __name__ == "__main__":
unittest.main()
-112
View File
@@ -1,112 +0,0 @@
"""Guards for the Mullvad VPN client being a plain Desktop Role install.
The desktop app and its daemon ship with Desktop Only and with no other role:
Server + Desktop and Bitcoin Node are machines that serve, and a VPN client with
its own DNS and routing policy is not something their services asked for.
Beyond that the install is deliberately boring. It is the NixOS service exactly
as NixOS ships it — no local-network boot service, no DNS or firewall edits of
our own, no Hub tile, no Feature Manager card, no toggles. The account, the
relay and the kill switch belong to the app.
Like the other nix-file checks these read the shipped sources as text: nothing is
run and nothing touches the network.
"""
import os
import unittest
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _read(*parts):
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
return f.read()
def _without_comments(src):
"""The source with whole-line `#` comments removed."""
return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
class TheClientIsBuiltOnDesktopAndNowhereElse(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.module = _read("modules", "mullvad.nix")
cls.code = _without_comments(cls.module)
def test_the_module_is_imported(self):
self.assertIn("./mullvad.nix", _read("modules", "modules.nix"))
def test_the_gate_is_the_desktop_role(self):
self.assertRegex(
self.code,
r"lib\.mkIf\s+config\.sovran_systemsOS\.roles\.desktop\s*\{",
)
def test_no_other_role_is_mentioned(self):
self.assertNotIn("server_plus_desktop", self.code)
self.assertNotIn("roles.node", self.code)
def test_the_install_is_the_daemon_and_the_app(self):
self.assertRegex(self.code, r"services\.mullvad-vpn\s*=\s*\{")
self.assertRegex(self.code, r"(?m)^ enable = true;$")
self.assertRegex(self.code, r"gui\.enable = true;")
class TheInstallStaysVanilla(unittest.TestCase):
"""Nothing is configured for the user, in the module or out of it."""
@classmethod
def setUpClass(cls):
cls.module = _without_comments(_read("modules", "mullvad.nix"))
def test_no_extra_services_are_added(self):
self.assertNotIn("systemd.services", self.module)
def test_no_dns_or_network_edits_of_our_own(self):
# The NixOS module already turns on systemd-resolved and points
# NetworkManager at it; re-declaring either here would be our policy,
# not the distribution's.
for key in ("services.resolved", "networkmanager", "networking."):
self.assertNotIn(key, self.module)
def test_no_firewall_or_kernel_edits(self):
self.assertNotIn("firewall", self.module)
self.assertNotIn("boot.", self.module)
def test_no_packages_are_forced_in(self):
self.assertNotIn("environment.systemPackages", self.module)
def test_the_feature_flag_does_not_come_back(self):
# A Hub-managed feature flag on an app that is simply installed would
# put a toggle in front of something that has no decision to make.
for path in (("modules", "core", "roles.nix"),
("modules", "core", "role-logic.nix")):
self.assertNotIn("mullvad", _read(*path).lower(), f"{path[-1]} mentions mullvad")
class TheHubDoesNotMentionIt(unittest.TestCase):
"""The app is not a service the Hub manages or announces."""
def test_the_hub_tiles_and_roles_are_untouched(self):
self.assertNotIn("mullvad", _read("modules", "core", "sovran-hub.nix").lower())
def test_the_hub_backend_is_untouched(self):
self.assertNotIn("mullvad", _read("app", "sovran_systemsos_web", "server.py").lower())
def test_the_desktop_shell_is_untouched(self):
# The app appears in the GNOME app grid by itself; no folder edit, no
# dock entry.
self.assertNotIn(
"mullvad", _read("modules", "core", "sovran_systemsos-desktop.nix").lower())
def test_there_is_no_icon_for_it(self):
# app/icons holds Hub tile icons only.
self.assertFalse(
os.path.exists(os.path.join(_ROOT, "app", "icons", "mullvad.svg")))
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large. Load diff
-64
View File
@@ -1,64 +0,0 @@
"""Guards for when port 22 is open in the firewall.
sshd-localhost.nix gives every role "ssh root@localhost" by listening on
127.0.0.1 only. NixOS opens sshd's ports in the firewall by default whether or
not sshd listens on them, which left port 22 open on every role, Desktop Only
included, with nothing behind it. The roles that really publish SSH open it
explicitly, so the default has to stay off.
Like the other nix-file checks these read the modules as text: nothing is run
and nothing touches the network.
"""
import os
import re
import unittest
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _read(*parts):
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
return f.read()
def _without_comments(src):
return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
class LocalhostSshdDoesNotOpenThePort(unittest.TestCase):
def test_the_firewall_is_not_opened_by_default(self):
code = _without_comments(_read("modules", "core", "sshd-localhost.nix"))
self.assertRegex(code, r"openFirewall\s*=\s*lib\.mkDefault\s+false\s*;")
def test_it_still_listens_on_loopback_only(self):
code = _without_comments(_read("modules", "core", "sshd-localhost.nix"))
self.assertRegex(code, r'addr\s*=\s*"127\.0\.0\.1"')
self.assertNotIn("0.0.0.0", code)
class PublishedSshOpensItsOwnPort(unittest.TestCase):
"""Turning the default off must not close the roles that want SSH open."""
def test_the_sshd_feature_opens_22_and_only_when_enabled(self):
src = _without_comments(_read("modules", "sshd.nix"))
self.assertRegex(src, r"lib\.mkIf\s+config\.sovran_systemsOS\.features\.sshd")
self.assertRegex(src, r"networking\.firewall\.allowedTCPPorts\s*=\s*\[\s*22\s*\]")
def test_remote_deploy_opens_22_and_only_when_enabled(self):
src = _without_comments(_read("modules", "core", "remote-deploy.nix"))
self.assertRegex(src, r"lib\.mkIf\s+cfg\.enable")
self.assertRegex(src, r"networking\.firewall\.allowedTCPPorts\s*=\s*\[\s*22\s*\]")
class DesktopOnlyDocumentsWhatItOpens(unittest.TestCase):
def test_security_policy_says_desktop_opens_no_tcp_port(self):
text = " ".join(_read("SECURITY.md").split()) # the file is line-wrapped
self.assertIn("opens no TCP port", text)
self.assertIn("UDP 5353", text)
if __name__ == "__main__":
unittest.main()
-222
View File
@@ -1,222 +0,0 @@
"""Regression tests for Hub update completion and polling recovery."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[1]
_APP_PARENT = _REPO_ROOT / "app"
if str(_APP_PARENT) not in sys.path:
sys.path.insert(0, str(_APP_PARENT))
from sovran_systemsos_web.update_state import ( # noqa: E402
effective_update_status,
reboot_is_pending,
)
# Real store paths observed on the incident machine that prompted this rework.
RUNNING_GENERATION = (
"84rsiqi66nc68jbikd26ms50ap831xf8-nixos-system-nixos-26.11.20260817.ec2d622"
)
PREVIOUS_GENERATION = (
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-nixos-system-nixos-26.11.20260816.old"
)
# What the stale Hub log still claimed was staged: a two-week-old update cycle.
HUB_LOG_GENERATION = (
"yis0saq6p8fhqcaii0h2yzqf0blhdwns-nixos-system-nixos-26.11.20260804.e72e4f2"
)
class TestRebootPendingState(unittest.TestCase):
"""Reboot-pending state is derived from NixOS, not from Hub marker files.
The system profile (what boots next) is compared against
/run/current-system (what is running). This stays correct no matter
which tool performed the update: Hub "Update System", a terminal
``nixos-rebuild``, or an SSH support session.
"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name)
self.store = root / "store"
self.store.mkdir()
# /nix/var/nix/profiles/system is a two-hop chain: the diagnostics on
# the incident box showed ``system`` -> ``system-148-link`` -> store.
self.profile = root / "system"
self.profile_entry = root / "system-148-link"
# /run/current-system links straight to the running store path.
self.current = root / "current-system"
def _store_dir(self, name: str) -> str:
path = self.store / name
path.mkdir(exist_ok=True)
return str(path)
def _stage(self, booted: str, running: str) -> None:
"""Stage ``booted`` as the boot default with ``running`` live."""
os.symlink(self._store_dir(booted), self.profile_entry)
os.symlink(self.profile_entry, self.profile)
os.symlink(self._store_dir(running), self.current)
def test_hub_update_staged_and_not_yet_rebooted_is_pending(self):
self._stage(booted=RUNNING_GENERATION, running=PREVIOUS_GENERATION)
self.assertTrue(
reboot_is_pending(str(self.profile), str(self.current))
)
def test_staged_generation_booted_is_no_longer_pending(self):
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
self.assertFalse(
reboot_is_pending(str(self.profile), str(self.current))
)
def test_terminal_switch_needs_no_reboot(self):
# nixos-rebuild switch moves the profile AND current-system together.
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
self.assertFalse(
reboot_is_pending(str(self.profile), str(self.current))
)
def test_rollback_leaves_nothing_pending(self):
# nixos-rebuild switch --rollback points both at the rollback target.
self._stage(booted=PREVIOUS_GENERATION, running=PREVIOUS_GENERATION)
self.assertFalse(
reboot_is_pending(str(self.profile), str(self.current))
)
def test_unverifiable_state_is_never_a_reboot_demand(self):
# No profile and no running system readable: the Hub must not nag
# about a reboot it cannot substantiate.
self.assertFalse(
reboot_is_pending(str(self.profile), str(self.current))
)
def test_stale_hub_marker_clears_after_terminal_updates(self):
"""The exact incident: terminal-updated machine, frozen Hub marker.
The user's last Hub update (old updater, weeks prior) left
REBOOT_REQUIRED behind. Every update since ran in a terminal and
never touched the Hub's files, so the log still records a staged
generation from 2026-08-04 while the machine runs a 2026-08-17
build. With the profile and the running system in agreement, the
stale claim must reconcile to IDLE regardless of anything the old
marker/log files say.
"""
root = Path(self.tmp.name)
log = root / "sovran-hub-update.log"
log.write_text(
"Done. The new configuration is "
f"/nix/store/{HUB_LOG_GENERATION}\n"
"✓ Update staged successfully\n",
encoding="utf-8",
)
# Deliberately no sovran-hub-update.generation marker: the updater
# that produced this state predates the marker feature.
self.assertFalse(
(root / "sovran-hub-update.generation").exists()
)
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
self.assertEqual(
effective_update_status(
"REBOOT_REQUIRED", str(self.profile), str(self.current)
),
"IDLE",
)
def test_genuine_pending_reboot_claim_survives(self):
# A staged generation that has NOT been booted yet: the claim is
# true and must keep surfacing until the reboot really happens.
self._stage(booted=RUNNING_GENERATION, running=PREVIOUS_GENERATION)
self.assertEqual(
effective_update_status(
"REBOOT_REQUIRED", str(self.profile), str(self.current)
),
"REBOOT_REQUIRED",
)
def test_other_statuses_pass_through_unchanged(self):
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
for status in ("RUNNING", "FAILED", "SUCCESS", "IDLE"):
self.assertEqual(
effective_update_status(
status, str(self.profile), str(self.current)
),
status,
)
class TestUpdatePollingWiring(unittest.TestCase):
"""Guard the browser failure modes that caused a permanent spinner."""
@classmethod
def setUpClass(cls):
js_dir = _REPO_ROOT / "app" / "sovran_systemsos_web" / "static" / "js"
cls.update_js = (js_dir / "update.js").read_text(encoding="utf-8")
cls.rebuild_js = (js_dir / "rebuild.js").read_text(encoding="utf-8")
cls.helpers_js = (js_dir / "helpers.js").read_text(encoding="utf-8")
cls.events_js = (js_dir / "events.js").read_text(encoding="utf-8")
cls.template = (
_REPO_ROOT / "app" / "sovran_systemsos_web" / "templates" / "index.html"
).read_text(encoding="utf-8")
def test_status_fetches_have_an_abort_timeout(self):
self.assertIn("function apiFetchWithTimeout", self.helpers_js)
self.assertIn("new AbortController()", self.helpers_js)
self.assertIn("controller.abort()", self.helpers_js)
self.assertIn("apiFetchWithTimeout(", self.update_js)
self.assertIn("STATUS_POLL_FETCH_TIMEOUT", self.update_js)
def test_async_update_polls_cannot_overlap(self):
self.assertIn("_updatePollInFlight", self.update_js)
self.assertIn(
"if (_updateFinished || _updatePollInFlight) return;", self.update_js
)
self.assertIn("finally", self.update_js)
def test_connection_failure_has_explicit_non_running_ui(self):
self.assertIn("showUpdateStatusUnavailable", self.update_js)
self.assertIn("Update status unavailable", self.update_js)
self.assertIn("Retry Status", self.template)
self.assertIn("retryUpdateStatus", self.events_js)
def test_rdp_or_tab_resume_forces_reconciliation(self):
self.assertIn("resumeUpdateStatusAfterInterruption", self.events_js)
self.assertIn('window.addEventListener("focus"', self.events_js)
self.assertIn('document.addEventListener("visibilitychange"', self.events_js)
def test_verbose_log_rendering_is_bounded(self):
self.assertIn("UPDATE_VISIBLE_LOG_MAX_CHARS", self.update_js)
self.assertIn("document.createTextNode(text)", self.update_js)
self.assertNotIn("$modalLog.textContent += text", self.update_js)
def test_page_reload_restores_running_or_completed_update(self):
self.assertIn("restoreUpdateModalIfNeeded", self.update_js)
self.assertIn("await restoreUpdateModalIfNeeded();", self.events_js)
self.assertIn('current.result === "reboot_required"', self.update_js)
def test_all_javascript_remains_syntax_valid(self):
node = shutil.which("node")
if not node:
self.skipTest("node is not available in this test environment")
for script in (
_REPO_ROOT / "app" / "sovran_systemsos_web" / "static" / "js"
).glob("*.js"):
result = subprocess.run(
[node, "--check", str(script)], capture_output=True, text=True
)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()
-56
View File
@@ -1,56 +0,0 @@
"""Regression tests for the Hub Zeus Connect QR.
The Zeus Connect setup service (modules/wallet-autoconnect.nix) and the Hub QR
encoding are tested here. The lndconnect wrapper itself is now part of the
Sovran_Bitcoin flake — its contract tests live in that repository.
"""
import os
import re
import unittest
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _read(relpath: str) -> str:
with open(os.path.join(_REPO_ROOT, relpath), encoding="utf-8") as fh:
return fh.read()
class TestZeusConnectSetup(unittest.TestCase):
"""zeus-connect-setup must wait for the REST onion and validate the URI."""
@classmethod
def setUpClass(cls):
cls.src = _read("modules/wallet-autoconnect.nix")
def test_waits_for_onion_addresses(self):
self.assertIn("onion-addresses.service", self.src)
def test_rejects_non_lndconnect_output(self):
self.assertIn("^lndconnect://", self.src)
self.assertIn("\\.onion", self.src)
self.assertIn("macaroon=", self.src)
def test_no_clightning_fallback(self):
self.assertNotIn("lnconnect-clnrest", self.src)
class TestHubQrFallback(unittest.TestCase):
"""Hub QR encoding must not die on a payload that is too large for ECC H."""
def test_qrencode_falls_back_to_lower_ecc(self):
src = _read("app/sovran_systemsos_web/server.py")
self.assertIn('for ecc in ("H", "Q", "L")', src)
def test_qronly_does_not_hide_uri_when_qr_fails(self):
src = _read("app/sovran_systemsos_web/server.py")
self.assertIn("Don't hide the URI if we could not render a scannable QR.", src)
def test_zeus_guide_mentions_use_tor(self):
src = _read("app/sovran_systemsos_web/static/js/service-detail.js")
self.assertIn("Use Tor", src)
if __name__ == "__main__":
unittest.main()