218 Commits
Author SHA1 Message Date
naturallaw777 d31887cae6 chore(release): prepare v1.1.2 2026-08-21 09:47:43 -05:00
naturallaw777 669181c75b nixpkgs update with Bisq update 2026-08-20 17:04:36 -05:00
naturallaw777 47e9bb99b0 fix(public-ip): move system.activationScripts under the config attribute
The module mixed the `options` keyword attribute with a bare top-level
`system.*` setting. Once a module declares `options` (or `config`), every
other top-level attribute must be a reserved module keyword — the nixpkgs
unifyModuleSyntax check rejects anything else, so every nixos-rebuild
aborted at evaluation time with:

  error: Module '.../modules/core/public-ip.nix' has an unsupported
  attribute `system'. ... move all of them (namely: system) into the
  `config' attribute.

Prefix the activation script with `config.` (equivalent to wrapping it in
`config = { ... };`) so the module evaluates again. The detector script
itself is unchanged.

Fixes: ac6c498615 (-feat(public-ip): unify public-IP detection into one privacy-first script-)
2026-08-20 17:00:23 -05:00
naturallaw777 ac6c498615 feat(public-ip): unify public-IP detection into one privacy-first script
The public IP was previously detected independently in three places,
each contacting a different third party: the Hub (HTTPS echo via
api.ipify.org / ifconfig.me / icanhazip.com on every API call and
background tick), DDNS (myip.opendns.com via OpenDNS), and LiveKit
(embedded STUN). Consolidate into a single detector with one shared
cache so every consumer reads the same value with minimal exposure.

- add modules/core/public-ip.nix: installs /var/lib/sovran/public-ip.py
  (pure Python stdlib, no new deps) writing /var/lib/secrets/external-ip
- detection chain (first success wins): explicit pin, fresh cache
  (default TTL 300s), STUN binding request over UDP (one packet, no
  metadata), DNS myip.opendns.com query, then OPT-IN HTTPS echo
  (publicIP.httpsEcho, empty by default — never contacted unless listed)
- privacy: while the cache is fresh zero third parties are contacted;
  at most one party learns the IP per refresh interval, via the least
  exposing mechanism available
- hub (server.py): _get_external_ip() now reads the shared detector /
  cache instead of calling ipify/ifconfig/icanhazip directly
- ddns (njalla.nix): use the shared detector instead of a separate
  OpenDNS dig; allow the hardened service to write /var/lib/secrets
- element-calling: livekit-turn-setup falls back to the shared
  detector on cold boot; add LiveKit webhooks to lk-jwt-service
  (sfu_webhook) so abrupt disconnects are cleaned up immediately;
  set LIVEKIT_SANITY_CHECK_INTERVAL_SECONDS=60 as a missed-webhook
  guard; drop the dead services.livekit.settings block and set
  openFirewall=false (Caddy fronts the SFU; no public 7880/tcp)
- new options: sovran_systemsOS.publicIP.{stunServer,stunPort,
  dnsResolver,httpsEcho,cacheTTL}
2026-08-20 16:40:00 -05:00
naturallaw777 224ea99ce4 fix(element-calling): reuse Hub external IP for LiveKit, drop egress service calls 2026-08-20 16:12:00 -05:00
naturallaw777 c54dbfe2a5 feat(element-calling): fix Element X discovery and harden federated calling
The element-calling feature only advertised the LiveKit focus via the
well-known org.matrix.msc4143.rtc_foci file, and relied on STUN
auto-detection for the public IP. Element X queries the MatrixRTC
transports registry endpoint and fails with MISSING_MATRIX_RTC_TRANSPORT
when it is absent, and blocked STUN egress silently left LiveKit
advertising a private IP (call connects but no video across servers).

- synapse: enable msc4143_enabled and advertise matrix_rtc.transports
  (MSC4519) with the site's element-calling URL, so Element X can
  discover the LiveKit focus instead of erroring out
- livekit: determine the public IP to advertise at runtime —
  explicit pin, then HTTPS egress detection (api.ipify.org /
  checkip.amazonaws.com / ifconfig.me), then STUN fallback with a
  warning; reject non-routable results (private/loopback/CGNAT)
- lk-jwt-service: append optional extra homeservers to
  LIVEKIT_FULL_ACCESS_HOMESERVERS via the new
  sovran_systemsOS.elementCalling.fullAccessHomeservers option
- add sovran_systemsOS.elementCalling.externalIP option to pin the
  advertised public IP for multi-WAN/VPN setups
- add element-calling-public-check.service: boot-time diagnostics for
  public DNS (via 1.1.1.1, bypassing local loopback overrides), JWT
  healthz through Caddy and via the public IP, and the transports
  endpoint — turns the silent -no media- failure into a visible error
- add restartTriggers so livekit/lk-jwt-service pick up regenerated
  runtime configs on rebuild
2026-08-20 14:07:06 -05:00
naturallaw777 a1fa40cacf fix(hub): derive Restart required from boot default vs running system
NixOS already knows whether a reboot is pending: /nix/var/nix/profiles/
system vs /run/current-system. Marker files only the Hub's own updater
wrote desynced for terminal-updated machines (and markers from older
updaters could never clear), pinning the badge on forever. Reconcile
REBOOT_REQUIRED against live state on every read; the stale marker
self-heals to IDLE. The .generation marker write is now informational.
2026-08-19 11:31:29 -05:00
naturallaw77 48dacbeef3 refactor(lnd): use pkgs.lndinit, drop local packages/lndinit
The local packages/lndinit/default.nix is a verbatim copy of the
upstream Nixpkgs expression, frozen at v0.1.3-beta (the version that
was vendored in from nix-bitcoin before the Aug 10 2026 refactor in
commit 1fbeafd). It carries no Sovran-specific patches, no local
overrides, and no behavioral modifications - it is byte-for-byte
identical to what Nixpkgs ships, except ~19 minor versions older
(Nixpkgs currently ships 0.1.22-beta; the developer upstream
lightninglabs/lndinit is at v0.1.36-beta as of June 10 2026).

Why this matters
----------------

The Aug 10 2026 refactor (1fbeafd, "refactor: move vendor/nix-bitcoin
to modules/bitcoin, remove overlays") stated the new convention:

    No more random vendor/ or pkgs/ dirs - follows Sovran convention:
    modules/ for NixOS modules, packages/ for packages

That refactor successfully removed:
  * pkgs/sovran-overlay.nix
  * pkgs/nbxplorer.nix
  * pkgs/README.md
  * modules/vendor/ (entire directory)
  * overlay-sovran from flake.nix

It moved the lndinit expression into packages/lndinit/default.nix as
an intermediate step, but the file is still a verbatim upstream copy
and therefore still incurs the maintenance burden the refactor was
meant to eliminate: manual version bumps, manual hash refreshes, and
no upstream security or bug-fix flow. Removing it completes the
intent of 1fbeafd.

The change
----------

modules/bitcoin/lnd.nix (line 153):

    -  lndinit = "${(pkgs.callPackage ../../packages/lndinit {})}/bin/lndinit";
    +  lndinit = "${pkgs.lndinit}/bin/lndinit";

The two later uses of `lndinit` in the same file (lines 243 and 247,
inside the systemd.services.lnd.preStart block that calls
`lndinit gen-seed` and `lndinit init-wallet`) are unchanged because
they reference the let-bound `lndinit` value, not the callPackage
expression. They continue to work with the new pkgs.lndinit binary
path transparently.

Removed:
  * packages/lndinit/default.nix
  * packages/lndinit/ (now empty directory)

No other files in the repository reference packages/lndinit. Verified
by:
  * Git tree search for "packages/lndinit" -> only the file and its
    parent directory match
  * Content grep of flake.nix, configuration.nix,
    modules/bitcoin/default.nix, modules/bitcoin/common.nix, and
    iso/common.nix -> zero matches

Why this is safe
----------------

1. CLI compatibility. The preStart script only invokes two
   lndinit subcommands:
     * `lndinit gen-seed`
     * `lndinit -v init-wallet --file.seed=... --file.wallet-password=... --init-file.output-wallet-dir=...`
   Both subcommands and all four flags have been stable since the
   0.1.x line. The Nixpkgs 0.1.22-beta binary produces a wallet.db
   and admin.macaroon in the same on-disk format that 0.1.3-beta did
   for the same LND version (LND is pinned separately by pkgs.lnd
   from Nixpkgs and is unaffected by this change).

2. No coupled Go modules or shared vendor tree. The local
   packages/lndinit/default.nix is a self-contained buildGoModule
   derivation; it has no shared state with any other Sovran package.

3. Nixpkgs pin is current. flake.nix pins
   github:NixOS/nixpkgs/nixos-unstable, which has shipped pkgs.lndinit
   since 2022 and is currently at 0.1.22-beta. There is no
   "missing attribute" risk.

4. Wallet data is forward-compatible. The wallet.db format is owned
   by LND, not lndinit. lndinit is only used at first boot to create
   the seed and initialize the wallet; subsequent LND restarts do
   not invoke lndinit. So even if a user already initialized a
   wallet with 0.1.3-beta, the binary being upgraded to 0.1.22-beta
   is irrelevant - LND owns the wallet from that point on.

5. Single call site. Only modules/bitcoin/lnd.nix references
   lndinit. No other modules, scripts, or tests need to change.

Operational notes
-----------------

* After this commit, lndinit updates flow through the normal
  `nix flake update` workflow (or whatever automated dependency
  tooling is already in use, e.g. for the recent "chore(deps):
  update RTL to 0.15.10" commits). No Sovran-side action is needed
  to pick up future lndinit versions.

* If a future LND version requires a specific lndinit version, the
  pin can be done in flake.nix via a one-line overlay:

      nixpkgs.overlays = [ (final: prev: {
        lndinit = prev.lndinit.overrideAttrs (o: {
          version = "X.Y.Z-beta";
          src = prev.fetchFromGitHub { ... };
          vendorHash = "...";
        });
      }) ];

  This keeps the upgrade path explicit without bringing the entire
  expression back into the Sovran tree.

* This drops ~20 lines of frozen derivation code, eliminates one
  source of upstream drift, and reduces the surface area of what
  Sovran needs to keep current.
2026-08-18 12:30:59 -05:00
naturallaw777 b6e6adbe31 chore(deps): update RTL to 0.15.10
Refresh the RTL source and Node dependency hashes, and align Hub version metadata with the vendored package.
2026-08-18 11:19:23 -05:00
naturallaw777 64624002bb fix(hub): reconcile completed updates after polling stalls
The full-system updater runs as a detached systemd service and can finish
successfully even when the browser loses its status connection. In that
case the update log and status file correctly report REBOOT_REQUIRED, but
the Hub modal can remain on "Updating..." with its controls disabled.

There were four independent ways for the frontend to get stuck:

* update status fetches had no deadline, so a request that stayed pending
  never rejected and never advanced the existing failure counter;
* setInterval started async polls without waiting for the previous poll,
  allowing slow requests to overlap and responses to arrive out of order;
* each log chunk used textContent +=, replacing the complete and growing
  Nix build log every two seconds, which could stall browser rendering and
  was especially visible over RDP; and
* page reload, tab resume, and RDP reconnect did not reattach the modal to
  the update status persisted by the backend.

This produced a dangerous UX mismatch: the machine had a fully staged
NixOS generation and was ready to reboot, while the Hub continued telling
the user that the update was still running.

Bound status requests with AbortController, prevent overlapping polls, and
replace the endless spinner after sustained failures with an explicit
"Update status unavailable" state and Retry Status action. Reconcile state
immediately on focus, visibility, online, page startup, and before starting
a new update. Use no-store requests and render verbose logs incrementally
with a bounded visible tail while retaining the complete report in memory.
Apply the same timeout and single-flight protection to rebuild polling.

Record the exact generation produced by `nixos-rebuild boot`. The Hub now
keeps REBOOT_REQUIRED visible until that generation matches
/run/current-system, then clears the marker after reboot. For an update
started by an older updater that did not write the marker, recover the
staged generation from the final nixos-rebuild log line. The dashboard
sidebar also distinguishes update-in-progress and restart-required states.

Regression coverage verifies generation marker/log recovery, pre- versus
post-reboot detection, request timeout wiring, single-flight polling,
connection-loss UX, RDP/tab resume reconciliation, bounded log rendering,
page-reload recovery, and JavaScript syntax.

Validation:
* python3 -m unittest discover -s tests -p 'test_*.py' -v (170 passed)
* node --check app/sovran_systemsos_web/static/js/*.js
* python3 -m py_compile for changed Python modules
* git diff --check

A Nix evaluation was not available in the development sandbox; the NixOS
module should still be evaluated and built in CI or on a test machine before
release.
2026-08-18 10:31:28 -05:00
naturallaw777 1ccce429a5 fix(bitcoin): migrate i2pd SAM settings for nixpkgs 26.11
nixpkgs commit c8f9654 refactored the services.i2pd module to use
an RFC42-style settings attribute set and removed services.i2pd.proto.
After updating the root nixpkgs input from f13ff45 to ec2d622, the
vendored bitcoind module failed evaluation on the obsolete
services.i2pd.proto.sam.enable definition.

The error occurred even with services.bitcoind.i2p at its false default:
bitcoind was enabled, so NixOS still validated the obsolete option path
inside the conditional i2pd integration.

Read the SAM endpoint from services.i2pd.settings.sam and configure its
new upstream-style fields explicitly. Keep 127.0.0.1:7656, matching the
old typed option defaults that bitcoind uses to generate its i2psam
setting.

This preserves optional I2P support without activating it by default.
i2pd remains disabled until services.bitcoind.i2p is set to true or
"only-outgoing".

Nixpkgs migration: https://github.com/NixOS/nixpkgs/commit/c8f965411e812060a9377fa4c2d7d0f84e8b10e0
2026-08-18 09:19:15 -05:00
naturallaw777 f832efb0d1 nixpkgs update 2026-08-18 08:59:35 -05:00
Sovran Systems 67ae53ad3f Add tor-browser to the package list 2026-08-17 19:10:19 -05:00
naturallaw777 db5b9f6b60 fix(livekit): order turn-setup after network-online to fix boot-time red dot
livekit-turn-setup.service detects the primary interface from the IPv4
default route, but had no ordering against network-online.target. With
NetworkManager+DHCP the default route is applied late at boot, so the
oneshot could run before it existed, exit 1, and — being a hard
dependency of livekit.service — take livekit down with it. The Hub then
showed a 'failed' red dot until livekit was restarted manually.

Order both livekit.service and livekit-turn-setup.service after
network-online.target. Also add a bounded retry when copying Caddy's ACME
cert so we never write an empty turn.crt/turn.key on a fresh boot.
2026-08-17 19:07:54 -05:00
Sovran Systems 2ea1427766 fix: restore a Zeus-scannable LND REST connect QR
The LND-only rewrite of lndconnect.nix shipped a wrapper Zeus cannot
use: unknown flags (--cert/--macaroon), a non-existent onion path
(free/lnd.onion), and a REST hidden service that collided with LND's
P2P onion. Restore the nix-bitcoin contract — dedicated lnd-rest
onion on port 8080, --nocert over Tor, admin macaroon in the URI —
and only persist a valid lndconnect:// URI for the Hub QR.
2026-08-17 18:22:36 -05:00
Sovran Systems b35b327a07 Merge pull request #435 from naturallaw777/security/lnd-macaroon-argv
security: prevent LND admin macaroon exposure in curl argv
2026-08-15 23:03:44 -05:00
naturallaw777 a9ff168fd6 security: prevent LND admin macaroon exposure in curl argv 2026-08-15 23:00:59 -05:00
naturallaw777 b436dbed95 docs: update desktop and Sovran Hub screenshots 2026-08-15 18:53:15 -05:00
naturallaw777 1cf611d44c chore(release): prepare v1.1.1 2026-08-15 17:49:55 -05:00
Sovran Systems 1e3dd2dd76 Update release-stable.sh 2026-08-15 17:48:45 -05:00
Sovran Systems 45322db41a Enhance release script with preflight checks and metadata
Refactor release script to improve preflight checks and metadata preparation. Update release steps for GitHub and Gitea.
2026-08-15 17:43:03 -05:00
naturallaw777 2a1d73af25 Migrate dock/folder/mime entries from brave to brave-origin on upgrade 2026-08-15 17:31:25 -05:00
Sovran Systems e83c7fdb18 Merge pull request #434 from naturallaw777/fix/hub-logout-persistence
fix(hub): persistent browser profile so logout survives window reopen
2026-08-15 17:24:14 -05:00
naturallaw777 587c19c2a5 fix(hub): persistent browser profile so logout survives window reopen
The Hub launcher used an ephemeral /tmp profile deleted on exit, which
wiped the hub_manual_logout marker cookie. On reopen, /auto-login minted a
new session and logged the user straight back in without a password.

Use a persistent per-user profile under XDG_STATE_HOME and drop the
deletion trap so the logout marker survives close/reopen. Keep
--skip-origin-startup-dialog. Adds regression tests.
2026-08-15 17:22:58 -05:00
naturallaw777 c862ed5806 Skip Brave Origin startup dialog in Hub launcher 2026-08-15 17:15:12 -05:00
Sovran Systems 1d79acb083 Merge pull request #433 from naturallaw777/brave-origin-default
Switch default browser to Brave Origin (brave-origin)
2026-08-15 17:00:24 -05:00
naturallaw777 5edf594eac Switch default browser to Brave Origin (brave-origin) 2026-08-15 16:58:33 -05:00
Sovran Systems 94dba571c1 Merge pull request #432 from naturallaw777/fix/hub-session-auth-recovery
fix(hub): recover from expired sessions and preserve logout
2026-08-15 16:45:24 -05:00
naturallaw777 de32699539 fix(hub): recover from expired sessions and preserve logout 2026-08-15 16:43:37 -05:00
Sovran Systems 892397589f Merge pull request #431 from naturallaw777/feat/bitcoin-tor-ibd-gossip
feat: add Bitcoin Core Tor IBD gossip control
2026-08-15 14:47:21 -05:00
naturallaw777 30b753ec40 feat: add Bitcoin Core Tor IBD gossip control 2026-08-15 14:44:13 -05:00
Sovran Systems e98a0b7554 Merge pull request #430 from naturallaw777/chore/bitcoin-core-only
Replace Bitcoin Knots with Bitcoin Core
2026-08-13 13:48:28 -05:00
naturallaw777 3541f6baa1 Replace Bitcoin Knots with Bitcoin Core 2026-08-13 13:43:17 -05:00
Sovran Systems e0ee8a50a4 Merge pull request #429 from naturallaw777/fix/hub-ui-preformance-improvement
fix: prevent Bitcoin Core switch from hanging the Hub UI
2026-08-11 18:50:00 -05:00
naturallaw777 8f89a4350a fix: prevent Bitcoin Core switch from hanging the Hub UI 2026-08-11 18:47:40 -05:00
naturallaw777 cb2b49174e docs: update CHANGELOG.md for v1.1.0 2026-08-11 13:51:41 -05:00
naturallaw777 6563acef0a docs: update README ISO download links to v1.1.0 2026-08-11 13:51:41 -05:00
naturallaw777 a053ee77fb chore: bump VERSION to v1.1.0 for ISO naming 2026-08-11 13:51:41 -05:00
Sovran Systems 9ce169c4f0 Merge pull request #428 from naturallaw777/fix/hub-load-performance
perf: speed up Hub service status loading
2026-08-11 13:37:29 -05:00
naturallaw777 61287dfece perf: speed up Hub service status loading 2026-08-11 13:35:59 -05:00
Sovran Systems f48f210209 Merge pull request #427 from naturallaw777/fix/hub-service-version-badges
fix: correct RTL and Mempool Hub versions
2026-08-11 13:09:48 -05:00
naturallaw777 4193c56397 fix: correct RTL and Mempool Hub versions 2026-08-11 13:07:28 -05:00
naturallaw777 f85f1a2c9f Update Documentation 2026-08-11 12:18:21 -05:00
Sovran Systems 802474fe6d Merge pull request #426 from naturallaw777/fix/ddns-validation-placeholder
Fix DDNS URL validation: replace ${IP} temporarily for validator, kee…
2026-08-11 11:51:53 -05:00
Arena Agent 679c7a039f Fix DDNS URL validation: replace ${IP} temporarily for validator, keep placeholder for storage 2026-08-11 11:47:32 -05:00
Copilot 1b43a34e9c Merge pull request #424 from naturallaw777/copilot/security-hardening-final
Security hardening: key removal, session expiry, DDNS validation, journal allowlist, and production-backed tests
2026-08-11 11:34:51 -05:00
copilot-swe-agent[bot]andnaturallaw777 3f233beea0 njalla.nix: fail on ImportError; fix redundant except clause
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 15:40:04 +00:00
copilot-swe-agent[bot]andnaturallaw777 947c04834d Fix all 8 security hardening blockers for PR #423
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 15:38:08 +00:00
naturallaw777 894707a87c Correctly escape DDNS placeholder in Nix string 2026-08-11 10:11:44 -05:00
naturallaw777 43fc01d350 Fix Nix interpolation in DDNS runner 2026-08-11 10:06:32 -05:00
naturallaw777 1f0a1ab865 Import consolidated security hardening from PRs #419 and #420 2026-08-11 09:57:06 -05:00
Sovran Systems 79f3e8efd8 Merge pull request #422 from naturallaw777/copilot/security-hardening-draft-pr-combined
Consolidate security hardening for DDNS, support access, auth gating, and upgrade migrations
2026-08-11 09:32:49 -05:00
copilot-swe-agent[bot] ff6abf3b8e Initial plan 2026-08-11 14:00:34 +00:00
copilot-swe-agent[bot]andnaturallaw777 a111de1ece Security hardening: fix all 8 blocking findings for PR #419
Fix 1: Update support.js to collect SSH public key and POST JSON
Fix 2: Legacy njalla.sh migration - parse safely, archive non-executable, replace cron with systemd timer
Fix 3: DDNS SSRF prevention - allowlist only njal.la, reject other hosts, disable curl redirects
Fix 4: Legacy root support-key removal migration (_remove_legacy_root_support_key)
Fix 5: Automatic support-key expiration (expires_at + _expire_support_if_stale)
Fix 6: Move security helpers to security_helpers.py, tests import production code
Fix 7: Real NIP-19/Bech32 npub validation (_bech32_decode + _validate_npub)
Fix 8: Replace journalctl sudo wildcard with restricted sovran-journal-helper.py
Also: Make _write_hub_overrides() atomic with tempfile+os.replace
94 tests passing

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 12:07:18 +00:00
copilot-swe-agent[bot]andnaturallaw777 9b77b04741 Fix IP validation in DDNS and document journalctl sudo rule
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 10:45:50 +00:00
copilot-swe-agent[bot]andnaturallaw777 f2ad9c1f17 Security hardening: fix DDNS injection, Nix injection, reboot auth, support key, sudo rules
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 10:44:26 +00:00
copilot-swe-agent[bot] 590ed134b7 Initial plan 2026-08-11 10:35:07 +00:00
Sovran Systems cc438aa524 Merge pull request #418 from naturallaw777/fix/btcpay-version-display
fix: report configured BTCPay Server version
2026-08-11 05:18:59 -05:00
Arena.ai Agent 0d0a1888f9 fix: report configured BTCPay Server version 2026-08-11 05:15:11 -05:00
Sovran Systems 14b8258420 Merge pull request #417 from naturallaw777/copilot/fix-nixos-service-startup-regressions-again
Fix Matrix SIGPIPE crash and RTL v0.15.8 config schema regressions
2026-08-10 22:43:24 -05:00
copilot-swe-agent[bot]andnaturallaw777 09b34997e4 Fix Matrix SIGPIPE and RTL v0.15.8 config schema regressions
- modules/synapse.nix: replace tr|head pipeline (causes SIGPIPE under
  set -euo pipefail) with pwgen -sA0 20 1 which is already in PATH
- modules/bitcoin/rtl.nix: lowercase Authentication->authentication and
  Settings->settings per RTL v0.15.8 schema; add lnServerUrl pointing
  to LND REST endpoint; move swapServerUrl/boltzServerUrl inside
  settings; apply same fixes to CLN branch

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 03:41:41 +00:00
copilot-swe-agent[bot] a372b88488 Initial plan 2026-08-11 03:39:21 +00:00
Sovran Systems 7716c950cd Merge pull request #415 from naturallaw777/copilot/fix-btcpay-runtime-regressions
fix(btcpay): NBXplorer cookie auth and WorkingDirectory for wwwroot
2026-08-10 22:25:27 -05:00
copilot-swe-agent[bot]andnaturallaw777 2c223d1166 fix: add NBXplorer cookie auth and WorkingDirectory for BTCPay service
- Add btcexplorercookiefile to BTCPay deterministic config so NBXplorer
  cookie authentication succeeds (fixes 401 Unauthorized)
- Set WorkingDirectory to package lib dir so ASP.NET Core can locate
  wwwroot and LanguageService.ctor does not throw ArgumentNullException
- Update regression test to assert cookie file path and WorkingDirectory

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 03:24:13 +00:00
copilot-swe-agent[bot] 503bd2467d Initial plan 2026-08-11 03:22:13 +00:00
Sovran Systems 57070644cc Merge pull request #414 from naturallaw777/copilot/fix-btcpay-startup-regression
Fix BTCPay Server startup crash: assign home directories to service users
2026-08-10 22:13:18 -05:00
copilot-swe-agent[bot]andnaturallaw777 1b6b1ade46 Fix BTCPay startup regression: add home dirs to service users
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 03:12:27 +00:00
copilot-swe-agent[bot] a7b149f58a Initial plan 2026-08-11 03:11:02 +00:00
Sovran Systems 90f89f14ba Merge pull request #413 from naturallaw777/copilot/harden-btcpay-nbxplorer-integration
Harden BTCPay/NBXplorer secret handling and deterministic config generation
2026-08-10 22:01:00 -05:00
copilot-swe-agent[bot]andnaturallaw777 dcfee1fb32 fix: address btcpay hardening review feedback
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 02:44:12 +00:00
copilot-swe-agent[bot]andnaturallaw777 05a42bcc4b feat: harden btcpay and nbxplorer config handling
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 02:40:16 +00:00
copilot-swe-agent[bot] 9befcd06e6 Initial plan 2026-08-11 02:34:27 +00:00
Sovran Systems c9169164b5 Merge pull request #412 from naturallaw777/copilot/fix-btcpayserver-hmac-authentication
fix(btcpayserver): register bitcoin-HMAC-btcpayserver as managed nix-bitcoin secret
2026-08-10 21:27:10 -05:00
copilot-swe-agent[bot]andnaturallaw777 cb84ca839f fix: register bitcoin-HMAC-btcpayserver as managed secret owned by bitcoind user
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 02:25:21 +00:00
copilot-swe-agent[bot] 6d59441b13 Initial plan 2026-08-11 02:24:45 +00:00
Copilot 5475e33d08 Merge pull request #410 from naturallaw777/copilot/409-update-fetch-node-modules
Unblock flake checks and document vendored nix-bitcoin packaging provenance
2026-08-10 21:07:38 -05:00
copilot-swe-agent[bot]andnaturallaw777 88896f00c4 Add provenance headers and fix flake checks for PR409
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-10 21:07:10 -05:00
Copilot cb2d6de0ef Merge pull request #409 from naturallaw777/copilot/fix-nixos-build-mempool-3-2-1
Restore upstream nix-bitcoin fetchNodeModules helper to fix mempool and RTL builds
2026-08-10 21:07:08 -05:00
copilot-swe-agent[bot]andnaturallaw777 c2dce6cf94 Restore upstream nix-bitcoin fetchNodeModules for mempool and RTL packages
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-10 23:24:31 +00:00
copilot-swe-agent[bot] 7879a2771d Initial plan 2026-08-10 23:21:00 +00:00
Sovran Systems 820d850d00 Refactor Nix build configuration for mempool 2026-08-10 18:07:46 -05:00
Sovran Systems d733187ec5 Update npm dependencies and patch handling in default.nix 2026-08-10 17:40:53 -05:00
Sovran Systems f7b1f42e0e Update npmDepsHash for backend and frontend packages 2026-08-10 17:33:21 -05:00
Sovran Systems 03fb7791eb Mempool: use postPatch to copy lock file for npmDeps fetcher
prefetch-npm-deps runs in the deps fetcher's buildPhase and checks for
package-lock.json at CWD (repo root). postPatch runs in both the
fetcher and the main build, so we copy the lock file there. The main
build then cds into the subdir in buildPhase.

npmDepsHash values remain placeholders (lib.fakeHash).
2026-08-10 17:21:42 -05:00
Sovran Systems 953cacc72c Mempool: use preBuild cd instead of postUnpack so npmDeps fetcher finds lock files
postUnpack did not run in the npmDeps fixed-output derivation, so
prefetch-npm-deps couldn't find package-lock.json. preBuild runs in
both the fetcher and main build, ensuring consistent CWD for the lock
file lookup. Backend patch moved to prePatch/postPatch so it only
applies in the main build.

npmDepsHash values remain placeholders (lib.fakeHash).
2026-08-10 17:15:07 -05:00
Sovran Systems 6021691b3f Mempool: fix npmDeps fetcher — postUnpack instead of sourceRoot, postPatch instead of patches
The `patches` attribute was being applied inside the npmDeps
fixed-output derivation where patch runs interactively and fails.
sourceRoot also caused the deps fetcher to hash a different tree than
expected. Now:

- postUnpack cds into the subdir for both the fetcher and main build
- postPatch applies the mining-pool patch only in the main build
- npmDepsHash values remain placeholders (lib.fakeHash)
2026-08-10 17:11:30 -05:00
Sovran Systems 4d8bb06d41 Mempool: switch backend and frontend to buildNpmPackage with placeholder npmDepsHash
npm ci in buildPhase hangs because the Nix build sandbox has no network
access. buildNpmPackage pre-fetches dependencies as a fixed-output
derivation with network access, then the main build runs offline.

npmDepsHash values are placeholders (lib.fakeHash) — the first builds
will fail with hash mismatches that report the correct hashes.
2026-08-10 17:07:02 -05:00
Sovran Systems b96d2bcb24 Update npmDepsHash with correct hash value 2026-08-10 12:46:02 -05:00
Sovran Systems 228a48af03 RTL: switch to buildNpmPackage with placeholder npmDepsHash
buildNpmPackage is built into nixpkgs and pre-fetches dependencies as a
fixed-output derivation with network access. npmDepsHash is a
placeholder (lib.fakeHash) — the first build will fail with a hash
mismatch that reports the correct hash.
2026-08-10 12:41:51 -05:00
Sovran Systems 0df4cde9b8 Fix RTL: use fetchNodeModules instead of npm ci in buildPhase
npm ci in buildPhase hangs because the Nix build sandbox has no network
access. Adopt nix-bitcoin's approach: fetchNodeModules pre-fetches
node_modules as a fixed-output derivation (which has network access),
and the main build copies them offline. Uses the same node_modules hash
as upstream nix-bitcoin for RTL 0.15.8.
2026-08-10 12:36:58 -05:00
Sovran Systems a37b1a91bf Fix PostgreSQL ensureUsers: use ensureDBOwnership instead of ensureClauses
ensureClauses generates ALTER ROLE clauses, not GRANT statements, so
'"DATABASE btcpayserver" = "ALL PRIVILEGES"' produced invalid SQL.
ensureDBOwnership = true makes each user own its database, which is
the intended effect.
2026-08-10 11:44:19 -05:00
Sovran Systems ebcc086a7e Fix lnd macaroons: replace invalid 'enable' with 'user'
The services.lnd.macaroons submodule has no 'enable' option — macaroons
are implicitly enabled by being defined. Replace 'enable = true' with
'user = cfg.btcpayserver.user' which is the correct option for
controlling macaroon file ownership.
2026-08-10 11:40:36 -05:00
Sovran Systems dcc9e4904e Add bitcoind.rpc.users.btcpayserver for NBXplorer
Define the btcpayserver RPC user in bitcoind with passwordHMACFromFile
and the full RPC whitelist required by NBXplorer.
2026-08-10 11:34:24 -05:00
Sovran Systems fd209254a2 Fix postgresql ensurePermissions -> ensureClauses for nixpkgs unstable
The option services.postgresql.ensureUsers.*.ensurePermissions was renamed
to ensureClauses in nixpkgs unstable. Update both btcpayserver and nbxplorer
database user configurations.
2026-08-10 11:21:10 -05:00
Sovran Systems a68130625e Fix btcpayserver.nix to use pkgs.stable overlay
Replace all pkgs-stable references with pkgs.stable (from overlay-stable in flake.nix):
- Remove pkgs-stable from function arguments
- Use pkgs.stable.nbxplorer (2.6.10 from nixos-26.05)
- Use pkgs.stable.btcpayserver (2.4.2 from nixos-26.05)
2026-08-10 11:16:26 -05:00
Sovran Systems 7b04adbbe6 Restore original flake.nix with nixosModules.Sovran_SystemsOS export
Restores the complete original flake.nix that includes:
- nixosModules.Sovran_SystemsOS (required by deployed machines)
- overlay-stable for accessing pkgs.stable packages
- nixosConfigurations.nixos for direct builds
- nixosConfigurations.sovran_systemsos-iso for ISO builds
- nixosTests and checks
2026-08-10 11:12:21 -05:00
Sovran Systems 2c6ef7b60e Fix btcpayserver syntax error and pin to nixpkgs-stable (2.4.2)
1. flake.nix: Add pkgs-stable as specialArg for modules
2. btcpayserver.nix:
   - Fix preStart script syntax error (closing brace on new line)
   - Use pkgs-stable.btcpayserver (2.4.2) instead of pkgs.btcpayserver
   - Use pkgs-stable.nbxplorer (2.6.10) instead of pkgs.nbxplorer
   - Remove clightning references (Sovran is LND-only)
2026-08-10 10:56:51 -05:00
Sovran Systems 179fc501f9 Add vendored RTL package and fix rtl.nix to use it
- Create packages/rtl/default.nix to build RTL from source
- Update modules/bitcoin/rtl.nix to use vendored package instead of pkgs.rtl
- Remove clightning/lightning-loop references (Sovran is LND-only)
- Simplify to LND-only configuration
2026-08-10 10:38:20 -05:00
Sovran Systems 8bcabe2aa1 Fix mempool package: remove fetchNodeModules dependency
Replace fetchNodeModules (nix-bitcoin specific) with standard npm ci approach.
The package-lock.json files in the mempool repo will be used directly.
2026-08-10 10:25:39 -05:00
Sovran Systems 5d5e369411 Merge pull request #408 from naturallaw777/copilot/vendor-mempool-packages
Vendor mempool packages and make `services.mempool` self-contained
2026-08-10 10:09:33 -05:00
copilot-swe-agent[bot]andnaturallaw777 425a1845b4 chore: clean mempool module comment typo
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-10 15:07:42 +00:00
copilot-swe-agent[bot]andnaturallaw777 546dacf396 vendor mempool packages and wire mempool module to vendored pkgs
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-10 15:06:50 +00:00
copilot-swe-agent[bot] 17c0db8290 Initial plan 2026-08-10 15:03:18 +00:00
Sovran Systems dac733de9a Refactor onion service configuration for LND 2026-08-10 09:55:59 -05:00
Sovran Systems 3baf504c60 Fix typo in onion-addresses service check 2026-08-10 09:52:55 -05:00
Sovran Systems f220b93b12 Add missing bitcoind-rpc-public-whitelist.nix
This file is imported by bitcoind.nix line 375 but was missing from the repo.
Vendored from nix-bitcoin to make the module self-contained.
2026-08-10 09:45:23 -05:00
Sovran Systems b2a5233653 Merge pull request #407 from naturallaw777/copilot/fixnix-bitcoin-vendoring
Complete nix-bitcoin vendoring for fully self-contained Bitcoin modules
2026-08-10 09:41:26 -05:00
copilot-swe-agent[bot] 2bb5c50196 Initial plan 2026-08-10 14:30:27 +00:00
Sovran Systems 80c4b052a0 Merge pull request #406 from naturallaw777/fix/onion-services-remove-joinmarket-ob-watcher
Remove joinmarket-ob-watcher from onion-services defaults
2026-08-10 09:15:26 -05:00
Sovran Systems 2accada021 Remove joinmarket-ob-watcher from onion-services defaults
The joinmarket-ob-watcher module was not imported from nix-bitcoin,
so referencing it in the onion-services defaults caused an
"attribute 'joinmarket-ob-watcher' missing" evaluation error.
2026-08-10 09:13:33 -05:00
Sovran Systems a6296b9473 Fix formatting of extraGroups in btcpayserver.nix 2026-08-10 08:55:12 -05:00
Sovran Systems 5173fb3df6 Merge pull request #404 from naturallaw777/fix/syntax-final
fix: add missing semicolon after extraGroups in btcpayserver.nix
2026-08-10 08:53:36 -05:00
Syntax Fix 147afcc4ac fix: add missing semicolon after extraGroups in btcpayserver.nix
- Previous lnd-only refactor removed the '++ optional clightning' line
  but left 'extraGroups = [ cfg.nbxplorer.group ]' without trailing ';'
  -> syntax error: unexpected '=', expecting ';' at home = ...
- Add ';' to fix build on both f13ff45 and 8b8c811
2026-08-10 08:51:39 -05:00
Sovran Systems 2e35539ae6 Merge pull request #403 from naturallaw777/tailor/lnd-only-final
tailor: make bitcoin modules truly Sovran-only (lnd-only) and delete …
2026-08-10 08:40:08 -05:00
Sovran Tailor 31cd5f96f2 tailor: make bitcoin modules truly Sovran-only (lnd-only) and delete stubs.nix
- lndconnect.nix: rewrite to lnd-only (320 lines removed) - remove
  services.clightning.plugins.clnrest.lnconnect and
  services.clightning-rest.lndconnect (Sovran only uses services.lnd.lndconnect for Zeus)
  Fixes: 'services.clightning.plugins.clnrest.address does not exist' on f13ff45
  and 'attribute enable missing' at lndconnect.nix:216

- btcpayserver.nix: remove liquidd (lbtc) dead branch - Sovran uses lbtc=false
  * remove inherit (config.services) bitcoind liquidd -> just bitcoind
  * lbtc chains/rpc handling is dead code, keep guards but no need for liquidd service

- mempool.nix: remove fulcrum branch - Sovran uses electrs only
- rtl.nix: remove lightning-loop block and clightning service block (lnd only)
- Delete modules/bitcoin/stubs.nix entirely - no dead service references left
  * liquidd, fulcrum, lightning-loop, joinmarket now not referenced at all
  * clightning already handled via guards, not stubs
- Result: modules/bitcoin/ is now 100% tailored to Sovran (bitcoind+knots, lnd, electrs, rtl, btcpayserver, mempool)
  No more stubs whack-a-mole on any nixos-unstable
2026-08-10 08:37:29 -05:00
Sovran Systems 90262e672e Merge pull request #402 from naturallaw777/refactor/clean-bitcoin
refactor: move vendor/nix-bitcoin to modules/bitcoin, remove overlays
2026-08-10 08:24:07 -05:00
Sovran Clean 1fbeafd02f refactor: move vendor/nix-bitcoin to modules/bitcoin, remove overlays
- Move modules/vendor/nix-bitcoin/* -> modules/bitcoin/* (clean, Sovran-owned)
  * modules/bitcoin/default.nix imports the 6 tailored services
  * modules/bitcoin/common.nix bundles secrets/onion/lib
  * modules/bitcoin/stubs.nix kept minimal (no clightning)
  * packages/lndinit/default.nix replaces pkgs/sovran-overlay.nix
    (lnd.nix now uses pkgs.callPackage ../../packages/lndinit {})
- Remove pkgs/sovran-overlay.nix, pkgs/nbxplorer.nix, pkgs/README.md
  * No global overlay - lndinit is a normal package in packages/
- Remove modules/vendor/ entirely
- Update flake.nix: drop overlay-sovran, import ./modules/bitcoin
  instead of ./modules/vendor/nix-bitcoin/modules.nix
- No more random vendor/ or pkgs/ dirs - follows Sovran convention:
  modules/ for NixOS modules, packages/ for packages
2026-08-10 08:20:40 -05:00
Sovran Systems 953cedce3f Merge pull request #401 from naturallaw777/fix/final-btcpayserver
fix: remove services.clightning.enable assignment that fails on f13ff45
2026-08-10 04:45:38 -05:00
Sovran Fix 58d7e21d28 fix: remove services.clightning.enable assignment that fails on f13ff45
- f13ff45 HAS services.clightning but only as { plugins = ... }, no enable
  -> 'option does not exist, did you mean plugins' (your error)
- Sovran never uses lightningBackend == clightning (uses lnd),
  so just don't set services.clightning.enable at all
- Also make lnd port conflict check always true for clightning case
- Fixes both f13ff45 (has clightning/plugins) and 8b8c811 (removed)
2026-08-10 04:43:20 -05:00
Sovran Systems 9f7dcfa6de Merge pull request #400 from naturallaw777/fix/final-clightning-both
fix: remove clightning and clightning-rest from stubs to avoid duplic…
2026-08-10 04:35:03 -05:00
Sovran Fix 6c2c69d18a fix: remove clightning and clightning-rest from stubs to avoid duplicate on f13ff45
- f13ff45 (staging-dev) HAS both services -> unconditional stubs duplicate
- Remove both from stubs.nix, keep only liquidd/fulcrum/etc.
- lndconnect.nix left as is (defines clightning-rest.lndconnect) - safe on f13ff45 where base exists
- On 8b8c811 where clightning is removed, Sovran doesn't use it anyway (lnd only), so guards in btcpayserver/lnd prevent use
2026-08-10 04:31:23 -05:00
Sovran Systems 6b9c68e3b2 Merge pull request #399 from naturallaw777/fix/nixpkgs-unstable-clightning-2026-08
fix: add stubs for nixpkgs-unstable 2026-08 where services.clightning…
2026-08-09 21:23:01 -05:00
Sovran Fix 4b511348ae fix: add stubs for nixpkgs-unstable 2026-08 where services.clightning removed
nixpkgs 8b8c811 (2026-08-08) removed services.clightning.enable,
causing btcpayserver.nix:124 to throw 'option does not exist' on
nixos-rebuild (your error). Sovran uses lnd only, never clightning,
but evaluation still throws.

- Add modules/vendor/nix-bitcoin/stubs.nix to provide missing options
  as false stubs: clightning, clightning-rest, liquidd, fulcrum,
  lightning-loop/pool, joinmarket
- Guard btcpayserver/rtl/lnd/mempool clightning/liquidd references
  with config.services ? X checks
- Trim enable-tor.nix onionServices for removed services
2026-08-09 21:21:19 -05:00
Sovran Systems 81946895db Merge pull request #398 from naturallaw777/vendor/nixpkgs-only
Vendor/nixpkgs only
2026-08-09 20:24:11 -05:00
naturallaw777 a10ef95b5c chore: nix flake update - drop nix-bitcoin 2026-08-09 20:19:35 -05:00
Sovran PR Bot 278d480653 vendor: replace nix-bitcoin flake input with minimal vendored modules (nixpkgs-only)
- Remove inputs.nix-bitcoin (fort-nix/nix-bitcoin/release) from flake.nix
- Vendor only 6 services actually used by Sovran: bitcoind, electrs,
  lnd (+lndconnect), rtl, btcpayserver, mempool + supporting infra:
  secrets, onion-services/addresses, operator, nodeinfo, security,
  versioning
- All packages now from nixpkgs directly (pkgs.*) — no pinned pkgs
- Keep nix-bitcoin.* option namespace for compatibility
- backups.nix removed: Sovran uses rsnapshot to Second_Drive
  (configuration.nix: hourly/daily to BTCEcoandBackup) — duplicity
  remote backup not needed
- netns-isolation.nix replaced with stub (5 lines): original 365-line
  bridge/iptables/ip-netns broke Caddy/AlbyHub/RTL a year ago and
  is incompatible with nwc-wallets (requires enable=false). Stub
  keeps option valid but warns if enabled.
- Add pkgs/sovran-overlay.nix for gaps only: lndinit + netns-exec stub
2026-08-09 20:16:34 -05:00
naturallaw777 b64061135a fix(scripts): keep ISO artifacts out of the repo and auto-update README on release 2026-08-07 18:31:20 -05:00
Sovran Systems 825c3fbb4b Remove duplicate 1.0.6 release notes from CHANGELOG
Remove duplicate version 1.0.6 details from CHANGELOG.md.
2026-08-07 16:17:24 -05:00
naturallaw777 8b169a04e3 chore: release v1.0.6 2026-08-07 16:11:59 -05:00
naturallaw777 d0c2ea933c docs: update CHANGELOG.md for v1.0.6 2026-08-07 16:03:21 -05:00
naturallaw777 b66c50c195 fix(scripts): resolve tag range detection and improve release diagnostics 2026-08-07 16:02:09 -05:00
naturallaw777 1737e6c7d8 fix(scripts): add token scope diagnostics and un-silence gh release errors 2026-08-07 15:59:22 -05:00
naturallaw777 782b279e0a docs: update CHANGELOG.md for v1.0.6 2026-08-07 15:55:26 -05:00
naturallaw777 564e9835ea chore: bump VERSION to v1.0.6 for ISO naming 2026-08-07 15:55:26 -05:00
Sovran Systems 3b8f809e7c Merge pull request #397 from naturallaw777/fix/security-error-hardening
fix: harden error handling and sanitize exception details across secu…
2026-08-07 15:38:49 -05:00
naturallaw777 9df52256d4 fix: harden error handling and sanitize exception details across security endpoints (CWE-209) 2026-08-07 15:36:44 -05:00
Sovran Systems de481db97c Merge pull request #396 from naturallaw777/fix/exception-exposure-5351
fix: sanitize exception handling in verify-integrity and security-res…
2026-08-07 15:00:10 -05:00
naturallaw777 9051aed737 fix: sanitize exception handling in verify-integrity and security-reset (CWE-209) 2026-08-07 14:58:24 -05:00
Sovran Systems 65376ec8fb Merge pull request #395 from naturallaw777/fix/exception-exposure-5252
fix: sanitize api_security_reset errors to prevent exception informat…
2026-08-07 14:40:08 -05:00
naturallaw777 899f570ded fix: sanitize api_security_reset errors to prevent exception information exposure (CWE-209) 2026-08-07 14:31:38 -05:00
Sovran Systems d3b41b14fb Merge pull request #394 from naturallaw777/fix/path-injection-4338
fix: pass sanitized abs_path to os.chown to resolve CodeQL path injec…
2026-08-07 14:23:57 -05:00
naturallaw777 56db634900 fix: use canonical prefix containment check for CodeQL path-injection 2026-08-07 14:22:44 -05:00
naturallaw777 3522270373 fix: pass sanitized abs_path to os.chown to resolve CodeQL path injection at 4338 2026-08-07 14:17:34 -05:00
Sovran Systems efda70c187 Merge pull request #393 from naturallaw777/fix/path-injection-4334-4532
fix: add CodeQL-recognized path sanitization for domain_name
2026-08-07 13:51:28 -05:00
copilot-swe-agent[bot]andnaturallaw777 a32b353eda fix: resolve merge conflict with origin/main in server.py
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-07 18:50:01 +00:00
naturallaw777 4555764d87 fix: add CodeQL-recognized path sanitization for domain_name 2026-08-07 13:44:26 -05:00
Sovran Systems c38d4be851 Merge pull request #392 from naturallaw777/fix/path-injection-4334-4532
fix: add CodeQL-recognized path sanitization for domain_name
2026-08-07 13:36:03 -05:00
naturallaw777 5130076400 fix: add CodeQL-recognized path sanitization for domain_name 2026-08-07 13:33:55 -05:00
Sovran Systems 1dbbd6a943 Merge pull request #391 from naturallaw777/fix/njalla-sentinel-4371
fix: remove domain substring check for CodeQL incomplete-url
2026-08-07 13:28:14 -05:00
naturallaw777 f0a640519d fix: remove domain substring check for CodeQL incomplete-url 2026-08-07 13:26:41 -05:00
naturallaw777 a14ef03d40 fix: use sentinel for njalla header check (CodeQL incomplete-url-substring) 2026-08-07 13:07:06 -05:00
Sovran Systems a0cc7e6bec Merge pull request #390 from naturallaw777/fix/xss-lnurl-qr-print-5013
fix: prevent reflected XSS in lnurl-qr print endpoint
2026-08-07 13:00:46 -05:00
naturallaw777 1ecf245a07 fix: prevent reflected XSS in lnurl-qr print endpoint 2026-08-07 12:59:08 -05:00
Sovran Systems f8730cc0ab Merge pull request #389 from naturallaw777/fix/clear-text-root-password
fix: hash root password instead of storing in clear text (CWE-312)
2026-08-07 12:39:48 -05:00
Contributor 8a766181de fix: hash root password instead of storing in clear text (CWE-312)
- Replace plain-text write of new_root_password in api_security_reset()
  with scrypt-hashed storage via _hash_password(), matching how the free
  password is already handled.

- Return new_root_password in the API response so the user sees it once
  before it is irreversibly hashed on disk.

- Teach _resolve_credential() to detect scrypt hashes and display a
  human-readable placeholder instead of raw hex in the Hub credentials UI.

- Harden root-password-setup systemd service: if the secrets file already
  contains a hash, skip chpasswd so a manual restart never sets the hash
  as the literal login password.
2026-08-07 12:34:13 -05:00
naturallaw777 bd0d2cd812 removed temp patch file 2026-08-07 12:16:11 -05:00
naturallaw777 592f2bd12f fix: separate web auth hash from system password file
- Add FREE_PASSWORD_FILE_WEB for scrypt hashes
- Legacy fallback + auto-migrate in _check_password
- chpasswd sync in api_change_password and security reset endpoint
2026-08-07 12:04:07 -05:00
Sovran Systems b7bba228fe Merge pull request #387 from naturallaw777/fix/matrix-hub-service-admin
Fix Matrix Hub service-admin provisioning
2026-08-07 11:10:48 -05:00
Sovran Systems 3010557064 Fix Matrix Hub admin API credentials 2026-08-07 16:09:13 +00:00
Sovran Systems 69f64c18d1 Merge pull request #386 from naturallaw777/copilot/fix-code-scanning-alerts
Fix CWE-78 command injection in Matrix create-user endpoint
2026-08-07 10:13:04 -05:00
copilot-swe-agent[bot]andnaturallaw777 942da64332 Fix CWE-78: replace subprocess call with Synapse Admin API in create-user endpoint
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-07 15:09:36 +00:00
copilot-swe-agent[bot] bae790ebb4 Initial plan 2026-08-07 15:07:32 +00:00
naturallaw777 501c5abe64 docs: add SECURITY.md detailing security policy and best practices 2026-08-06 14:09:35 -05:00
Sovran Systems 63f1069e4a Merge pull request #385 from naturallaw777/arena/019fd2b5-sovran-systemsos
docs: versioned CDN downloads + add CDN upload script
2026-08-05 11:18:26 -05:00
naturallaw777andarena-agent f2842ffafb docs: versioned CDN downloads + add CDN upload script
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-05 16:16:59 +00:00
Sovran Systems 591ae766d2 Merge pull request #384 from naturallaw777/arena/019fd28e-sovran-systemsos
iso: fix replaceStrings length mismatch breaking ISO build
2026-08-05 10:39:02 -05:00
naturallaw777andarena-agent f81b27cc19 iso: fix replaceStrings length mismatch in cleanVersion
builtins.replaceStrings requires the 'from' and 'to' lists to have the
same length. The 'to' list had a single empty string while 'from' had
three entries (v, newline, CR), which made evaluating image.baseName
fail with: 'from' and 'to' arguments passed to builtins.replaceStrings
have different lengths. Add the two missing empty strings.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-05 15:38:21 +00:00
Sovran Systems dda6fb8e91 Merge pull request #383 from naturallaw777/arena/019fcf25-sovran-systemsos
Promote virtual machine trial option in README
2026-08-04 19:43:06 -05:00
naturallaw777andarena-agent cf250604f9 Promote virtual machine trial option in README
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-05 00:42:42 +00:00
Sovran Systems 963d41f1ed Merge pull request #382 from naturallaw777/arena/019fce1d-sovran-systemsos
Improve installer VM compatibility
2026-08-04 14:08:45 -05:00
naturallaw777andarena-agent 5188cca9aa Improve installer VM compatibility
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-04 19:07:51 +00:00
Sovran Systems f39f59fb81 Merge pull request #381 from naturallaw777/arena/019fcde4-sovran-systemsos
Trim redundant dev-vs-stable Gitea explanations in README
2026-08-04 13:24:52 -05:00
naturallaw777andarena-agent 61fc61cceb Trim redundant dev-vs-stable Gitea explanations in README
Replace repeated prose across 4 sections with a compact table + flow arrow:
- Top callout: 7-line paragraph → 2-line summary + link
- Development workflow: 2 bullets + 3-step list + warning → 3-row table + flow arrow
- Build from source: re-explained sync relationship → single labels on clone commands
- Contributing footer: repeated branch/host info → one sentence

No information lost; ~270 words removed.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-04 18:23:54 +00:00
Sovran Systems 4b9800aef3 Merge pull request #380 from naturallaw777/arena/019fcdbe-sovran-systemsos
docs: correct active development workflow
2026-08-04 12:17:34 -05:00
naturallaw777andarena-agent c447674ef9 docs: correct active development workflow
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-04 17:16:46 +00:00
Sovran Systems a77bfc5070 Merge pull request #379 from naturallaw777/arena/019fcda3-sovran-systemsos
docs: clarify GitHub dev-mirror workflow and Gitea stable home
2026-08-04 12:04:46 -05:00
naturallaw777andarena-agent 7b381d0387 docs: make repo references mirror-neutral for Gitea readers
The README and CONTRIBUTING are mirrored to both Gitea branches, so
GitHub-specific references now name the GitHub repository explicitly
instead of saying 'this repository'.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-04 17:03:52 +00:00
naturallaw777andarena-agent ec43d631d4 docs: clarify GitHub is the dev mirror of the Gitea stable repo
The GitHub repo mirrors Gitea's staging-dev branch; changes are tested
here and promoted to the stable branch on the self-hosted Gitea instance.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-04 16:39:27 +00:00
Sovran Systems 82a9952e63 Add Arean.Ai to AI tools used for development
Updated the mention of AI tools used in development.
2026-08-04 11:33:50 -05:00
arena-ai-coding-agent[bot] 1eb4be2c01 Merge pull request #378 from naturallaw777/arena/019fcd8d-sovran-systemsos
Move OS version badge inline right after the Hub title
2026-08-04 16:31:20 +00:00
naturallaw777andarena-agent 6f1edb938e Prefix OS version badge with 'v' (v1.0.5)
Keeps the modal-matching neutral pill styling; the badge now reads
"v1.0.5" as plain uniform text instead of the bare number.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-04 16:29:52 +00:00
naturallaw777andarena-agent bd96782229 Match OS version badge styling to service modal version badges
The header badge now uses the exact visual treatment of
.creds-title-version-badge from modals.css: neutral translucent pill
(rgba(255,255,255,0.06) background, 0.08 white border, 12px radius,
--text-secondary text, 0.72rem/600/2px-10px padding), no hover
animation, and a bare version number like the modals show (e.g.
"2.8.4") instead of the green pill with a "v" prefix.

The badge markup flattens to a single text span; the .version-label
and .version-number rules are removed.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-04 16:27:36 +00:00
naturallaw777andarena-agent cf23438a5c Place OS version badge inline right after the Hub title
Mirrors the service modal pattern where the version badge sits to the
right of the title: .title-group switches from a centered column
(badge under the title, PR #377) to a centered row with the badge
vertically centered directly after "Sovran_SystemsOS Hub".

flex-wrap keeps a graceful fallback: on very narrow screens the badge
wraps below the title, centered — the previous stacked look.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-04 16:21:39 +00:00
Sovran Systems d2c3fd066e Merge pull request #377 from naturallaw777/arena/019fcd82-sovran-systemsos
Center OS version badge under the Hub title
2026-08-04 11:05:33 -05:00
naturallaw777andarena-agent 1bfec68d77 Center OS version badge under the Hub title
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-04 16:04:56 +00:00
Sovran Systems bda1372a1d Merge pull request #376 from naturallaw777/arena/019fcd69-sovran-systemsos
Add 'About Bitcoin wallet entropy' section to README
2026-08-04 10:59:09 -05:00
naturallaw777andarena-agent 13c700ca44 Strengthen entropy section: add DYOR emphasis and hardware wallet verification note
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-04 15:51:39 +00:00
naturallaw777andarena-agent f6f87d6b03 Add 'About Bitcoin wallet entropy' section to README
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-04 15:48:01 +00:00
Sovran Systems 4f75117540 Merge pull request #375 from naturallaw777/arena/019fcd5a-sovran-systemsos
README: balance intro between Bitcoin sovereignty and sovereign computing
2026-08-04 10:27:07 -05:00
naturallaw777andarena-agent 2bb02e50d9 README: balance intro between Bitcoin sovereignty and sovereign computing
- Replace tagline with 'Bitcoin sovereignty. Sovereign computing. One system.'
- Open with both pillars as 'inseparable freedoms'
- Extend growth path beyond node/infrastructure to include private cloud and comms
- Remove standalone 'Privacy. Sovereignty. Bitcoin.' from intro (branding holds it at page close)

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-08-04 15:26:27 +00:00
naturallaw777 4d1c3565c9 docs: update CHANGELOG.md for v1.0.5 2026-08-04 09:33:09 -05:00
naturallaw777 3678aedc88 chore: bump VERSION to v1.0.5 for ISO naming 2026-08-04 09:33:09 -05:00
naturallaw777 991c790816 Nixpkgs Update 2026-08-04 09:07:09 -05:00
naturallaw777 a343afffaa Nixpkgs Upddate 2026-08-03 18:06:18 -05:00
Sovran Systems eeef81ffc7 Merge pull request #374 from naturallaw777/arena/019fb590-sovran-systemsos
Update LND REST Zeus Connect instructions and design to match NWC
2026-07-30 19:35:40 -05:00
naturallaw777andarena-agent bc6b3f0843 Update LND REST Zeus Connect instructions and design to match NWC exactly
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-31 00:33:22 +00:00
Sovran Systems f01e776f34 Merge pull request #373 from naturallaw777/arena/019fb564-sovran-systemsos
UI: Align LND REST and NWC Zeus connection instructions styling
2026-07-30 18:53:28 -05:00
naturallaw777andarena-agent a19fa53468 UI: Align LND REST and NWC Zeus connection instructions styling
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-30 23:44:20 +00:00
Sovran Systems baefca0c68 Merge pull request #372 from naturallaw777/arena/019fb535-sovran-systemsos
Coherent Zeus LND REST / NWC Zeus connect instructions
2026-07-30 18:28:41 -05:00
naturallaw777andarena-agent 9ed6040425 Make Zeus LND REST instructions coherent with NWC Zeus connect guide
- Fix QR hint for zeus-connect-setup.service to use correct LND REST steps
- Expand 'How to Connect' instructions with title/intro/steps/note
- Add matching styled guide block in credentials modal

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-30 23:26:37 +00:00
Sovran Systems ca17b381f6 Merge pull request #371 from naturallaw777/arena/019fb509-sovran-systemsos
Clarify Zeus NWC wallet setup
2026-07-30 17:37:27 -05:00
naturallaw777andarena-agent 3f74388359 Clarify Zeus NWC wallet setup
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-30 22:36:16 +00:00
Sovran Systems a814ed332f Merge pull request #370 from naturallaw777/arena/019fb4e9-sovran-systemsos
Manual Backup: match the system role (Desktop Only scope fix)
2026-07-30 16:50:21 -05:00
naturallaw777andarena-agent 94e4412e67 Make Manual Backup match the system role (Desktop Only scope)
The Manual Backup screen in the Hub always listed the Node / Server +
Desktop items (nix-bitcoin secrets, /var/lib system service data, and the
database/blockchain caveat), and the backup script mirrored /var/lib and
counted it in the free-space estimate even on the Desktop Only role.
Desktop Only systems run no server or Bitcoin services and have no
internal second data drive, so none of that applies.

Hub UI (support.js):
- 'What gets backed up' is role-aware: Desktop Only lists only the NixOS
  configuration (/etc/nixos) and home directory (/home)
- Database/blockchain note hidden on Desktop Only
- Intro copy corrected: external USB copy is a second location on
  Desktop Only (no internal second drive); third-location wording kept
  for Node / Server + Desktop

Backup script (sovran-hub-backup.sh):
- Desktop Only runs 2 stages (1/2 /etc/nixos, 2/2 /home); secrets and
  /var/lib stages no longer run on that role
- Free-space estimate skips /var/lib on Desktop Only
- BACKUP_MANIFEST.txt sources/exclusions/limitations/restore guidance
  and blockchain note are role-aware
- Completion message role-aware ('second, external location' on
  Desktop Only); header comments updated

Node and Server + Desktop behavior is unchanged. Added CHANGELOG entry.

Verified: bash -n / node --check, 6 role-detection cases, manifest
generation for both role groups (non-desktop output identical to
before), and simulated UI renders for all three roles.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-30 21:49:04 +00:00
Sovran Systems 670cfb9acd Merge pull request #369 from naturallaw777/arena/019fb396-sovran-systemsos
Seat new domains in running Caddy + unify Njal.la/router guidance across onboarding & feature modals
2026-07-30 11:52:43 -05:00
naturallaw777andarena-agent 2015a3ecd4 Reload Caddy immediately when a domain (or ACME email) is saved
The Caddyfile is generated at runtime by caddy-generate-config.service
from /var/lib/domains/*, but the generator only re-runs when caddy.service
starts fresh. Saving a domain while Caddy is already running therefore
never seats the new virtual host — no proxying and no ACME cert — and the
Hub's reachability check shows a misleading 'ports 80/443' router error
until the next reboot or rebuild.

api_domains_set and api_domains_set_email now restart the generator and
reload Caddy (ExecReload: caddy reload --force, no dropped connections)
right after saving. Entirely skipped when Caddy is inactive — e.g. Node
role before its first domain-based service is enabled — because the
rebuild that enables the service starts caddy.service for the first time,
runs the generator first (requiredBy), and seats the already-saved domain
on its own. Best-effort throughout: a domain save never fails because of
a Caddy reload issue.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-30 16:50:32 +00:00
naturallaw777andarena-agent 2622223033 Unify Njal.la + router port-forwarding guidance across onboarding and feature enable modals
Single source of truth (static/js/domain-prereqs.js) for the domain
prerequisite instructions so all three surfaces read identically:

- Server + Desktop first-boot onboarding wizard (step 3)
- Lightning Wallet Connections (NWC) enable modal (Node-only mode)
- BTCPay Server (web) enable modal (Node-only mode)

Both feature-enable flows share openDomainSetupModal(), which now drops
its role-branched intro and 'Option A/B' blocks in favor of the shared
renderers. All surfaces now consistently cover:

  1. A domain from Njal.la (account, subdomain-vs-separate-domain,
     Dynamic record with host-part-only Name field, auto-filled IP,
     DDNS curl command)
  2. Router access — forward ports 80 & 443 (TCP) to this computer's
     internal IP, once, for HTTPS/SSL (with CGNAT note)
  3. How to get Njal.la working, step by step

The reconfigure/troubleshooting modal gains the same router reminder,
since 'domain not reachable' is often the port forwarding rather than
DNS. domain-prereqs.js is loaded via asset_version cache busting and
ships automatically (installPhase copies the package wholesale).

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-30 16:45:36 +00:00
Sovran Systems d0ec6dd760 Merge pull request #368 from naturallaw777/arena/019fb370-sovran-systemsos
Enhance Lightning Wallet Connect modal UI/UX
2026-07-30 09:47:45 -05:00
naturallaw777andarena-agent 90b1c45a0d Enhance Lightning Wallet Connect modal with professional benefits grid and refined messaging
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-30 14:46:09 +00:00
Sovran Systems 0ac1529a1d Merge pull request #367 from naturallaw777/arena/019faeb9-sovran-systemsos
docs: real v1.0.4 changelog + auto-generated release notes in release script
2026-07-29 11:38:42 -05:00
naturallaw777andarena-agent 1b579271e5 docs: write real v1.0.4 changelog and auto-generate release notes in release script
- CHANGELOG.md: replace v1.0.4 placeholder section with the actual changes
  (Lightning Wallet Connections/NWC, Hub version badges, backup overhaul,
  automated releases, security hardening, fixes)
- release-stable.sh: generate categorized Keep-a-Changelog release notes
  from commits since the last tag (feat/fix/docs/security grouping),
  let the user review/edit before publishing, and use the same notes for
  CHANGELOG.md, the GitHub release, and the Gitea release
- Fix broken /api/ path in the v1.0.4 changelog release link
- Build Gitea API payload with jq/python so multi-line notes are JSON-safe

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-29 16:36:35 +00:00
Sovran Systems c6b4b89be3 Merge pull request #366 from naturallaw777/arena/019fae93-sovran-systemsos
fix(hub): resolve 'vdev' version badge and align it under the Hub title
2026-07-29 11:07:52 -05:00
naturallaw777andarena-agent a8ff366992 fix(hub): resolve 'vdev' version badge and align it under the Hub title
- The Hub header badge could render 'vdev' because the runtime
  /etc/nixos/VERSION lookup fell back to the literal string 'dev' when
  the file was missing (e.g. dev/test environments, or before the
  Nix-generated config carried a version at all).
- modules/core/sovran-hub.nix now reads the repo's VERSION file at
  Nix eval time and bakes a real semantic version (sovran_version)
  into the generated config.json and a VERSION file shipped with the
  package, so the Hub always has a solid value to display.
- server.py's _get_sovran_version() now reads that baked-in
  sovran_version first, and explicitly rejects a literal 'dev' value
  from any of its file-based fallbacks so the badge never shows
  'vdev' again.
- templates/index.html + header.css: wrapped the title and the
  version badge in a '.title-group' column so the version badge sits
  directly underneath 'Sovran_SystemsOS Hub', left-aligned with it.

Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-29 16:06:49 +00:00
Sovran Systems ddba59cce8 Merge pull request #365 from naturallaw777/arena/019fae73-sovran-systemsos
fix(scripts): auto-detect git remotes and clean backup files in release-stable.sh
2026-07-29 10:30:10 -05:00
naturallaw777andarena-agent e12e9ba01b fix(scripts): align release script with Gitea staging-dev and stable branch workflow
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-29 15:28:50 +00:00
naturallaw777andarena-agent 853c3edf92 fix(scripts): auto-detect git remotes and clean up temp files in release-stable.sh
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
2026-07-29 15:22:58 +00:00
naturallaw777 4a6ba3eacc docs: update CHANGELOG.md for v1.0.4 2026-07-29 10:19:00 -05:00
naturallaw777 537d4781f2 chore: bump VERSION to v1.0.4 for ISO naming 2026-07-29 10:19:00 -05:00
87 changed files with 10767 additions and 1807 deletions
+315 -1
View File
@@ -7,6 +7,320 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
--- ---
## [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 ## [1.0.3] - 2026-07-29
### Added ### Added
@@ -30,7 +344,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Integrated Sparrow Wallet, Bisq, and Bisq 2 - Integrated Sparrow Wallet, Bisq, and Bisq 2
- Comprehensive Sovran Hub for service management - Comprehensive Sovran Hub for service management
- NixOS-based operating system with privacy and sovereignty focus - NixOS-based operating system with privacy and sovereignty focus
- Support for Bitcoin Knots + BIP110, Electrs, LND, Ride The Lightning, BTCPay Server, and more - 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 - Server + Desktop hybrid mode with Matrix, Nextcloud, VaultWarden, and other self-hosted services
- Automated installer with graphical GNOME desktop - Automated installer with graphical GNOME desktop
- Tor integration and onion services for all major components - Tor integration and onion services for all major components
+34 -5
View File
@@ -2,13 +2,35 @@
First off, thank you for considering contributing to Sovran_SystemsOS! 🎉 First off, thank you for considering contributing to Sovran_SystemsOS! 🎉
## This Github Repo ## Development and Release Repositories
This repo is for the development of Sovran_SystemsOS and serves as its connection to the GitHub ecosystem. The main repo for Sovran_SystemsOS is hosted at https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable. All activity in this GitHub repo is tested and eventually mirrored to the main repo on Gitea, as the ethos of Sovran_SystemsOS is self-sovereignty. 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.
Please note: this repo may contain new features and code not yet in the stable branch on Gitea, and this code is not fully tested. 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.
Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used Copilot to work through significant coding challenges and troubleshooting hurdles. We will continue to use AI to help keep Sovran_SystemsOS stable and maintained. 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? ## How Can I Contribute?
@@ -26,7 +48,7 @@ Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used C
### 🔧 Submitting Code Changes ### 🔧 Submitting Code Changes
#### 1. Fork the Repository #### 1. Fork the Repository
Click the "Fork" button at the top right of this repository. Click the "Fork" button at the top right of the GitHub repository.
#### 2. Clone Your Fork #### 2. Clone Your Fork
```bash ```bash
@@ -70,6 +92,13 @@ git push origin feature/your-feature-name
- **Do not push directly to `main`.** Always use a feature branch and open a PR. - **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. - **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 ## Code Style
- Follow the existing patterns in the codebase - Follow the existing patterns in the codebase
+196 -71
View File
@@ -4,44 +4,54 @@
# Sovran_SystemsOS # Sovran_SystemsOS
### Your Bitcoin life. Your keys. Your node. Your machine. ### Bitcoin sovereignty. Sovereign computing. One system.
Sovran_SystemsOS is a free and open-source operating system for **Bitcoin Sovran_SystemsOS is a free and open-source operating system built for two
self-custody** and **digital sovereignty**. Hold your own keys, trade inseparable freedoms: **Bitcoin self-custody** and **sovereign computing**.
peer-to-peer, and verify your own money with your own node. Then extend the Hold your own keys, trade peer-to-peer, and verify your own money with your
same ownership to the rest of your digital life: your files, communications, own node. Then claim that same uncompromising ownership over the rest of your
passwords, and websites, all on hardware you control. digital life — your files, communications, passwords, and websites all on
hardware you control, running auditable open-source software you can trust.
Every installation is a private [NixOS](https://nixos.org) desktop with Every installation is a private [NixOS](https://nixos.org) desktop with
[Sparrow Wallet](https://sparrowwallet.com), [Bisq](https://bisq.network), and [Sparrow Wallet](https://sparrowwallet.com), [Bisq](https://bisq.network), and
[Bisq 2](https://github.com/bisq-network/bisq2) ready to use. Move beyond [Bisq 2](https://github.com/bisq-network/bisq2) ready to use. Move beyond
custodial exchanges from the first boot, and grow into your own Bitcoin and custodial exchanges from the first boot, and grow into your own Bitcoin node,
Lightning infrastructure when you are ready. Lightning infrastructure, private cloud, and communications platform when you
are ready.
**Privacy. Sovereignty. Bitcoin.**
[Visit the Website](https://sovransystems.com) · [Visit the Website](https://sovransystems.com) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.3.iso) · [Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS.iso.sha256) · [Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso.sha256) ·
[Build from Source](#build-from-source) [Build from Source](#build-from-source)
<img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" /> <img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
*Bitcoin sovereignty from the first boot.* *Bitcoin sovereignty from the first boot.*
</div> </div>
> **📌 Active development on GitHub `main` / Gitea `staging-dev` — releases on
> [Gitea `stable`](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable).**
> See [Development workflow](#development-workflow) for details.
--- ---
## Contents ## Contents
- [Why Sovran_SystemsOS?](#why-sovran_systemsos) - [Why Sovran_SystemsOS?](#why-sovran_systemsos)
- [Try it first in a virtual machine](#try-it-first-in-a-virtual-machine)
- [What is included](#what-is-included) - [What is included](#what-is-included)
- [Three modes](#three-modes) - [Three modes](#three-modes)
- [Use it your way](#use-it-your-way) - [Use it your way](#use-it-your-way)
- [The Sovran Hub](#the-sovran-hub) - [The Sovran Hub](#the-sovran-hub)
- [Install Sovran_SystemsOS](#install-sovran_systemsos) - [Install Sovran_SystemsOS](#install-sovran_systemsos)
- [For developers](#for-developers) - [For developers](#for-developers)
- [Development workflow](#development-workflow)
- [Build from source](#build-from-source)
- [Publishing a release](#publishing-a-release)
- [About Bitcoin wallet entropy](#about-bitcoin-wallet-entropy)
- [Security approach](#security-approach) - [Security approach](#security-approach)
- [Acknowledgements](#acknowledgements) - [Acknowledgements](#acknowledgements)
- [License](#license) · [Contributing](#contributing) - [License](#license) · [Contributing](#contributing)
@@ -63,8 +73,7 @@ Sovran_SystemsOS solves both with one operating system:
- **Buy and sell Bitcoin peer-to-peer** with Bisq and Bisq 2. No central - **Buy and sell Bitcoin peer-to-peer** with Bisq and Bisq 2. No central
company holds user funds, and no exchange account stands between buyers and company holds user funds, and no exchange account stands between buyers and
sellers. sellers.
- **Verify your own Bitcoin** with a full node: [Bitcoin - **Verify your own Bitcoin** with a full node: [Bitcoin Core](https://bitcoin.org) and
Knots](https://bitcoinknots.org) and
[Electrs](https://github.com/romanz/electrs), so your wallets connect to [Electrs](https://github.com/romanz/electrs), so your wallets connect to
*your* node instead of a stranger's. *your* node instead of a stranger's.
- **Use Lightning** with [LND](https://github.com/lightningnetwork/lnd) and - **Use Lightning** with [LND](https://github.com/lightningnetwork/lnd) and
@@ -98,6 +107,40 @@ and a reproducible, auditable NixOS foundation.
--- ---
## Try it first in a virtual machine
**Curious, but not ready to replace your current operating system?** Start with
Sovran_SystemsOS in a virtual machine (VM). A VM runs Sovran_SystemsOS in a
window on your existing Windows, macOS, or Linux computer, using a virtual disk
file instead of your computer's internal drive. You can explore the desktop,
Sovran Hub, Sparrow, Bisq, and the installation experience before changing how
you use any physical machine.
This is a low-commitment way to decide whether Sovran_SystemsOS is right for
you:
- **Keep your current OS.** Closing or deleting the VM leaves the host operating
system in place.
- **Learn at your own pace.** Familiarize yourself with the desktop and tools
without needing to make it your daily computer on day one.
- **Choose your next step with confidence.** When you are ready, install it on
a dedicated computer, or keep using the VM as a learning environment.
You can use the same ISO with [VirtualBox](https://www.virtualbox.org), VMware,
QEMU/KVM, Proxmox, and similar x86_64 VM software. For a first look, select
**Desktop Only**, allocate at least **8 GB RAM**, and create a **256 GB or
larger dynamically allocated virtual disk**. The full VM setup and installer
requirements are in [Installing in a virtual machine](#installing-in-a-virtual-machine-optional).
> **A VM is for evaluation and learning, not a substitute for a dedicated,
> hardened setup.** Do not use a trial VM to hold meaningful Bitcoin, recovery
> phrases, passwords, or other sensitive data. Avoid attaching physical drives
> to the VM, and be deliberate about shared folders, clipboard sharing, and
> network settings. The installer only changes the disk you select, but you
> should always review VM disk selections before confirming an install.
---
## What is included ## What is included
Depending on the selected mode and enabled features, Sovran_SystemsOS brings Depending on the selected mode and enabled features, Sovran_SystemsOS brings
@@ -106,13 +149,13 @@ presents and manages the features available on your system.
### Your money — Bitcoin sovereignty ### Your money — Bitcoin sovereignty
- Bitcoin Knots, Electrs, and Tor integration - Bitcoin Core, Electrs, and Tor integration
- LND and Ride The Lightning, with [Alby Hub](https://albyhub.com) for Nostr - LND and Ride The Lightning, with [Alby Hub](https://albyhub.com) for Nostr
Wallet Connect (NWC) connections Wallet Connect (NWC) connections
- BTCPay Server - BTCPay Server
- Sparrow Wallet, Bisq, and Bisq 2, with automatic wallet-to-node connections - Sparrow Wallet, Bisq, and Bisq 2, with automatic wallet-to-node connections
- Optional: a self-hosted [Mempool](https://github.com/mempool/mempool) - Optional: a self-hosted [Mempool](https://github.com/mempool/mempool)
explorer, or Bitcoin Core in place of Knots explorer
Run your own Bitcoin infrastructure. Verify your own money. Trust no one. Run your own Bitcoin infrastructure. Verify your own money. Trust no one.
@@ -142,7 +185,7 @@ hardware you control.
### Your desktop ### Your desktop
- [GNOME](https://www.gnome.org) desktop - [GNOME](https://www.gnome.org) desktop
- [Brave](https://brave.com) and - [Brave Origin](https://brave.com/origin/) and
[Firefox](https://www.mozilla.org/firefox) browsers [Firefox](https://www.mozilla.org/firefox) browsers
- File management, email, calendar, and office applications - File management, email, calendar, and office applications
- System monitoring and administration tools - System monitoring and administration tools
@@ -158,7 +201,7 @@ Bitcoin and self-hosting infrastructure runs on the machine.
| Mode | Best for | What you get | | Mode | Best for | What you get |
|---|---|---| |---|---|---|
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use | | **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Knots, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections | | **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services | | **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services |
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free **Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
@@ -238,6 +281,10 @@ From one place, the Hub helps you:
- Reach your Bitcoin tools, private cloud, and communications - Reach your Bitcoin tools, private cloud, and communications
- Perform supported system operations without everyday terminal commands - Perform supported system operations without everyday terminal commands
<img src="assets/sovran-hub-screenshot.webp" alt="The Sovran Hub dashboard" width="800" />
*The Sovran Hub: manage your private infrastructure from one place.*
### Example home setup ### Example home setup
```text ```text
@@ -298,8 +345,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
### 1. Download the ISO and checksum ### 1. Download the ISO and checksum
- [Download Sovran_SystemsOS.iso](https://downloads.sovransystems.com/Sovran_SystemsOS.iso) - [Download Sovran_SystemsOS-1.1.2.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso)
- [Download Sovran_SystemsOS.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS.iso.sha256) - [Download Sovran_SystemsOS-1.1.2.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso.sha256)
The download may take some time. Do not rename or modify the ISO before The download may take some time. Do not rename or modify the ISO before
verifying it, and keep both files in the same folder. verifying it, and keep both files in the same folder.
@@ -317,16 +364,16 @@ checksum exactly.
Open a terminal in the download folder and run: Open a terminal in the download folder and run:
```bash ```bash
sha256sum --check Sovran_SystemsOS.iso.sha256 sha256sum --check Sovran_SystemsOS-1.1.2.iso.sha256
``` ```
A successful comparison reports: A successful comparison reports:
```text ```text
Sovran_SystemsOS.iso: OK Sovran_SystemsOS-1.1.2.iso: OK
``` ```
You can also run `sha256sum Sovran_SystemsOS.iso` and compare the output You can also run `sha256sum Sovran_SystemsOS-1.1.2.iso` and compare the output
against the checksum file manually. against the checksum file manually.
</details> </details>
@@ -337,11 +384,11 @@ against the checksum file manually.
Open Terminal in the download folder and run: Open Terminal in the download folder and run:
```bash ```bash
shasum -a 256 Sovran_SystemsOS.iso shasum -a 256 Sovran_SystemsOS-1.1.2.iso
``` ```
Compare the value shown in Terminal with the value inside Compare the value shown in Terminal with the value inside
`Sovran_SystemsOS.iso.sha256`. `Sovran_SystemsOS-1.1.2.iso.sha256`.
</details> </details>
@@ -351,7 +398,7 @@ Compare the value shown in Terminal with the value inside
Open PowerShell in the download folder and run: Open PowerShell in the download folder and run:
```powershell ```powershell
Get-FileHash .\Sovran_SystemsOS.iso -Algorithm SHA256 Get-FileHash .\Sovran_SystemsOS-1.1.2.iso -Algorithm SHA256
``` ```
Compare the value under `Hash` with the published checksum. Compare the value under `Hash` with the published checksum.
@@ -366,7 +413,7 @@ match exactly.
1. Download and install [Balena Etcher](https://etcher.balena.io), then 1. Download and install [Balena Etcher](https://etcher.balena.io), then
connect the USB drive. connect the USB drive.
2. Choose **Flash from file** and select `Sovran_SystemsOS.iso`. 2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.2.iso`.
3. Choose **Select target**, select the USB drive, and review your selection 3. Choose **Select target**, select the USB drive, and review your selection
carefully. carefully.
4. Choose **Flash** and wait for the writing and verification process to 4. Choose **Flash** and wait for the writing and verification process to
@@ -390,6 +437,26 @@ installer. Do not format the drive.
If the normal operating system starts instead, restart and try the boot-menu If the normal operating system starts instead, restart and try the boot-menu
key again. key again.
### Installing in a virtual machine (optional)
Sovran_SystemsOS can be tested in VirtualBox, VMware, QEMU/KVM, Proxmox, and
similar x86_64 virtual machines. Use production-like resources where possible:
- Allocate **8 GB RAM or more** for Desktop Only. Node and Server + Desktop
should follow the normal 16 GB / 32 GB recommendations.
- Create a **256 GB or larger virtual OS disk**. Thin-provisioned / dynamically
allocated disks are fine; they do not consume the full size immediately.
- Use NAT or bridged networking with internet access before opening the
installer.
- UEFI/EFI firmware is preferred. In UEFI VMs, the installer avoids depending
on VM NVRAM boot-entry writes. If a VM boots the ISO in legacy BIOS mode,
the installer automatically switches the installed system to GRUB.
- For Node or Server + Desktop, attach a **second 2 TB virtual data disk**.
If you only attach one disk, choose **Desktop Only**.
- Present the install target as a normal virtual disk such as VirtIO, SATA,
SCSI, or NVMe. USB-attached target disks are intentionally hidden by the
installer to avoid erasing the installer USB by mistake.
### 5. Install ### 5. Install
Follow the on-screen installer. Before confirming: Follow the on-screen installer. Before confirming:
@@ -443,21 +510,34 @@ setup, supported hardware, and Royal Membership.
## For developers ## For developers
Sovran_SystemsOS combines the reproducibility of [NixOS](https://nixos.org), Sovran_SystemsOS combines [NixOS](https://nixos.org), an in-repository
the Bitcoin service modules of Bitcoin and Lightning stack, desktop packages from
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), the desktop Bitcoin
packages provided by
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
Sovran Hub into a complete Bitcoin operating system. The operating system Sovran Hub. The Bitcoin modules under `modules/bitcoin/` were adapted from
configuration, installer, Hub, desktop integration, Bitcoin services, and [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) and are now maintained
optional self-hosting services are all maintained in this repository. here. Builds no longer import or fetch nix-bitcoin. Legacy `nix-bitcoin.*`
option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
### Development workflow
| Branch | Host | Purpose |
|---------------|---------------------------------------------------|-----------------------------|
| `main` | [GitHub](https://github.com/naturallaw777/Sovran_SystemsOS) | Active development & PRs |
| `staging-dev` | [Gitea](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS) | Synced with `main` |
| `stable` | [Gitea](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable) | Tested, release-ready builds |
**Flow:** develop → sync `main``staging-dev` → promote to `stable`
> ⚠️ `main` and `staging-dev` may contain unreleased or less-tested code. For
> stable, audited code, use Gitea
> [`stable`](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable).
### Technology ### Technology
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes) - [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
for reproducible system configuration for declarative, pinned system configuration
- [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) for declarative - `modules/bitcoin/` for the in-repository Bitcoin and Lightning stack
Bitcoin and Lightning services - `packages/` for Sovran-maintained package definitions and patches
- [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the - [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the
Sparrow, Bisq, and Bisq 2 packages Sparrow, Bisq, and Bisq 2 packages
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com) - [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
@@ -472,16 +552,46 @@ optional self-hosting services are all maintained in this repository.
You need a system with Nix installed and flakes enabled. You need a system with Nix installed and flakes enabled.
Clone the **stable** branch from Gitea (recommended for builds):
```bash
git clone -b stable https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS.git
cd Sovran_SystemsOS
```
Or clone the **active development** line from GitHub:
```bash ```bash
git clone https://github.com/naturallaw777/Sovran_SystemsOS.git git clone https://github.com/naturallaw777/Sovran_SystemsOS.git
cd Sovran_SystemsOS cd Sovran_SystemsOS
```
Then build the installer ISO:
```bash
nix build \ nix build \
.#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
``` ```
The resulting build output will be available through the `result` symlink. The resulting build output will be available through the `result` symlink.
### Publishing a release
Releases are managed with `scripts/release-stable.sh` and `scripts/upload-cdn.sh`:
1. Run the stable release script to bump version, tag, update changelog, and push/create releases:
```bash
./scripts/release-stable.sh [version]
```
2. Build the installer ISO:
```bash
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
```
3. Copy, checksum, verify, and optionally upload to CDN:
```bash
./scripts/upload-cdn.sh --upload
```
### Common development commands ### Common development commands
Run these commands from the flake root. Run these commands from the flake root.
@@ -517,11 +627,14 @@ sudo nixos-rebuild switch --rollback
| `flake.nix` | Declares flake inputs, the running system, and installer outputs | | `flake.nix` | Declares flake inputs, the running system, and installer outputs |
| `flake.lock` | Pins dependencies for reproducible builds | | `flake.lock` | Pins dependencies for reproducible builds |
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration | | `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
| `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features | | `modules/` | Core modules, self-hosted services, and optional features |
| `modules/bitcoin/` | In-repository Bitcoin and Lightning service modules |
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior | | `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface | | `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
| `scripts/` | Automated release, build, and CDN upload utility scripts |
| `iso/` | Installer configuration, installer code, and installer assets | | `iso/` | Installer configuration, installer code, and installer assets |
| `packages/` | Custom package sources and patches (for example, Alby Hub) | | `packages/` | Sovran-maintained package definitions and patches |
| `tests/` | Security and Nix integration checks |
| `assets/` | Documentation images | | `assets/` | Documentation images |
| `custom.template.nix` | Template for local features and service overrides | | `custom.template.nix` | Template for local features and service overrides |
@@ -578,7 +691,6 @@ rebuilds the machine into the selected declarative state.
| Shared credentials | `modules/credentials.nix` | | Shared credentials | `modules/credentials.nix` |
| Bitcoin and Lightning stack | `modules/bitcoinecosystem.nix` | | Bitcoin and Lightning stack | `modules/bitcoinecosystem.nix` |
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` | | Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
| Optional Bitcoin Core in place of Knots | `modules/bitcoin-core.nix` |
| Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` | | Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` |
| Matrix Synapse | `modules/synapse.nix` | | Matrix Synapse | `modules/synapse.nix` |
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` | | Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
@@ -593,31 +705,37 @@ production environment.
--- ---
## About Bitcoin wallet entropy
Wallet recovery words control the funds. Never share them with a website,
support technician, cloud service, or chat application.
For meaningful balances, prefer a well-reviewed hardware signer and follow its
verified backup process. A BIP39 passphrase is optional, advanced protection;
it is not a replacement for the recovery words. If you use one, back it up
separately—losing either item can make the wallet unrecoverable.
Keep durable offline backups in separate secure locations. Test recovery before
relying on a wallet, and begin with a small amount.
---
## Security approach ## Security approach
Sovran_SystemsOS is designed around local ownership and explicit control. Sovran_SystemsOS uses layered controls:
Its security foundations include: - Pinned flake inputs and hash-pinned source archives
- Bitcoin and Lightning modules maintained in this repository
- Firewall enabled; public SSH and remote desktop disabled by default
- Separate service users, systemd sandboxing, and loopback bindings where practical
- Tor enforcement for supported Bitcoin services
- Restricted, time-limited support access with scoped `sudo`
- Operator-controlled public service exposure
- Reproducible builds from pinned flake inputs See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
- Firewall enabled by default and operator guidance. No operating system can protect funds after recovery
- Public SSH disabled by default words, administrator credentials, or the root account are compromised. Apply
- Remote desktop disabled by default updates and keep tested offline backups.
- Hub authentication
- Local-network Hub access through `sovransystemsos.local`
- Tor integration for the Bitcoin stack
- User-controlled service exposure
- Declarative system configuration
- Restricted technical-support access
- Auditable open-source code
No operating system can guarantee complete security. Users should still apply
updates, protect credentials, maintain backups, secure their local network,
and review any services they choose to expose publicly.
Bitcoin users must also securely back up wallet seed phrases, descriptors,
channel backups, and other recovery information. Never store your only wallet
backup on the same computer that holds the wallet.
--- ---
@@ -635,11 +753,15 @@ Sovran_SystemsOS would not have the same reliability, transparency, or reproduci
### nix-bitcoin ### nix-bitcoin
Special thanks go to the [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) project and its [contributors](https://github.com/fort-nix/nix-bitcoin/graphs/contributors). The in-repository Bitcoin stack began with code adapted from
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), primarily from commit
[`360e30f`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
It has since been narrowed to Sovran's supported services and is maintained in
this repository. nix-bitcoin is no longer a flake input or build dependency.
nix-bitcoin provides the declarative foundation for building and operating Bitcoin and Lightning services on NixOS. Its work makes it possible to configure complex Bitcoin infrastructure—including nodes, Electrs, Lightning, Tor integration, and related services—in a reproducible and auditable way. We remain grateful to the nix-bitcoin contributors for the declarative and
security-focused foundation. Its MIT notice is retained in
Sovran_SystemsOS builds upon that foundation to make this infrastructure approachable through an integrated desktop, installer, and Sovran Hub. [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
### Emmanuel Rosa and btc-clients-nix ### Emmanuel Rosa and btc-clients-nix
@@ -657,7 +779,6 @@ This work helps make it possible for Sovran_SystemsOS to deliver self-custody an
Sovran_SystemsOS also depends on the work of the developers and communities behind: Sovran_SystemsOS also depends on the work of the developers and communities behind:
- [Bitcoin Knots](https://github.com/bitcoinknots/bitcoin)
- [Bitcoin Core](https://github.com/bitcoin/bitcoin) - [Bitcoin Core](https://github.com/bitcoin/bitcoin)
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow) - [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
- [Bisq](https://github.com/bisq-network/bisq) - [Bisq](https://github.com/bisq-network/bisq)
@@ -714,15 +835,19 @@ license.
> Individual upstream applications, packages, artwork, fonts, and other > Individual upstream applications, packages, artwork, fonts, and other
> components included with or built by Sovran_SystemsOS may have their own > components included with or built by Sovran_SystemsOS may have their own
> licenses and copyright holders. The AGPL-3.0 license for this repository does > licenses and copyright holders. The AGPL-3.0 license for this repository does
> not replace the licenses of independent upstream projects. > not replace those licenses.
Read the complete license terms in [`LICENSE`](LICENSE). Read [`LICENSE`](LICENSE) and
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
--- ---
## Contributing ## Contributing
We welcome contributions! Please read our [Contributing Guidelines](CONTRIBUTING.md) before submitting a pull request. We welcome contributions! The
[GitHub repository](https://github.com/naturallaw777/Sovran_SystemsOS) is the
primary location for collaboration. Please read our
[Contributing Guidelines](CONTRIBUTING.md) before submitting a pull request.
<div align="center"> <div align="center">
@@ -731,7 +856,7 @@ We welcome contributions! Please read our [Contributing Guidelines](CONTRIBUTING
## Privacy. Sovereignty. Bitcoin. ## Privacy. Sovereignty. Bitcoin.
[Visit Sovran Systems](https://sovransystems.com) · [Visit Sovran Systems](https://sovransystems.com) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS.iso) · [Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso) ·
[View the License](LICENSE) [View the License](LICENSE)
</div> </div>
+101
View File
@@ -0,0 +1,101 @@
# Security Policy
## Supported versions
| Release | Supported |
|---|:---:|
| Latest `1.0.x` 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 intended for a
trusted local network and must not be port-forwarded to the internet. Public
services, DDNS, software updates, and optional third-party relays require
external networks and are outside a “fully offline” model.
The local Hub currently uses HTTP. Authentication does not encrypt local network
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
### In-repository Bitcoin stack
Bitcoin and Lightning modules are maintained under `modules/bitcoin/`. They were
adapted from nix-bitcoin, but Sovran builds do not import or fetch nix-bitcoin.
The `nix-bitcoin.*` option 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, NixVim, btc-clients-nix, upstream source
archives, and any configured binary cache. Keeping the Bitcoin modules in this
repository reduces an external dependency; it does not remove supply-chain
risk.
The Hub integrity check verifies Nix store contents and compares the running
system with a build from local `/etc/nixos`. It does not authenticate the release
publisher or protect against an attacker who already controls root and can
change both the system and local configuration.
### Access and service isolation
- Firewall enabled by default
- Public SSH 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
Tor reduces network exposure for configured Bitcoin services. It is not a
guarantee against every IP leak, application bug, or traffic-analysis attack.
### Restricted support access
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.
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.
+33
View File
@@ -0,0 +1,33 @@
# Third-Party Notices
## nix-bitcoin
Portions of `modules/bitcoin/` and selected package definitions were adapted
from [fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
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 -1
View File
@@ -1 +1 @@
1.0.3 1.1.2
-244
View File
@@ -1,244 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 18 KiB

@@ -3,10 +3,18 @@
# Backs up Sovran_SystemsOS data to an external USB hard drive using rsync. # Backs up Sovran_SystemsOS data to an external USB hard drive using rsync.
# Designed for the Hub web UI (no GUI dependencies). # Designed for the Hub web UI (no GUI dependencies).
# #
# Your Sovran Pro already backs up your data automatically to its # On Server + Desktop and Node systems, your Sovran Pro already backs up
# internal second drive (BTCEcoandBackup at /run/media/Second_Drive). # your data automatically to its internal second drive (BTCEcoandBackup at
# This script creates an additional copy on an external USB drive — # /run/media/Second_Drive); this script stores a copy in a third location.
# storing your data in a third location for maximum protection. # 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 # The external drive must be formatted as ext4. Files are stored as
# directly browsable files under Sovran_SystemsOS_Backup/current/. # directly browsable files under Sovran_SystemsOS_Backup/current/.
@@ -344,6 +352,19 @@ case "$ROLE" in
esac esac
log "Detected role: $ROLE_LABEL" 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 ────────────────────────────────────────── # ── Detect target drive ──────────────────────────────────────────
if [[ -n "${BACKUP_TARGET:-}" ]]; then if [[ -n "${BACKUP_TARGET:-}" ]]; then
@@ -385,11 +406,13 @@ log "Backup destination: $BACKUP_DIR"
ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos) ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos)
HOME_BYTES=$(estimate_path_bytes /home --exclude='*/.cache' --exclude='*/.local/share/Trash' --exclude='*/Trash') 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 SECRETS_BYTES=0
VAR_LIB_BYTES=0
if [[ "$ROLE" != "desktop" ]]; then if [[ "$ROLE" != "desktop" ]]; then
SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets) SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets)
fi
VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \ VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \
--exclude='postgresql' \ --exclude='postgresql' \
--exclude='mysql' \ --exclude='mysql' \
@@ -400,6 +423,7 @@ VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \
--exclude='*/logs' \ --exclude='*/logs' \
--exclude='*/cache' \ --exclude='*/cache' \
--exclude='*/tmp') --exclude='*/tmp')
fi
ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES )) ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES ))
# Require 20% growth headroom plus a fixed 1 GiB safety margin. # Require 20% growth headroom plus a fixed 1 GiB safety margin.
@@ -418,10 +442,10 @@ log "Free space on drive: ${FREE_GB} GB"
(( FREE_BYTES >= REQUIRED_BYTES )) || \ (( FREE_BYTES >= REQUIRED_BYTES )) || \
fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)." fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)."
# ── Stage 1/4: NixOS configuration ────────────────────────────── # ── Stage 1: NixOS configuration ────────────────────────────────
log "" log ""
log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────" log "── Stage 1/${TOTAL_STAGES}: NixOS configuration (/etc/nixos) ──────────────"
if [[ -d /etc/nixos ]]; then if [[ -d /etc/nixos ]]; then
sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/etc/nixos/" sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/etc/nixos/"
log "Stage 1 complete." log "Stage 1 complete."
@@ -429,26 +453,30 @@ else
log "WARNING: /etc/nixos not found — skipping." log "WARNING: /etc/nixos not found — skipping."
fi fi
# ── Stage 2/4: Secrets ────────────────────────────────────────── # ── 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.
if [[ "$ROLE" != "desktop" ]]; then
log "" log ""
log "── Stage 2/4: Secrets (/etc/nix-bitcoin-secrets) ───────────" log "── Stage 2/${TOTAL_STAGES}: Secrets (/etc/nix-bitcoin-secrets) ───────────"
if [[ "$ROLE" == "desktop" ]]; then if [[ -e /etc/nix-bitcoin-secrets ]]; then
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
elif [[ -e /etc/nix-bitcoin-secrets ]]; then
sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/" sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/"
else else
log "(not found: /etc/nix-bitcoin-secrets — skipping)" log "(not found: /etc/nix-bitcoin-secrets — skipping)"
fi fi
log "Stage 2 complete." log "Stage 2 complete."
fi
# ── Stage 3/4: Home directory ─────────────────────────────────── # ── 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 # Rsync exit code 24 (vanished source files) is treated as nonfatal here
# because the desktop may be active and files can disappear between the # because the desktop may be active and files can disappear between the
# directory scan and the copy. All other nonzero exit codes remain fatal. # directory scan and the copy. All other nonzero exit codes remain fatal.
log "" log ""
log "── Stage 3/4: Home directory (/home) ───────────────────────" log "── Stage ${HOME_STAGE_NUM}/${TOTAL_STAGES}: Home directory (/home) ───────────────────────"
if [[ -d /home ]]; then if [[ -d /home ]]; then
sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \ sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \
--exclude='.cache/' \ --exclude='.cache/' \
@@ -463,22 +491,27 @@ if [[ -d /home ]]; then
--exclude='.config/chromium/*/Code Cache/' \ --exclude='.config/chromium/*/Code Cache/' \
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \ --exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
--exclude='.config/BraveSoftware/Brave-Browser/*/Code 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='.local/share/baloo/' \
--exclude='.thumbnails/' \ --exclude='.thumbnails/' \
--exclude='.xsession-errors' \ --exclude='.xsession-errors' \
--exclude='.xsession-errors.old' --exclude='.xsession-errors.old'
log "Stage 3 complete." log "Stage ${HOME_STAGE_NUM} complete."
else else
log "WARNING: /home not found — skipping." log "WARNING: /home not found — skipping."
fi fi
# ── Stage 4/4: System data ────────────────────────────────────── # ── 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 # PostgreSQL/MariaDB raw database directories are excluded. Application
# databases must be backed up separately with native database tools. # databases must be backed up separately with native database tools.
# Bitcoin/Electrs data are excluded; they live on the internal second drive. # Bitcoin/Electrs data are excluded; they live on the internal second drive.
if [[ "$ROLE" != "desktop" ]]; then
log "" log ""
log "── Stage 4/4: System data (/var/lib) ───────────────────────" log "── Stage 4/${TOTAL_STAGES}: System data (/var/lib) ───────────────────────"
if [[ -d /var/lib ]]; then if [[ -d /var/lib ]]; then
sync_tree "/var/lib" no /var/lib/ "$BACKUP_DIR/var/lib/" \ sync_tree "/var/lib" no /var/lib/ "$BACKUP_DIR/var/lib/" \
--exclude='postgresql/' \ --exclude='postgresql/' \
@@ -494,6 +527,7 @@ if [[ -d /var/lib ]]; then
else else
log "WARNING: /var/lib not found — skipping." log "WARNING: /var/lib not found — skipping."
fi fi
fi
# ── Generate manifest ──────────────────────────────────────────── # ── Generate manifest ────────────────────────────────────────────
@@ -517,23 +551,29 @@ MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
echo "- /etc/nix-bitcoin-secrets (when present) → current/etc/nix-bitcoin-secrets/" echo "- /etc/nix-bitcoin-secrets (when present) → current/etc/nix-bitcoin-secrets/"
fi fi
echo "- /home → current/home/" echo "- /home → current/home/"
if [[ "$ROLE" != "desktop" ]]; then
echo "- /var/lib → current/var/lib/" echo "- /var/lib → current/var/lib/"
fi
echo "" echo ""
echo "Exclusions:" echo "Exclusions:"
if [[ "$ROLE" != "desktop" ]]; then
echo "- /var/lib/postgresql (PostgreSQL raw database files — not included)" 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/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/bitcoind (Bitcoin blockchain — excluded; lives on internal second drive)"
echo "- /var/lib/electrs (Electrs index — 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 "- /run/media/Second_Drive (internal second drive — never traversed)"
echo "- /var/lib/*/log, /var/lib/*/logs, /var/lib/*/cache, /var/lib/*/tmp" 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 "- Browser disk caches, thumbnail caches, trash directories, X session error logs"
echo "" echo ""
echo "Important limitations:" echo "Important limitations:"
if [[ "$ROLE" != "desktop" ]]; then
echo "- PostgreSQL and MariaDB/MySQL application databases are NOT included in this" 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 " backup. If you use Nextcloud, Matrix/Synapse, or other database-backed"
echo " applications, their data must be backed up separately using native tools." 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 "- Bitcoin blockchain data and Electrs indexes are NOT included; they are"
echo " reconstructable or stored on the internal second drive." echo " reconstructable or stored on the internal second drive."
fi
echo "- This is a live file-level mirror, not a transactional database backup." 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 " Files being written during the backup may be in an inconsistent state."
echo "" echo ""
@@ -542,7 +582,9 @@ MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
echo "- To restore a directory:" echo "- To restore a directory:"
echo " sudo rsync -aAXH --numeric-ids current/etc/nixos/ /etc/nixos/" echo " sudo rsync -aAXH --numeric-ids current/etc/nixos/ /etc/nixos/"
echo " sudo rsync -aAXH --numeric-ids current/home/ /home/" echo " sudo rsync -aAXH --numeric-ids current/home/ /home/"
if [[ "$ROLE" != "desktop" ]]; then
echo " sudo rsync -aAXH --numeric-ids current/var/lib/ /var/lib/" echo " sudo rsync -aAXH --numeric-ids current/var/lib/ /var/lib/"
fi
echo "- To copy individual files:" echo "- To copy individual files:"
echo " sudo cp -a current/home/username/ /home/username/" echo " sudo cp -a current/home/username/ /home/username/"
echo "- When restoring /etc/nixos to replacement hardware, regenerate" echo "- When restoring /etc/nixos to replacement hardware, regenerate"
@@ -556,10 +598,12 @@ MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
echo "- $warning" echo "- $warning"
done done
fi fi
if [[ "$ROLE" != "desktop" ]]; then
echo "" echo ""
echo "Note: Bitcoin blockchain and Electrs index data are intentionally excluded" 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 "from manual external backup because they already live on the internal second drive"
echo "(/run/media/Second_Drive) and are reconstructable/internal-backup data." echo "(/run/media/Second_Drive) and are reconstructable/internal-backup data."
fi
} > "$MANIFEST_FILE" } > "$MANIFEST_FILE"
log "Manifest written to $MANIFEST_FILE" log "Manifest written to $MANIFEST_FILE"
@@ -576,7 +620,11 @@ if [[ "${#RSYNC_WARNINGS[@]}" -gt 0 ]]; then
log "vanished during backup, which is normal on an active desktop." log "vanished during backup, which is normal on an active desktop."
log "" log ""
fi 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." 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 "Files are directly browsable on the drive under: ${BACKUP_DIR}"
log "Please eject the drive safely before removing it from your Sovran Pro." log "Please eject the drive safely before removing it from your Sovran Pro."
@@ -0,0 +1,313 @@
"""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 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
# ── 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
+20 -32
View File
@@ -29,6 +29,15 @@
color: var(--text-primary); color: var(--text-primary);
} }
.title-group {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 6px 10px;
}
.header-buttons { .header-buttons {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -45,42 +54,21 @@
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
/* ── OS Version Badge (polished) ───────────────────────────────── */ /* ── OS Version Badge — identical styling to the version badge ────
── shown next to titles in the service modal windows ──────────── */
.os-version-badge { .os-version-badge {
background-color: rgba(255, 255, 255, 0.06);
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 600;
padding: 2px 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
letter-spacing: 0.02em;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
background: linear-gradient(145deg, #1a2a22, #0f1a15);
border: 1px solid rgba(74, 222, 128, 0.25);
border-radius: 9999px;
padding: 2px 14px 2px 10px;
font-size: 0.75rem;
font-weight: 600;
color: #4ade80;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
margin-left: 12px;
white-space: nowrap; white-space: nowrap;
} flex-shrink: 0;
.os-version-badge:hover {
border-color: rgba(74, 222, 128, 0.45);
box-shadow: 0 4px 8px rgba(74, 222, 128, 0.15);
transform: translateY(-1px);
}
.os-version-badge .version-label {
font-size: 0.68rem;
font-weight: 500;
color: #4ade80;
opacity: 0.85;
margin-right: 2px;
}
.os-version-badge .version-number {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-weight: 700;
letter-spacing: 0.5px;
color: #86efac;
} }
/* ── IP bar ─────────────────────────────────────────────────────── */ /* ── IP bar ─────────────────────────────────────────────────────── */
+124 -3
View File
@@ -1,5 +1,56 @@
/* ── NWC Benefits Grid ─────────────────────────────────────────── */
.nwc-benefits-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-top: 14px;
}
.nwc-benefit-item {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px 14px;
display: flex;
gap: 12px;
align-items: flex-start;
transition: background-color 0.2s;
}
.nwc-benefit-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.nwc-benefit-icon {
font-size: 1.25rem;
margin-top: 2px;
opacity: 0.9;
}
.nwc-benefit-content strong {
display: block;
font-size: 0.82rem;
color: var(--text-primary);
margin-bottom: 2px;
}
.nwc-benefit-content p {
margin: 0;
font-size: 0.75rem;
line-height: 1.45;
color: var(--text-secondary);
}
@media (max-width: 800px) {
.nwc-benefits-grid {
grid-template-columns: 1fr;
}
}
/* ── Update modal ────────────────────────────────────────────────── */ /* ── Update modal ────────────────────────────────────────────────── */
.modal-overlay { .modal-overlay {
display: none; display: none;
position: fixed; position: fixed;
@@ -607,10 +658,12 @@ button.btn-reboot:hover:not(:disabled) {
.nwc-tab-intro { .nwc-tab-intro {
display: flex; display: flex;
align-items: flex-start; flex-direction: column;
justify-content: space-between; align-items: stretch;
gap: 16px; gap: 16px;
margin-bottom: 16px; margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
} }
.nwc-tab-intro-title { .nwc-tab-intro-title {
@@ -982,6 +1035,74 @@ button.btn-reboot:hover:not(:disabled) {
background: #1c2a24; background: #1c2a24;
} }
/* ── NWC Connect Guide (shown after wallet creation) ────────────── */
.nwc-connect-guide {
margin-top: 16px;
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: rgba(109, 191, 139, 0.04);
}
.nwc-connect-guide-title {
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.nwc-connect-guide-intro {
margin: 0 0 14px;
font-size: 0.85rem;
line-height: 1.55;
color: var(--text-secondary);
}
.nwc-connect-steps {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.nwc-connect-step {
display: flex;
gap: 10px;
align-items: flex-start;
font-size: 0.86rem;
line-height: 1.5;
}
.nwc-step-num {
background: var(--accent-color);
color: #0A1A10;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.78rem;
flex-shrink: 0;
margin-top: 2px;
}
.nwc-connect-note {
background: rgba(245, 158, 11, 0.08);
border-left: 3px solid #f59e0b;
padding: 10px 12px;
border-radius: 0 8px 8px 0;
font-size: 0.8rem;
line-height: 1.5;
color: var(--text-secondary);
}
.nwc-connect-note strong {
color: #fbbf24;
}
/* ── Narrow screens ─────────────────────────────────────────────── */ /* ── Narrow screens ─────────────────────────────────────────────── */
@media (max-width: 720px) { @media (max-width: 720px) {
+62 -62
View File
@@ -1,5 +1,28 @@
/* ── Service tile card (status-only) ─────────────────────────────── */ /* ── Service tile card (status-only) ─────────────────────────────── */
.dashboard-loading {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.dashboard-loading-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: dashboard-loading-spin 0.8s linear infinite;
}
@keyframes dashboard-loading-spin {
to { transform: rotate(360deg); }
}
.service-tile { .service-tile {
width: 160px; width: 160px;
min-height: 130px; min-height: 130px;
@@ -155,68 +178,6 @@
white-space: nowrap; 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 ───────────────────────────────── */ /* ── Service detail modal sections ───────────────────────────────── */
@@ -404,6 +365,45 @@
color: var(--text-dim); color: var(--text-dim);
} }
.svc-detail-option-card {
padding: 16px;
background: rgba(94, 173, 138, 0.06);
border: 1px solid rgba(94, 173, 138, 0.24);
border-radius: 10px;
}
.svc-detail-option-list {
margin: 10px 0 0;
padding-left: 20px;
color: var(--text-secondary);
font-size: 0.84rem;
line-height: 1.55;
}
.svc-detail-option-list li {
margin-bottom: 5px;
}
.svc-detail-option-privacy {
margin-top: 12px;
padding: 10px 12px;
border-left: 3px solid var(--accent-color);
background: rgba(94, 173, 138, 0.08);
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.82rem;
line-height: 1.5;
}
.svc-detail-option-privacy strong {
color: var(--accent-color);
}
.svc-detail-related-feature-btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.feature-conflict-warning { .feature-conflict-warning {
margin-top: 8px; margin-top: 8px;
margin-bottom: 8px; margin-bottom: 8px;
@@ -5,6 +5,16 @@
const POLL_INTERVAL_SERVICES = 5000; const POLL_INTERVAL_SERVICES = 5000;
const POLL_INTERVAL_UPDATES = 1800000; const POLL_INTERVAL_UPDATES = 1800000;
const UPDATE_POLL_INTERVAL = 2000; 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_CHECK_INTERVAL = 5000;
const REBOOT_FETCH_TIMEOUT = 12000; const REBOOT_FETCH_TIMEOUT = 12000;
const REBOOT_REQUEST_TIMEOUT = 4000; const REBOOT_REQUEST_TIMEOUT = 4000;
@@ -0,0 +1,146 @@
/* Sovran_SystemsOS Hub — Shared domain-prerequisite instructions.
SINGLE SOURCE OF TRUTH for the "you need a Njal.la domain + router port
forwarding" guidance that must read identically everywhere it appears:
• First-boot onboarding wizard (Server + Desktop role)
— onboarding.js step 3
• Feature-enable domain modal (Node role, and any role)
— features.js openDomainSetupModal()
(Lightning Wallet Connections / NWC, BTCPay Server, Haven, …)
• Domain reconfigure / troubleshooting modal
— features.js openDomainReconfigureModal()
Keep these three surfaces word-for-word consistent: always edit this
file, never fork the wording inline. Plain classic script (no modules) —
both templates load it with a plain <script> tag. */
"use strict";
/* Escape helper local to this file so it is self-contained on both pages. */
function dpEsc(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/* ── "What you'll need" intro ──────────────────────────────────────
opts.serviceName — e.g. "Lightning Wallet Connections", or null for
the multi-service (onboarding) wording.
opts.hostExample — e.g. "lightning" → lightning.yourdomain.com */
function renderDomainNeedsHtml(opts) {
var serviceName = opts.serviceName || null;
var hostExample = dpEsc(opts.hostExample || "myservice");
var html = "";
if (serviceName) {
html += "<p>To enable <strong>" + dpEsc(serviceName) + "</strong>, you'll need two things first:</p>";
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
+ dpEsc(serviceName) + " gets its own hostname: a subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many.</li>";
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
+ "to this computer once. All domain-based services share these two ports; "
+ "they're required for HTTPS and SSL certificates.</li>";
html += "</ol>";
} else {
html += "<p><strong>Each service below needs two things set up:</strong></p>";
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
+ "Each service gets its own hostname: its own subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many, "
+ "so a single domain can serve every service.</li>";
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
+ "to this computer once. All domain-based services share these two ports; "
+ "they're required for HTTPS and SSL certificates.</li>";
html += "</ol>";
}
return html;
}
/* ── "How to set it up at Njal.la" steps ───────────────────────────
opts.hostExample — host part used in the examples (default "call").
opts.pasteHint — where the curl command goes:
"below" (single service) or "next to its service below"
(onboarding, many services). */
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>How to set it up at Njal.la:</strong></p>";
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
html += "<li>Create an account at <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">https://njal.la</a> and buy a domain.</li>";
html += "<li>Add a <strong>Dynamic</strong> record for the hostname:"
+ '<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>"
+ "(For &quot;" + hostExample + ".yourdomain.com&quot; you&apos;d type just: <code>" + hostExample + "</code>.)<br>"
+ "&#9888; Do NOT type the full domain here — Njal.la adds it automatically.</li>"
+ "<li>Dedicating a whole separate domain to this service? Leave the Name field blank or use <code>@</code>.</li>"
+ "</ul>"
+ "</li>";
html += "<li>A Dynamic record has <strong>NO IP field</strong> — you don&apos;t enter an IP anywhere. "
+ "It auto-fills once Sovran_SystemsOS runs the update command (on save, and again after every reboot).</li>";
html += "<li>Njal.la gives you a curl command, e.g.:<br>"
+ '<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=' + hostExample + '.yourdomain.com&amp;k=abc123&amp;auto&quot;</code><br>'
+ "Copy it and paste it " + pasteHint + ".</li>";
html += "</ol>";
return html;
}
/* ── "One router task" port-forwarding box ─────────────────────────
opts.internalIp — LAN IP string, or empty/null for generic wording.
opts.plural — true for multi-service (onboarding) wording.
opts.includeSsh — also mention port 22 for SSH (onboarding).
opts.extraNote — extra sentence appended at the end (optional). */
function renderRouterPortsHtml(opts) {
opts = opts || {};
var ipPart = opts.internalIp
? " to this computer&rsquo;s internal IP <strong>" + dpEsc(opts.internalIp) + "</strong>"
: " to this computer&rsquo;s internal IP";
var serviceWord = opts.plural ? "services" : "service";
var html = "";
html += "🔌 <strong>One router task:</strong> in your router&rsquo;s <strong>port forwarding</strong> settings, "
+ "forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>"
+ ipPart + ". Use the <strong>same number for the internal and external port</strong>. "
+ "This only needs to be done once — all domain services share these ports — "
+ "but HTTPS and SSL certificates won&rsquo;t work without them, so your " + serviceWord + " "
+ "can&rsquo;t be reached from outside your home network. "
+ "You&rsquo;ll need normal access to your router&rsquo;s settings with working port forwarding — "
+ "if your ISP blocks it (e.g. CGNAT), domain-based services can&rsquo;t be reached from the internet. ";
if (opts.includeSsh) {
html += "Add port <strong>22 (TCP)</strong> as well if you want remote SSH access. ";
}
if (opts.extraNote) {
html += dpEsc(opts.extraNote);
}
return html;
}
/* ── Async helper: fill a router-box container with the internal IP ──
Renders generic text immediately, then upgrades to the concrete LAN IP
if /api/network provides one. Best-effort — never blocks the UI. */
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 already shown — fine */ });
}
+36 -12
View File
@@ -6,6 +6,16 @@
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal); if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
if ($btnReboot) $btnReboot.addEventListener("click", doReboot); if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
if ($btnRetryUpdate) $btnRetryUpdate.addEventListener("click", retryUpdateStatus);
// 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 ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal); if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
@@ -187,30 +197,39 @@ function showSecurityBanner() {
// ── Init ────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────
async function init() { async function init() {
// Check onboarding status first — redirect to wizard if not complete // 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;
try { try {
var onboardingStatus = await apiFetch("/api/onboarding/status"); var startupResults = await Promise.all([
if (!onboardingStatus.complete) { 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) {
window.location.href = "/onboarding"; window.location.href = "/onboarding";
return; 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 // Show first-login security banner only for machines that went through onboarding
// (legacy machines without the onboarding flag will never see this) // (legacy machines without the onboarding flag will never see this)
try {
var bannerData = await apiFetch("/api/security/banner-status");
if (bannerData && bannerData.show) { if (bannerData && bannerData.show) {
showSecurityBanner(); showSecurityBanner();
} }
} catch (_) {
// Non-fatal — silently ignore
}
try { try {
var cfg = await apiFetch("/api/config"); if (!cfg) throw new Error("Hub config unavailable");
_currentRole = cfg.role || "server_plus_desktop"; _currentRole = cfg.role || "server_plus_desktop";
if (cfg.category_order) { if (cfg.category_order) {
for (var i = 0; i < cfg.category_order.length; i++) { for (var i = 0; i < cfg.category_order.length; i++) {
@@ -239,6 +258,11 @@ async function init() {
setInterval(checkUpdates, POLL_INTERVAL_UPDATES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
loadAutolaunchToggle(); 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); document.addEventListener("DOMContentLoaded", init);
+22 -39
View File
@@ -86,45 +86,17 @@ function openDomainSetupModal(feat, onSaved) {
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com"; var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com"; var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
var introHtml; // Shared instructions (single source of truth: static/js/domain-prereqs.js) —
if (_currentRole === "node") { // identical wording to the Server + Desktop onboarding wizard and every other
introHtml = // domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la.</p>' + var hostExample = isWalletConnections ? "lightning" : "call";
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">njal.la</a>.</li>' +
'<li>Set up a domain for it — either a free subdomain or a separate domain. Pick one option:</li>' +
'</ol>';
} else {
introHtml =
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la. ' +
'In your Njal.la account, set up a domain for it — either a free subdomain or a separate domain. Pick one option:</p>';
}
$domainSetupBody.innerHTML = $domainSetupBody.innerHTML =
'<div class="domain-setup-intro">' + '<div class="domain-setup-intro">' +
nwcWarning + nwcWarning +
introHtml + renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample }) +
'<details style="margin-top:10px;">' + renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' + '<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
'<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;' + domainLabelExample + '&quot; you&apos;d type just: &nbsp;<code>' + (isWalletConnections ? 'lightning' : '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=' + domainLabelExample + '&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. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' + '<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' +
'</div>' + '</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 + '" /></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 + '" /></div>' +
@@ -170,6 +142,9 @@ function openDomainSetupModal(feat, onSaved) {
}); });
$domainSetupModal.classList.add("open"); $domainSetupModal.classList.add("open");
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
renderRouterPortsBox("domain-router-box");
} }
function openDomainReconfigureModal(feat, existingDomain, onSaved) { function openDomainReconfigureModal(feat, existingDomain, onSaved) {
@@ -218,7 +193,9 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' + '<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
'<li>If the IP is wrong or the record is missing, update it</li>' + '<li>If the IP is wrong or the record is missing, update it</li>' +
'<li>If you changed the DDNS curl command, paste the updated one below</li>' + '<li>If you changed the DDNS curl command, paste the updated one below</li>' +
'<li>Confirm ports <strong>80</strong> and <strong>443</strong> (TCP) are still forwarded on your router to this computer — see the reminder below:</li>' +
'</ol>' + '</ol>' +
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
'</div>' + '</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-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-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>' +
@@ -263,6 +240,9 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
}); });
$domainSetupModal.classList.add("open"); $domainSetupModal.classList.add("open");
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
renderRouterPortsBox("domain-router-box");
} }
function closeDomainSetupModal() { function closeDomainSetupModal() {
@@ -362,8 +342,11 @@ async function performFeatureToggle(featId, enabled, extra) {
function handleFeatureToggle(feat, newEnabled) { function handleFeatureToggle(feat, newEnabled) {
if (!newEnabled) { if (!newEnabled) {
// Disable: ask confirmation // 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( openFeatureConfirm(
"This will disable " + feat.name + ". The system will rebuild. Continue?", disableMessage,
function() { performFeatureToggle(feat.id, false, {}); } function() { performFeatureToggle(feat.id, false, {}); }
); );
return; return;
@@ -423,9 +406,9 @@ function handleFeatureToggle(feat, newEnabled) {
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck); openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
} }
if (feat.id === "bitcoin-core") { if (feat.id === "bitcoin-tor-gossip") {
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?"; 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(confirmMsg, proceedAfterConflictCheck); openFeatureConfirm(torGossipConfirmMsg, proceedAfterConflictCheck);
} else if (conflictNames.length > 0) { } else if (conflictNames.length > 0) {
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck); openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
} else { } else {
+29 -13
View File
@@ -111,8 +111,19 @@ function formatDuration(seconds) {
// ── Fetch wrappers ──────────────────────────────────────────────── // ── 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) { async function apiFetch(path, options) {
const res = await fetch(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) { if (!res.ok) {
let detail = res.status + " " + res.statusText; let detail = res.status + " " + res.statusText;
try { try {
@@ -134,16 +145,21 @@ async function apiFetch(path, options) {
return res.json(); return res.json();
} }
async function apiFetchWithTimeout(path, options, timeoutMs) {
// ── BIP-110 badge state config ──────────────────────────────────── var controller = new AbortController();
// Shared lookup used by tiles.js and service-detail.js. var fetchOptions = Object.assign({}, options || {});
// Keys match the "state" values returned by /api/bitcoin/bip110. fetchOptions.signal = controller.signal;
var timer = setTimeout(function() { controller.abort(); }, timeoutMs);
var BIP110_BADGE_CONFIG = { try {
active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' }, return await apiFetch(path, fetchOptions);
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' }, } catch (err) {
signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' }, if (controller.signal.aborted) {
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' }, var timeoutError = new Error("Request timed out");
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' }, timeoutError.name = "TimeoutError";
unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' } throw timeoutError;
}; }
throw err;
} finally {
clearTimeout(timer);
}
}
+23 -2
View File
@@ -8,6 +8,8 @@ function openRebuildModal() {
_rebuildLogOffset = 0; _rebuildLogOffset = 0;
_rebuildServerDown = false; _rebuildServerDown = false;
_rebuildFinished = false; _rebuildFinished = false;
_rebuildPollInFlight = false;
_rebuildPollFailures = 0;
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; } if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
var action = _rebuildIsEnabling ? "Enabling" : "Disabling"; var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
var label = _rebuildFeatureName || "feature"; var label = _rebuildFeatureName || "feature";
@@ -33,6 +35,7 @@ function appendRebuildLog(text) {
} }
function startRebuildPoll() { function startRebuildPoll() {
if (_rebuildPollTimer) clearInterval(_rebuildPollTimer);
pollRebuildStatus(); pollRebuildStatus();
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL); _rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
} }
@@ -42,9 +45,15 @@ function stopRebuildPoll() {
} }
async function pollRebuildStatus() { async function pollRebuildStatus() {
if (_rebuildFinished) return; if (_rebuildFinished || _rebuildPollInFlight) return;
_rebuildPollInFlight = true;
try { try {
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); var data = await apiFetchWithTimeout(
"/api/rebuild/status?offset=" + _rebuildLogOffset,
{ cache: "no-store" },
STATUS_POLL_FETCH_TIMEOUT
);
_rebuildPollFailures = 0;
if (_rebuildServerDown) { _rebuildServerDown = false; } if (_rebuildServerDown) { _rebuildServerDown = false; }
if (data.log) appendRebuildLog(data.log); if (data.log) appendRebuildLog(data.log);
_rebuildLogOffset = data.offset; _rebuildLogOffset = data.offset;
@@ -57,7 +66,19 @@ async function pollRebuildStatus() {
onRebuildDone(data.result === "success"); onRebuildDone(data.result === "success");
} }
} catch (err) { } 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; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; } if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
} finally {
_rebuildPollInFlight = false;
} }
} }
@@ -2,6 +2,21 @@
// ── Service detail modal ────────────────────────────────────────── // ── Service detail modal ──────────────────────────────────────────
function _getZeusConnectGuideHtml() {
return '<div class="nwc-connect-guide">' +
'<div class="nwc-connect-guide-title">📱 Connect to Zeus</div>' +
'<p class="nwc-connect-guide-intro">This connection URL is an <strong>LND REST</strong> connection — the direct way to use Zeus with your node for full admin access. It connects securely through Tor, allowing you to manage channels, balances, and your full node on the go.</p>' +
'<div class="nwc-connect-steps">' +
'<div class="nwc-connect-step"><div class="nwc-step-num">1</div><div><strong>Download Zeus</strong> from the App Store or Google Play.</div></div>' +
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the LND REST QR and fills in the connection details. Turn <strong>Use Tor</strong> on (the host is a .onion address), then tap <strong>Save Wallet Config</strong>.</div></div>' +
'</div>' +
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the NWC pairing QR shown in Lightning Wallet Connections — that gives your wallet sandboxed, limited access for everyday spending. LND REST connects Zeus directly to your node for full admin control. The QR uses your dedicated LND REST Tor address (no TLS cert) so Zeus can scan and connect over Tor.</div>' +
'</div>';
}
function _renderCredsHtml(credentials, unit) { function _renderCredsHtml(credentials, unit) {
var html = ""; var html = "";
for (var i = 0; i < credentials.length; i++) { for (var i = 0; i < credentials.length; i++) {
@@ -9,7 +24,10 @@ function _renderCredsHtml(credentials, unit) {
var id = "cred-" + Math.random().toString(36).substring(2, 8); var id = "cred-" + Math.random().toString(36).substring(2, 8);
var qrBlock = ""; var qrBlock = "";
if (cred.qrcode) { if (cred.qrcode) {
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</div></div>'; var qrHint = (unit === "zeus-connect-setup.service")
? "This is an <strong>LND REST</strong> connection QR — in Zeus, add a wallet and use the scan icon (see steps below)."
: "In Zeus: <em>Wallets → + → scan icon</em>. This is an <strong>LND REST</strong> QR for direct node access.";
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">' + qrHint + '</div></div>';
} }
// If qronly, render the label + QR block only — skip value and copy button // If qronly, render the label + QR block only — skip value and copy button
if (cred.qronly) { if (cred.qronly) {
@@ -174,7 +192,7 @@ function _nwcRenderWalletState() {
var selectedLimited = state.createForm.access_preset === "send_receive_limited"; var selectedLimited = state.createForm.access_preset === "send_receive_limited";
html += html +=
'<div class="nwc-tab-intro-title">Create a Wallet</div>' + '<div class="nwc-tab-intro-title">Create a Wallet</div>' +
'<p class="nwc-tab-intro-desc">An isolated wallet you pair to a single app, with its own Lightning Address.</p>' + '<p class="nwc-tab-intro-desc">Create a secure, sandboxed wallet for a specific app. Each wallet gets its own Lightning Address and optional spending limit.</p>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-name">Wallet Name</label>' + '<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-name">Wallet Name</label>' +
'<input class="matrix-form-input" id="nwc-wallet-name" type="text" placeholder="My Wallet" value="' + escHtml(state.createForm.name || "") + '" autocomplete="off"></div>' + '<input class="matrix-form-input" id="nwc-wallet-name" type="text" placeholder="My Wallet" value="' + escHtml(state.createForm.name || "") + '" autocomplete="off"></div>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-alias">Lightning Address Alias</label>' + '<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-alias">Lightning Address Alias</label>' +
@@ -218,7 +236,8 @@ function _nwcRenderWalletState() {
var pairId = "nwc-pairing-uri-" + Math.random().toString(36).substring(2, 8); var pairId = "nwc-pairing-uri-" + Math.random().toString(36).substring(2, 8);
html += '<div class="nwc-secret-warning">⚠ One-time pairing secret. Save it now — it will not be shown again.</div>'; html += '<div class="nwc-secret-warning">⚠ One-time pairing secret. Save it now — it will not be shown again.</div>';
if (created.pairing_qrcode) { if (created.pairing_qrcode) {
html += '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + created.pairing_qrcode + '" alt="QR code for Lightning Wallet Connections pairing secret"><div class="creds-qr-hint">Scan now in Zeus or copy the URI below.</div></div>'; html += '<div class="creds-row"><div class="creds-label">QR Code</div>' +
'<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + created.pairing_qrcode + '" alt="QR code for Lightning Wallet Connections pairing secret"><div class="creds-qr-hint">This is an <strong>NWC</strong> pairing QR — in Zeus, add a wallet and use the scan icon (see steps below).</div></div></div>';
} }
html += '<div class="creds-row"><div class="creds-label">Pairing URI</div>' + html += '<div class="creds-row"><div class="creds-label">Pairing URI</div>' +
'<div class="creds-value-wrap"><div class="creds-value" id="' + pairId + '">' + escHtml(created.pairing_uri || "Unavailable") + '</div><button class="creds-copy-btn" data-target="' + pairId + '">Copy</button></div></div>'; '<div class="creds-value-wrap"><div class="creds-value" id="' + pairId + '">' + escHtml(created.pairing_uri || "Unavailable") + '</div><button class="creds-copy-btn" data-target="' + pairId + '">Copy</button></div></div>';
@@ -226,6 +245,18 @@ function _nwcRenderWalletState() {
html += '<div class="creds-row"><div class="creds-label">Lightning Address</div>' + html += '<div class="creds-row"><div class="creds-label">Lightning Address</div>' +
'<div class="creds-value-wrap"><div class="creds-value">' + escHtml(created.wallet.lightning_address) + '</div></div></div>'; '<div class="creds-value-wrap"><div class="creds-value">' + escHtml(created.wallet.lightning_address) + '</div></div></div>';
} }
html += '<div class="nwc-connect-guide">' +
'<div class="nwc-connect-guide-title">📱 Connect to Zeus</div>' +
'<p class="nwc-connect-guide-intro">This pairing URI is an <strong>NWC (Nostr Wallet Connect)</strong> connection — the modern, mobile-friendly way to use Zeus with your node. It connects directly through your Lightning domain, so no Tor or port forwarding is needed on your phone.</p>' +
'<div class="nwc-connect-steps">' +
'<div class="nwc-connect-step"><div class="nwc-step-num">1</div><div><strong>Download Zeus</strong> from the App Store or Google Play.</div></div>' +
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the NWC QR and fills in <strong>Nostr Wallet Connect</strong>. Review it, then tap <strong>Save Wallet Config</strong>.</div></div>' +
'</div>' +
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the LND REST / Tor QR shown on your LND tile — that connects Zeus directly to your Lightning node for full admin control. NWC gives your wallet sandboxed, limited access for everyday spending.</div>' +
'</div>';
html += '<div class="matrix-form-actions">' + html += '<div class="matrix-form-actions">' +
'<button class="matrix-form-back" id="nwc-created-another-btn"' + (state.busy ? " disabled" : "") + '>Create Another Wallet</button>' + '<button class="matrix-form-back" id="nwc-created-another-btn"' + (state.busy ? " disabled" : "") + '>Create Another Wallet</button>' +
'<button class="matrix-form-submit" id="nwc-created-continue-btn"' + (state.busy ? " disabled" : "") + '>I Saved This Secret</button>' + '<button class="matrix-form-submit" id="nwc-created-continue-btn"' + (state.busy ? " disabled" : "") + '>I Saved This Secret</button>' +
@@ -330,9 +361,9 @@ function _nwcRenderWalletState() {
if (!state.wallets || state.wallets.length === 0) { if (!state.wallets || state.wallets.length === 0) {
html += '<div class="nwc-empty-state">' + html += '<div class="nwc-empty-state">' +
'<div class="nwc-empty-icon">👛</div>' + '<div class="nwc-empty-icon"></div>' +
'<div class="nwc-empty-title">No wallets yet</div>' + '<div class="nwc-empty-title">Ready to start spending</div>' +
'<p class="nwc-empty-desc">Create your first wallet to get a one-time pairing secret for an app, plus a reusable Lightning Address.</p>' + '<p class="nwc-empty-desc">Create your first isolated wallet to connect to apps like Zeus (via NWC) or Nostr. Experience faster, more secure Lightning payments today.</p>' +
'</div>'; '</div>';
host.innerHTML = html; host.innerHTML = html;
_nwcWireDomainLink(); _nwcWireDomainLink();
@@ -673,20 +704,6 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>' + '</div>' +
'</div>'); '</div>');
// Section B2: BIP-110 live status (bip110 tile only)
if (icon === 'bip110' && data.bip110) {
var bip110 = data.bip110;
var bip110State = bip110.state || 'unknown';
var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown;
var bip110Source = bip110.source ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
'<div class="bip110-status-row">' +
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
bip110Source +
'</div>' +
'</div>';
}
// Section C: Domain diagnostics (domain services) // Section C: Domain diagnostics (domain services)
if (data.needs_domain) { if (data.needs_domain) {
@@ -761,9 +778,32 @@ async function openServiceDetailModal(unit, name, icon) {
if (isNwc) { if (isNwc) {
if (effectiveEnabled || data.enabled) { if (effectiveEnabled || data.enabled) {
html += '<div class="nwc-tab-intro">' + html += '<div class="nwc-tab-intro">' +
'<div>' + '<div class="nwc-intro-header">' +
'<div class="nwc-tab-intro-title">Your Lightning Wallets</div>' + '<div class="nwc-tab-intro-title">Lightning Wallet Connections</div>' +
'<p class="nwc-tab-intro-desc">Each wallet is isolated: pair it to one app with a one-time secret, and share its Lightning Address to get paid.</p>' + '<p class="nwc-tab-intro-desc">Powerful, isolated wallets for your daily spending and modern apps.</p>' +
'</div>' +
'<div class="nwc-benefits-grid">' +
'<div class="nwc-benefit-item">' +
'<div class="nwc-benefit-icon">🛡️</div>' +
'<div class="nwc-benefit-content">' +
'<strong>Isolated & Secure</strong>' +
'<p>Create sandboxed wallets for quick spending while keeping your main node protected. Isolated access is faster than Tor and perfect for budgeting.</p>' +
'</div>' +
'</div>' +
'<div class="nwc-benefit-item">' +
'<div class="nwc-benefit-icon">🌐</div>' +
'<div class="nwc-benefit-content">' +
'<strong>Modern Ecosystem</strong>' +
'<p>Easily spend and receive bitcoin using LNURL and Nostr (NWC) across the growing ecosystem of decentralized apps.</p>' +
'</div>' +
'</div>' +
'<div class="nwc-benefit-item">' +
'<div class="nwc-benefit-icon">📱</div>' +
'<div class="nwc-benefit-content">' +
'<strong>Zeus on the Go (via NWC)</strong>' +
'<p>Connect Zeus to your wallet using NWC — no Tor, no port forwarding. Create a wallet below, scan the pairing QR, and start spending from your phone.</p>' +
'</div>' +
'</div>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div id="nwc-wallets-body"><p class="creds-loading">Loading wallets…</p></div>' + '<div id="nwc-wallets-body"><p class="creds-loading">Loading wallets…</p></div>' +
@@ -813,6 +853,7 @@ async function openServiceDetailModal(unit, name, icon) {
html += '<div class="svc-detail-section">' + html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">Credentials &amp; Access</div>' + '<div class="svc-detail-section-title">Credentials &amp; Access</div>' +
_renderCredsHtml(data.credentials, unit) + _renderCredsHtml(data.credentials, unit) +
(unit === "zeus-connect-setup.service" ? _getZeusConnectGuideHtml() : "") +
(unit === "matrix-synapse.service" ? (unit === "matrix-synapse.service" ?
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' + '<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
'<button class="matrix-action-btn" id="matrix-add-user-btn"> Add New User</button>' + '<button class="matrix-action-btn" id="matrix-add-user-btn"> Add New User</button>' +
@@ -846,9 +887,7 @@ async function openServiceDetailModal(unit, name, icon) {
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
// Section title: use a more specific label for mutually-exclusive Bitcoin node features // Section title: use a more specific label for mutually-exclusive Bitcoin node features
var addonSectionTitle = (feat.id === "bitcoin-core") var addonSectionTitle = "\uD83D\uDD27 Addon Feature";
? "\u20BF Bitcoin Node Selection"
: "\uD83D\uDD27 Addon Feature";
// Description: prefer the feature's own description over a generic fallback // Description: prefer the feature's own description over a generic fallback
var addonDesc = feat.description var addonDesc = feat.description
@@ -879,6 +918,54 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>'); '</div>');
} }
// Section G: Contextual feature options. The Tor IBD advertising control is
// deliberately shown only inside the Bitcoin Core modal so its bandwidth
// and privacy implications are explained before the user enables it.
var relatedFeatures = Array.isArray(data.related_features) ? data.related_features : [];
relatedFeatures.forEach(function(optionFeat) {
// Keep the shared feature state current for the standard rebuild flow.
if (!_featuresData) {
_featuresData = { features: [optionFeat], ssl_email_configured: false };
} else {
var optionIndex = _featuresData.features.findIndex(function(f) { return f.id === optionFeat.id; });
if (optionIndex >= 0) _featuresData.features[optionIndex] = optionFeat;
else _featuresData.features.push(optionFeat);
}
var detailsHtml = "";
if (Array.isArray(optionFeat.details) && optionFeat.details.length > 0) {
detailsHtml = '<ul class="svc-detail-option-list">' +
optionFeat.details.map(function(detail) {
return '<li>' + escHtml(detail) + '</li>';
}).join("") +
'</ul>';
}
var optionAvailable = optionFeat.available !== false;
var optionStatusLabel = optionFeat.enabled ? "Advertising enabled \u2713" : "Not advertised";
var optionStatusCls = optionFeat.enabled ? "addon-status--on" : "addon-status--off";
var optionButtonLabel = optionFeat.enabled ? "Stop Advertising" : "Advertise Onion Address";
var optionButtonCls = optionFeat.enabled ? "btn btn-close-modal" : "btn btn-primary";
if (!optionAvailable) {
optionStatusLabel = "Bitcoin Core is not enabled";
optionButtonLabel = "Enable Bitcoin Core First";
optionButtonCls = "btn btn-close-modal";
}
addSetup('<div class="svc-detail-section svc-detail-option-card">' +
'<div class="svc-detail-section-title">Tor IBD Service Advertising</div>' +
'<p class="svc-detail-desc">' + escHtml(optionFeat.description || "") + '</p>' +
detailsHtml +
'<div class="svc-detail-option-privacy">' +
'<strong>Tor-only:</strong> This setting advertises the onion service. It does not open a clearnet port, expose your home IP address, or require router port forwarding.' +
'</div>' +
'<div class="svc-detail-addon-row">' +
'<span class="svc-detail-addon-status ' + optionStatusCls + '">' + escHtml(optionStatusLabel) + '</span>' +
'<button class="' + optionButtonCls + ' svc-detail-related-feature-btn" data-feature-id="' + escHtml(optionFeat.id) + '"' + (!optionAvailable ? ' disabled' : '') + '>' + escHtml(optionButtonLabel) + '</button>' +
'</div>' +
'</div>');
});
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") { if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
addSetup('<div class="svc-detail-section svc-detail-restart-section">' + addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
'<div class="svc-detail-section-title">Troubleshooting</div>' + '<div class="svc-detail-section-title">Troubleshooting</div>' +
@@ -939,6 +1026,18 @@ async function openServiceDetailModal(unit, name, icon) {
} }
} }
var relatedFeatureButtons = $credsBody.querySelectorAll(".svc-detail-related-feature-btn");
relatedFeatureButtons.forEach(function(button) {
button.addEventListener("click", function() {
if (button.disabled) return;
var featureId = button.getAttribute("data-feature-id");
var relatedFeat = relatedFeatures.find(function(f) { return f.id === featureId; });
if (!relatedFeat) return;
closeCredsModal();
handleFeatureToggle(relatedFeat, !relatedFeat.enabled);
});
});
var restartBtn = document.getElementById("svc-detail-restart-btn"); var restartBtn = document.getElementById("svc-detail-restart-btn");
var restartResult = document.getElementById("svc-detail-restart-result"); var restartResult = document.getElementById("svc-detail-restart-result");
if (restartBtn && restartResult) { if (restartBtn && restartResult) {
@@ -1030,6 +1129,9 @@ async function openCredsModal(unit, name, icon) {
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' + '<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
'</div>'; '</div>';
} }
if (unit === "zeus-connect-setup.service") {
html += _getZeusConnectGuideHtml();
}
$credsBody.innerHTML = html; $credsBody.innerHTML = html;
_attachCopyHandlers($credsBody); _attachCopyHandlers($credsBody);
if (unit === "matrix-synapse.service") { if (unit === "matrix-synapse.service") {
@@ -6,9 +6,13 @@ let _servicesCache = [];
let _categoryLabels = {}; let _categoryLabels = {};
let _updateLog = ""; let _updateLog = "";
let _updatePollTimer = null; let _updatePollTimer = null;
let _updatePollInFlight = false;
let _updateLogOffset = 0; let _updateLogOffset = 0;
let _updateVisibleLogChars = 0;
let _serverWasDown = false; let _serverWasDown = false;
let _updateFinished = false; let _updateFinished = false;
let _updateStatusUnavailable = false;
let _updatePollFailures = 0; // consecutive failed update-status polls
let _supportTimerInt = null; let _supportTimerInt = null;
let _supportEnabledAt = null; let _supportEnabledAt = null;
let _supportStatus = null; // last fetched /api/support/status payload let _supportStatus = null; // last fetched /api/support/status payload
@@ -23,8 +27,10 @@ let _featuresData = null;
let _rebuildLog = ""; let _rebuildLog = "";
let _rebuildLogOffset = 0; let _rebuildLogOffset = 0;
let _rebuildPollTimer = null; let _rebuildPollTimer = null;
let _rebuildPollInFlight = false;
let _rebuildFinished = false; let _rebuildFinished = false;
let _rebuildServerDown = false; let _rebuildServerDown = false;
let _rebuildPollFailures = 0; // consecutive failed rebuild-status polls
let _pendingToggle = null; // {feature, extra} waiting for domain/confirm let _pendingToggle = null; // {feature, extra} waiting for domain/confirm
let _rebuildFeatureName = ""; let _rebuildFeatureName = "";
let _rebuildIsEnabling = true; let _rebuildIsEnabling = true;
@@ -46,6 +52,7 @@ const $modalStatus = document.getElementById("modal-status");
const $modalLog = document.getElementById("modal-log"); const $modalLog = document.getElementById("modal-log");
const $btnReboot = document.getElementById("btn-reboot"); const $btnReboot = document.getElementById("btn-reboot");
const $btnSave = document.getElementById("btn-save-report"); const $btnSave = document.getElementById("btn-save-report");
const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
const $btnCloseModal = document.getElementById("btn-close-modal"); const $btnCloseModal = document.getElementById("btn-close-modal");
const $rebootOverlay = document.getElementById("reboot-overlay"); const $rebootOverlay = document.getElementById("reboot-overlay");
+104 -18
View File
@@ -110,12 +110,26 @@ function renderSupportInactive() {
'</div>', '</div>',
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>', '<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>A restricted <code>sovran-support</code> user is created with limited access</li>',
'<li>Our SSH key is added only to that restricted account</li>', '<li>Support\'s SSH key is added only to that restricted account — not to root</li>',
'<li>Wallet files are locked via access controls — not visible to support</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>You control if and when wallet access is granted (time-limited)</li>',
'<li>All session events are logged for your audit</li>', '<li>All session events are logged for your audit</li>',
'<li>Access expires automatically after 24 hours</li>',
'</ol></div>', '</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>', '<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>', '<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>', '</div>',
].join(""); ].join("");
@@ -227,16 +241,43 @@ function renderSupportRemoved(verified) {
async function enableSupport() { async function enableSupport() {
var btn = document.getElementById("btn-support-enable"); 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…"; } if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
try { try {
await apiFetch("/api/support/enable", { method: "POST" }); await apiFetch("/api/support/enable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ssh_public_key: sshKey }),
});
var status = await apiFetch("/api/support/status"); var status = await apiFetch("/api/support/status");
_supportStatus = status; _supportStatus = status;
_supportEnabledAt = status.enabled_at; _supportEnabledAt = status.enabled_at;
renderSupportActive(status); renderSupportActive(status);
} catch (err) { } catch (err) {
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; } if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
alert("Failed to enable support access. Please try again."); 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); }
} }
} }
@@ -473,11 +514,27 @@ function renderBackupReady(drives) {
].join(""); ].join("");
} }
$supportBody.innerHTML = [ // ── Role-aware backup description ─────────────────────────────
'<div class="support-section">', // Desktop Only systems run no server or Bitcoin services and have no
'<div class="support-icon-big">\ud83d\udcbe</div>', // internal second drive, so the backup mirrors only the NixOS configuration
'<h3 class="support-heading">Manual Backup</h3>', // 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");
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;">', '<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
'<p class="support-wallet-desc">', '<p class="support-wallet-desc">',
'Your Sovran Pro already backs up your data automatically to its internal second drive. ', 'Your Sovran Pro already backs up your data automatically to its internal second drive. ',
@@ -486,6 +543,44 @@ function renderBackupReady(drives) {
'against hardware failure or physical damage.', 'against hardware failure or physical damage.',
'</p>', '</p>',
'</div>', '</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">',
'<div class="support-steps-title">Requirements</div>', '<div class="support-steps-title">Requirements</div>',
@@ -500,20 +595,11 @@ function renderBackupReady(drives) {
'<div class="support-steps">', '<div class="support-steps">',
'<div class="support-steps-title">What gets backed up</div>', '<div class="support-steps-title">What gets backed up</div>',
'<ol class="support-backup-steps">', '<ol class="support-backup-steps">',
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>', backupItemsHtml,
'<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>',
'</ol>', '</ol>',
'</div>', '</div>',
'<div class="support-wallet-box support-wallet-warning">', dbNoteHtml,
'<div class="support-wallet-header">',
'<span class="support-wallet-icon">\u2139\ufe0f</span>',
'<span class="support-wallet-title">Database and Blockchain Data</span>',
'</div>',
'<p class="support-wallet-desc">Application databases stored in PostgreSQL or MariaDB/MySQL are <strong>not included</strong> in Manual Backup. Bitcoin blockchain and Electrs index data are also excluded (they are stored on the internal second drive). If you use Nextcloud, Matrix, or other database-backed applications, back up those databases separately with their native tools.</p>',
'</div>',
'<div class="support-wallet-box support-wallet-protected">', '<div class="support-wallet-box support-wallet-protected">',
'<div class="support-wallet-header">', '<div class="support-wallet-header">',
+11 -28
View File
@@ -4,14 +4,6 @@
// Keyed by tileId: { progress: float, timestamp: ms } // Keyed by tileId: { progress: float, timestamp: ms }
var _btcSyncPrev = {}; 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) { function _firstElementFromHtml(html) {
var tmp = document.createElement("div"); var tmp = document.createElement("div");
@@ -175,8 +167,7 @@ function buildTile(svc) {
return tile; return tile;
} }
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><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></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>' + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
tile.style.cursor = "pointer"; tile.style.cursor = "pointer";
tile.addEventListener("click", function() { tile.addEventListener("click", function() {
@@ -244,23 +235,6 @@ function updateTiles(services) {
var text = tile.querySelector(".status-text"); var text = tile.querySelector(".status-text");
if (dot) dot.className = "status-dot " + sc; if (dot) dot.className = "status-dot " + sc;
if (text) text.textContent = st; if (text) text.textContent = st;
// 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 the service name
var anchorEl = tile.querySelector(".tile-name");
if (anchorEl) {
var newBadgeEl = _firstElementFromHtml(badgeHtml);
if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl);
}
}
}
} }
} }
} }
@@ -297,10 +271,19 @@ async function checkUpdates() {
try { try {
var data = await apiFetch("/api/updates/check"); var data = await apiFetch("/api/updates/check");
var hasUpdates = !!data.available; var hasUpdates = !!data.available;
var updateStatus = data.status || "idle";
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update"); var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
var sidebarUpdateHint = document.getElementById("sidebar-update-hint"); var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
if (sidebarUpdateBtn) { if (sidebarUpdateBtn) {
if (hasUpdates) { if (updateStatus === "reboot_required") {
sidebarUpdateBtn.style.borderColor = "#e5a50a";
sidebarUpdateBtn.style.backgroundColor = "rgba(229, 165, 10, 0.10)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
} else if (updateStatus === "running") {
sidebarUpdateBtn.style.borderColor = "#3584e4";
sidebarUpdateBtn.style.backgroundColor = "rgba(53, 132, 228, 0.10)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
} else if (hasUpdates) {
sidebarUpdateBtn.style.borderColor = "#2ec27e"; sidebarUpdateBtn.style.borderColor = "#2ec27e";
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)"; sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!"; if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
+169 -21
View File
@@ -2,20 +2,45 @@
// ── Update modal ────────────────────────────────────────────────── // ── Update modal ──────────────────────────────────────────────────
function openUpdateModal() { async function openUpdateModal() {
if (!$modal) return; if (!$modal) return;
apiFetch("/api/updates/check")
// 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") {
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
)
.then(function(data) { .then(function(data) {
if (!data.available) { if (!data.available) {
stopUpdatePoll(); stopUpdatePoll();
_updateLog = ""; _updateLog = "";
_updateLogOffset = 0; _updateLogOffset = 0;
_updateVisibleLogChars = 0;
_updateFinished = true; _updateFinished = true;
_updateStatusUnavailable = false;
if ($modalLog) $modalLog.textContent = ""; if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date"; if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
if ($modalSpinner) $modalSpinner.classList.remove("spinning"); if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnReboot) $btnReboot.style.display = "none"; if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none"; if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false; if ($btnCloseModal) $btnCloseModal.disabled = false;
$modal.classList.add("open"); $modal.classList.add("open");
return; return;
@@ -27,22 +52,69 @@ function openUpdateModal() {
}); });
} }
function _doOpenUpdateModal() { function prepareUpdateModal() {
if (!$modal) return; if (!$modal) return;
stopUpdatePoll();
_updateLog = ""; _updateLog = "";
_updateLogOffset = 0; _updateLogOffset = 0;
_updateVisibleLogChars = 0;
_updatePollInFlight = false;
_serverWasDown = false; _serverWasDown = false;
_updateFinished = false; _updateFinished = false;
_updateStatusUnavailable = false;
_updatePollFailures = 0;
if ($modalLog) $modalLog.textContent = ""; if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "Starting update…"; if ($modalStatus) $modalStatus.textContent = "Starting update…";
if ($modalSpinner) $modalSpinner.classList.add("spinning"); if ($modalSpinner) $modalSpinner.classList.add("spinning");
if ($btnReboot) $btnReboot.style.display = "none"; if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none"; if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = true; if ($btnCloseModal) $btnCloseModal.disabled = true;
$modal.classList.add("open"); $modal.classList.add("open");
}
function _doOpenUpdateModal() {
prepareUpdateModal();
startUpdate(); startUpdate();
} }
function showExistingUpdate(data) {
prepareUpdateModal();
if (data.log) appendLog(data.log);
_updateLogOffset = Number(data.offset) || 0;
if (data.running) {
if ($modalStatus) $modalStatus.textContent = "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() { function closeUpdateModal() {
if (!$modal) return; if (!$modal) return;
$modal.classList.remove("open"); $modal.classList.remove("open");
@@ -52,21 +124,36 @@ function closeUpdateModal() {
function appendLog(text) { function appendLog(text) {
if (!text) return; if (!text) return;
_updateLog += text; _updateLog += text;
if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; } 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;
}
} }
function startUpdate() { function startUpdate() {
fetch("/api/updates/run", { method: "POST" }) apiFetchWithTimeout(
.then(function(response) { "/api/updates/run",
if (!response.ok) return response.text().then(function(t) { throw new Error(t); }); { method: "POST" },
return response.json(); STATUS_POLL_FETCH_TIMEOUT * 2
}) )
.then(function(data) { .then(function(data) {
if (data.status === "no_updates") { if (data.status === "no_updates") {
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date"; if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
if ($modalSpinner) $modalSpinner.classList.remove("spinning"); if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnReboot) $btnReboot.style.display = "none"; if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none"; if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false; if ($btnCloseModal) $btnCloseModal.disabled = false;
_updateFinished = true; _updateFinished = true;
return; return;
@@ -82,6 +169,7 @@ function startUpdate() {
} }
function startUpdatePoll() { function startUpdatePoll() {
if (_updatePollTimer) clearInterval(_updatePollTimer);
pollUpdateStatus(); pollUpdateStatus();
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL); _updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
} }
@@ -91,32 +179,45 @@ function stopUpdatePoll() {
} }
async function pollUpdateStatus() { async function pollUpdateStatus() {
if (_updateFinished) return; // 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;
try { try {
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset); var data = await apiFetchWithTimeout(
"/api/updates/status?offset=" + _updateLogOffset,
{ cache: "no-store" },
STATUS_POLL_FETCH_TIMEOUT
);
_updatePollFailures = 0;
if (_serverWasDown) { if (_serverWasDown) {
_serverWasDown = false; _serverWasDown = false;
if (!data.running) { if (!data.running) {
// The update finished while the server was restarting. Reset to // The update finished while the server or browser connection was away.
// offset 0 and re-fetch so the complete log is shown from the top. // Re-fetch from offset 0 so the final result and complete tail agree.
_updateLog = ""; _updateLog = "";
_updateLogOffset = 0; _updateLogOffset = 0;
_updateVisibleLogChars = 0;
if ($modalLog) $modalLog.textContent = ""; if ($modalLog) $modalLog.textContent = "";
try { try {
var fullData = await apiFetch("/api/updates/status?offset=0"); var fullData = await apiFetchWithTimeout(
"/api/updates/status?offset=0",
{ cache: "no-store" },
STATUS_POLL_FETCH_TIMEOUT
);
if (fullData.log) appendLog(fullData.log); if (fullData.log) appendLog(fullData.log);
_updateLogOffset = fullData.offset; _updateLogOffset = fullData.offset;
} catch (e) { data = fullData;
// If the re-fetch fails, fall through with whatever we have. } catch (_) {
if (data.log) appendLog(data.log); if (data.log) appendLog(data.log);
_updateLogOffset = data.offset; _updateLogOffset = data.offset;
} }
if (data.result === "reboot_required") { if (data.result === "reboot_required") {
appendLog("[Server restarted — update completed, reboot required.]\n"); appendLog("[Reconnected — update completed, reboot required.]\n");
} else if (data.result === "success") { } else if (data.result === "success") {
appendLog("[Server restarted — update completed successfully.]\n"); appendLog("[Reconnected — update completed successfully.]\n");
} else { } else {
appendLog("[Server restarted — update encountered an error.]\n"); appendLog("[Reconnected — update encountered an error.]\n");
} }
_updateFinished = true; _updateFinished = true;
stopUpdatePoll(); stopUpdatePoll();
@@ -127,7 +228,7 @@ async function pollUpdateStatus() {
} }
return; return;
} }
appendLog("[Server reconnected]\n"); appendLog("[Update status reconnected]\n");
if ($modalStatus) $modalStatus.textContent = "Updating…"; if ($modalStatus) $modalStatus.textContent = "Updating…";
} }
if (data.log) appendLog(data.log); if (data.log) appendLog(data.log);
@@ -143,12 +244,58 @@ async function pollUpdateStatus() {
onUpdateDone(false); onUpdateDone(false);
} }
} catch (err) { } catch (err) {
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; } _updatePollFailures += 1;
if (_updatePollFailures >= STATUS_POLL_MAX_FAILURES) {
showUpdateStatusUnavailable();
return;
}
if (!_serverWasDown) {
_serverWasDown = true;
appendLog("\n[Update status connection interrupted — retrying…]\n");
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…";
}
} finally {
_updatePollInFlight = false;
}
}
function showUpdateStatusUnavailable() {
_updateFinished = true;
_updateStatusUnavailable = true;
stopUpdatePoll();
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($modalStatus) $modalStatus.textContent = "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");
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = true;
startUpdatePoll();
}
function resumeUpdateStatusAfterInterruption() {
if (!$modal || !$modal.classList.contains("open")) return;
if (_updateStatusUnavailable) {
retryUpdateStatus();
} else if (!_updateFinished) {
pollUpdateStatus();
} }
} }
function onUpdateDone(result) { function onUpdateDone(result) {
_updateStatusUnavailable = false;
if ($modalSpinner) $modalSpinner.classList.remove("spinning"); if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false; if ($btnCloseModal) $btnCloseModal.disabled = false;
if (result === true) { if (result === true) {
if ($modalStatus) $modalStatus.textContent = "✓ Update complete"; if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
@@ -175,6 +322,7 @@ function saveErrorReport() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
// ── Reboot ──────────────────────────────────────────────────────── // ── Reboot ────────────────────────────────────────────────────────
var _rebootStartTime = 0; var _rebootStartTime = 0;
+13 -27
View File
@@ -357,38 +357,24 @@ async function loadStep3() {
if (relevantDomains.length === 0) { 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>'; html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
} else { } 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;">' html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ '<p style="margin:0 0 8px;"><strong>Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS.</strong></p>' + renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
+ '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">' + renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
+ '<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>'; + '</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>'; 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>';
// Compact router note (full port guidance is shown when a feature is // Router note (the same wording is shown again, per-service, whenever a
// enabled, and lives on each service tile afterwards) // domain-based feature is enabled, and lives on each service tile afterwards)
var routerIpPart = internalIp
? ' to this computer&rsquo;s internal IP <strong>' + escHtml(internalIp) + '</strong>'
: ' to this computer&rsquo;s internal IP';
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">' html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ '🔌 <strong>One router task:</strong> in your router&rsquo;s <strong>port forwarding</strong> settings, forward ' + renderRouterPortsHtml({
+ 'port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>' internalIp: internalIp,
+ routerIpPart + '. Use the <strong>same number for the internal and external port</strong>. ' plural: true,
+ 'These are required for HTTPS and SSL certificates — without them your services cannot be reached from outside your home network. ' includeSsh: true,
+ 'Add port <strong>22 (TCP)</strong> as well if you want remote SSH access. ' extraNote: "Element Call needs a few extra ports (some UDP), and youll be shown exactly which when you enable it.",
+ 'Element Call needs a few extra ports (some UDP), and you&rsquo;ll be shown exactly which when you enable it.' })
+ '</div>'; + '</div>';
relevantDomains.forEach(function(d) { relevantDomains.forEach(function(d) {
+283
View File
@@ -0,0 +1,283 @@
"""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
+39
View File
@@ -18,6 +18,45 @@ def is_active(unit: str, scope: Literal["system", "user"] = "system") -> str:
return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown" 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: def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str:
return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown" return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown"
+13 -6
View File
@@ -21,13 +21,13 @@
<!-- Header bar --> <!-- Header bar -->
<header class="header-bar"> <header class="header-bar">
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" /> <img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
<div class="title-group">
<span class="title">Sovran_SystemsOS Hub</span> <span class="title">Sovran_SystemsOS Hub</span>
<!-- OS Version Badge --> <!-- OS Version Badge — styled identically to the version badge in service modals -->
<span class="os-version-badge" id="os-version-badge" title="Sovran_SystemsOS Version"> <span class="os-version-badge" id="os-version-badge" title="Sovran_SystemsOS Version">v{{ sovran_version }}</span>
<span class="version-label">v</span> </div>
<span class="version-number" id="version-number">{{ sovran_version }}</span>
</span>
<div class="header-buttons"> <div class="header-buttons">
<span class="role-badge" id="role-badge">Loading…</span> <span class="role-badge" id="role-badge">Loading…</span>
@@ -55,7 +55,12 @@
<div id="sidebar-support"></div> <div id="sidebar-support"></div>
<div id="sidebar-features"></div> <div id="sidebar-features"></div>
</aside> </aside>
<div id="tiles-area"></div> <div id="tiles-area">
<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> </main>
<!-- Update modal --> <!-- Update modal -->
@@ -69,6 +74,7 @@
<div class="modal-log" id="modal-log" aria-live="polite"></div> <div class="modal-log" id="modal-log" aria-live="polite"></div>
<div class="modal-footer"> <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-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-reboot" style="display:none">Restart Entire System</button> <button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button> <button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
</div> </div>
@@ -309,6 +315,7 @@
<script src="/static/js/constants.js?v={{ asset_version }}"></script> <script src="/static/js/constants.js?v={{ asset_version }}"></script>
<script src="/static/js/state.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/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/tiles.js?v={{ asset_version }}"></script>
<script src="/static/js/service-detail.js?v={{ asset_version }}"></script> <script src="/static/js/service-detail.js?v={{ asset_version }}"></script>
<script src="/static/js/support.js?v={{ asset_version }}"></script> <script src="/static/js/support.js?v={{ asset_version }}"></script>
@@ -125,9 +125,8 @@
<span class="onboarding-step-icon">🌐</span> <span class="onboarding-step-icon">🌐</span>
<h2 class="onboarding-step-title">Domain Configuration</h2> <h2 class="onboarding-step-title">Domain Configuration</h2>
<p class="onboarding-step-desc"> <p class="onboarding-step-desc">
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS. Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS, and your router needs ports <strong>80</strong> and <strong>443</strong> (TCP) forwarded to this computer.
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. Everything you need — Njal.la account, Dynamic records, and the one router task — is laid out step by step below.
Paste the curl command from your Njal.la dashboard for each service.
</p> </p>
</div> </div>
<div class="onboarding-card" id="step-3-body"> <div class="onboarding-card" id="step-3-body">
@@ -170,6 +169,7 @@
</div><!-- /panel-wrap --> </div><!-- /panel-wrap -->
</div><!-- /shell --> </div><!-- /shell -->
<script src="/static/js/domain-prereqs.js?v={{ asset_version }}"></script>
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script> <script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
</body> </body>
</html> </html>
+88
View File
@@ -0,0 +1,88 @@
"""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
+3 -3
View File
@@ -5,10 +5,10 @@
"bitcoind.service": "27.1.0", "bitcoind.service": "27.1.0",
"electrs.service": "0.10.6", "electrs.service": "0.10.6",
"lnd.service": "0.18.0", "lnd.service": "0.18.0",
"rtl.service": "0.15.2", "rtl.service": "0.15.10",
"btcpayserver.service": "2.0.0", "btcpayserver.service": "2.4.2",
"albyhub.service": "1.8.0", "albyhub.service": "1.8.0",
"mempool.service": "3.0.0", "mempool.service": "3.2.1",
"matrix-synapse.service": "1.115.0", "matrix-synapse.service": "1.115.0",
"livekit.service": "1.5.2", "livekit.service": "1.5.2",
"vaultwarden.service": "1.32.0", "vaultwarden.service": "1.32.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

+5 -7
View File
@@ -145,12 +145,13 @@
ranger fastfetch gedit openssl pwgen ranger fastfetch gedit openssl pwgen
aspell aspellDicts.en lm_sensors aspell aspellDicts.en lm_sensors
hunspell hunspellDicts.en_US hunspell hunspellDicts.en_US
synadm brave dua synadm brave-origin dua
gparted pv unzip parted screen zenity gparted pv unzip parted screen zenity
libargon2 gnome-terminal libreoffice-fresh libargon2 gnome-terminal libreoffice-fresh
dig firefox wp-cli axel dig firefox wp-cli axel
lk-jwt-service livekit-libwebrtc livekit lk-jwt-service livekit-libwebrtc livekit
matrix-synapse age onlyoffice-desktopeditors matrix-synapse age onlyoffice-desktopeditors
tor-browser
]; ];
# ── Shell ────────────────────────────────────────────────── # ── Shell ──────────────────────────────────────────────────
@@ -192,12 +193,9 @@ backup /etc/nix-bitcoin-secrets/ localhost/
}; };
# ── Cron ─────────────────────────────────────────────────── # ── Cron ───────────────────────────────────────────────────
services.cron = { # The legacy njalla.sh root cron job has been replaced by the systemd timer
enable = true; # defined in modules/core/njalla.nix (sovran-ddns-update.timer). Cron is
systemCronJobs = [ # retained so that rsnapshot and other module-defined cron jobs continue to run.
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh"
];
};
# ── Tor ──────────────────────────────────────────────────── # ── Tor ────────────────────────────────────────────────────
services.tor = { enable = true; client.enable = true; torsocks.enable = true; }; services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
Generated
+26 -156
View File
@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1784932759, "lastModified": 1787246616,
"narHash": "sha256-44/iCx+wiYukHGhvPm65ppJZ3FZZbp6f9JE5isz8TsA=", "narHash": "sha256-TTbXIBwoaPbzk2o+rGEayqOvrXqKo9JdYJAMDwqjCQ4=",
"owner": "emmanuelrosa", "owner": "emmanuelrosa",
"repo": "btc-clients-nix", "repo": "btc-clients-nix",
"rev": "8aab86c245ab9a2bea0d72175d6fd663a892af9f", "rev": "8b10c40cb13bac5d100ae1d1fb42eccc0d9c3223",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -18,32 +18,6 @@
"type": "github" "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": { "flake-parts": {
"inputs": { "inputs": {
"nixpkgs-lib": [ "nixpkgs-lib": [
@@ -52,11 +26,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1782949081, "lastModified": 1785627969,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", "narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", "rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -65,54 +39,13 @@
"type": "github" "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": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1782911660, "lastModified": 1785590095,
"narHash": "sha256-PbR+tJ5E/Ux+01UtdFKqblccVA4/FgWbkym4ev3VHHQ=", "narHash": "sha256-CNO2szJbdLjVN/Hi1BML9MSALz1GM2fIdwnzs404QO8=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "cf720c15e108d432d29041cc5a185630809acefb", "rev": "e568f3b19d54b08f48bfae9b12b3e124d1a28002",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -121,29 +54,13 @@
"type": "github" "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": { "nixpkgs-stable": {
"locked": { "locked": {
"lastModified": 1784856561, "lastModified": 1787101114,
"narHash": "sha256-J+Bx1Z6Oeoj2FgnBhRMKyUhhtDoOpTgXYaVLZpDjW4A=", "narHash": "sha256-gwrPcFf/rDjHPaVflbDZ040ZDmBTRj/7+s8ZmE2SaIM=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "597283ad8aa0b331c788e97c4c262d58877074ef", "rev": "b18a4b905f8d028dc4476412e6d6891728695379",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -153,45 +70,13 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs-unstable": {
"locked": {
"lastModified": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1778737229, "lastModified": 1787135253,
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=", "narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5", "rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1784796856,
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -201,13 +86,13 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_4": { "nixpkgs_3": {
"locked": { "locked": {
"lastModified": 1784555310, "lastModified": 1787111413,
"narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=", "narHash": "sha256-sFosWtq21eHGJRnTc/hvf4M1obRgLEUMNm/IzllkHMA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493", "rev": "afe3d8ac4395617bdcdac9f188ac8717a062e014",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -220,15 +105,15 @@
"nixvim": { "nixvim": {
"inputs": { "inputs": {
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"nixpkgs": "nixpkgs_4", "nixpkgs": "nixpkgs_3",
"systems": "systems_2" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1784814601, "lastModified": 1787151631,
"narHash": "sha256-T32JXjZ7kIbhBn8/Har171yGg6IBdl97cxAWameqZDE=", "narHash": "sha256-EblMdrDFBFNNlUPm5zUQdh0j6gDx+OFQPv7LFE4B5AA=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixvim", "repo": "nixvim",
"rev": "f316e949e0ed9df0e1e0bf645c6dce721d4e230e", "rev": "d0d62a2b5027da689b4e8d5ee43f1cf83f2e975d",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -240,28 +125,13 @@
"root": { "root": {
"inputs": { "inputs": {
"btc-clients": "btc-clients", "btc-clients": "btc-clients",
"nix-bitcoin": "nix-bitcoin", "nixpkgs": "nixpkgs_2",
"nixpkgs": "nixpkgs_3",
"nixpkgs-stable": "nixpkgs-stable", "nixpkgs-stable": "nixpkgs-stable",
"nixvim": "nixvim" "nixvim": "nixvim"
} }
}, },
"systems": { "systems": {
"locked": { "flake": false,
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": { "locked": {
"lastModified": 1774449309, "lastModified": 1774449309,
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=", "narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
Executable → Regular
+19 -13
View File
@@ -3,13 +3,12 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
nixvim.url = "github:nix-community/nixvim"; nixvim.url = "github:nix-community/nixvim";
btc-clients.url = "github:emmanuelrosa/btc-clients-nix"; btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05"; nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
}; };
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }: outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, ... }:
let let
overlay-stable = final: prev: { overlay-stable = final: prev: {
@@ -22,7 +21,7 @@
{ {
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem { nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
modules = [ modules = [
{ nixpkgs.hostPlatform = "x86_64-linux"; } { nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
self.nixosModules.Sovran_SystemsOS self.nixosModules.Sovran_SystemsOS
./hardware-configuration.nix ./hardware-configuration.nix
./role-state.nix ./role-state.nix
@@ -32,10 +31,9 @@
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem { nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
modules = [ modules = [
{ nixpkgs.hostPlatform = "x86_64-linux"; } { nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
./iso/common.nix ./iso/common.nix
nix-bitcoin.nixosModules.default ./modules/bitcoin
nixvim.nixosModules.nixvim nixvim.nixosModules.nixvim
]; ];
}; };
@@ -46,7 +44,7 @@
nixpkgs.overlays = [ overlay-stable ]; nixpkgs.overlays = [ overlay-stable ];
}) })
./configuration.nix ./configuration.nix
nix-bitcoin.nixosModules.default ./modules/bitcoin
nixvim.nixosModules.nixvim nixvim.nixosModules.nixvim
]; ];
config = { config = {
@@ -58,13 +56,21 @@
}; };
}; };
nixosTests.nwc-wallets-port-collision = checks.x86_64-linux = let
import ./nix/tests/nwc-wallets-port-collision.nix { pkgs = import nixpkgs {
inherit nixpkgs;
system = "x86_64-linux"; system = "x86_64-linux";
}; };
fetchNodeModules =
checks.x86_64-linux.nwc-wallets-port-collision = pkgs.callPackage ./packages/build-support/fetch-node-modules.nix {};
self.nixosTests.nwc-wallets-port-collision; mempoolPkgs =
pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; };
in {
bitcoin-btcpay-hardening = import ./tests/bitcoin-btcpay-hardening.nix {
inherit nixpkgs overlay-stable;
};
mempool-backend = mempoolPkgs.mempool-backend;
mempool-frontend = mempoolPkgs.mempool-frontend;
rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; };
};
}; };
} }
+1 -1
View File
@@ -9,7 +9,7 @@ let
else "dev"; else "dev";
# Clean version (remove 'v' prefix and newlines) # Clean version (remove 'v' prefix and newlines)
cleanVersion = builtins.replaceStrings ["v" "\n" "\r"] [""] versionFile; cleanVersion = builtins.replaceStrings ["v" "\n" "\r"] ["" "" ""] versionFile;
pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ps.pycairo ]); pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ps.pycairo ]);
+186 -23
View File
@@ -103,25 +103,81 @@ def human_size(nbytes):
return f"{nbytes:.1f} PB" return f"{nbytes:.1f} PB"
def check_internet(): def check_internet():
"""Return True if the machine can reach the 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."""
try: try:
result = subprocess.run( result = subprocess.run(
["ping", "-c", "1", "-W", "5", "nixos.org"], ["systemd-detect-virt", "--vm"],
capture_output=True, text=True capture_output=True, text=True
) )
if result.returncode == 0: if result.returncode == 0:
return True name = result.stdout.strip()
if name == "none":
return None
return name or "virtual machine"
except Exception: except Exception:
pass pass
# Fallback: try a second host in case DNS for nixos.org is down
try: # Fallback for minimal environments where systemd-detect-virt is absent.
result = subprocess.run( dmi_paths = [
["ping", "-c", "1", "-W", "5", "1.1.1.1"], "/sys/class/dmi/id/product_name",
capture_output=True, text=True "/sys/class/dmi/id/sys_vendor",
"/sys/class/dmi/id/board_vendor",
]
markers = (
"kvm", "qemu", "virtualbox", "vmware", "hyper-v",
"bhyve", "parallels", "xen", "bochs", "virtual",
) )
return result.returncode == 0 for dmi_path in dmi_paths:
except Exception: try:
return False 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}"'
def symbolic_icon(name): def symbolic_icon(name):
"""Create a crisp symbolic icon suitable for use as an ActionRow prefix.""" """Create a crisp symbolic icon suitable for use as an ActionRow prefix."""
@@ -159,6 +215,10 @@ class InstallerWindow(Adw.ApplicationWindow):
self.data_size = None self.data_size = None
self.data_drive_has_timechain = False self.data_drive_has_timechain = False
self.free_password = None 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 # Root navigation view
self.nav = Adw.NavigationView() self.nav = Adw.NavigationView()
@@ -343,7 +403,7 @@ class InstallerWindow(Adw.ApplicationWindow):
if os.path.exists(LOGO): if os.path.exists(LOGO):
try: try:
img = Gtk.Image.new_from_file(LOGO) img = Gtk.Image.new_from_file(LOGO)
img.set_pixel_size(480) img.set_pixel_size(320 if self.virtualization else 480)
hero.append(img) hero.append(img)
except Exception: except Exception:
pass pass
@@ -397,6 +457,42 @@ class InstallerWindow(Adw.ApplicationWindow):
notice_frame.set_child(notice_box) notice_frame.set_child(notice_box)
outer.append(notice_frame) outer.append(notice_frame)
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)
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)
vm_icon = symbolic_icon("computer-symbolic")
vm_icon.set_valign(Gtk.Align.START)
vm_box.append(vm_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."
)
vm_box.append(vm_lbl)
vm_frame.set_child(vm_box)
outer.append(vm_frame)
# Role label # Role label
role_lbl = Gtk.Label() role_lbl = Gtk.Label()
role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>") role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>")
@@ -448,7 +544,10 @@ class InstallerWindow(Adw.ApplicationWindow):
available = has_second_drive or key not in NEEDS_DATA_DRIVE available = has_second_drive or key not in NEEDS_DATA_DRIVE
if not available: if not available:
card.set_subtitle(desc + "\n⚠ Requires a second internal drive (not detected)") 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_sensitive(False) card.set_sensitive(False)
else: else:
card.set_subtitle(desc) card.set_subtitle(desc)
@@ -531,9 +630,10 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── OS Drive group ──────────────────────────────────────────── # ── OS Drive group ────────────────────────────────────────────
os_group = Adw.PreferencesGroup() os_group = Adw.PreferencesGroup()
os_group.set_title("OS Drive (NixOS Boot + Root)") os_group.set_title("OS Drive (NixOS Boot + Root)")
os_group.set_description( os_description = "Choose the drive for the NixOS installation. Minimum 256 GB required."
"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_margin_top(24) os_group.set_margin_top(24)
os_group.set_margin_start(40) os_group.set_margin_start(40)
os_group.set_margin_end(40) os_group.set_margin_end(40)
@@ -546,6 +646,8 @@ class InstallerWindow(Adw.ApplicationWindow):
row.set_title(f"/dev/{name}") row.set_title(f"/dev/{name}")
type_label = tran.upper() if tran else "Disk" type_label = tran.upper() if tran else "Disk"
meets = "✓ Meets 256 GB minimum" if size >= BYTES_256GB else "✗ Below 256 GB minimum" 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.set_subtitle(f"{human_size(size)} · {type_label}{meets}")
row.add_prefix(symbolic_icon("drive-harddisk-symbolic")) row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
@@ -570,11 +672,14 @@ class InstallerWindow(Adw.ApplicationWindow):
if self.role != "Desktop Only": if self.role != "Desktop Only":
data_group = Adw.PreferencesGroup() data_group = Adw.PreferencesGroup()
data_group.set_title("Bitcoin Timechain & Backups Drive") data_group.set_title("Bitcoin Timechain & Backups Drive")
data_group.set_description( data_description = (
"💡 Tip: Always assign your LARGEST drive here. " "💡 Tip: Always assign your LARGEST drive here. "
"The full Bitcoin timechain is over 700 GB and grows continuously — " "The full Bitcoin timechain is over 700 GB and grows continuously — "
"a 2 TB or larger drive is required." "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_top(20)
data_group.set_margin_start(40) data_group.set_margin_start(40)
data_group.set_margin_end(40) data_group.set_margin_end(40)
@@ -599,6 +704,8 @@ class InstallerWindow(Adw.ApplicationWindow):
row.set_title(f"/dev/{name}") row.set_title(f"/dev/{name}")
type_label = tran.upper() if tran else "Disk" type_label = tran.upper() if tran else "Disk"
meets = "✓ Meets 2 TB minimum" if size >= BYTES_2TB else "✗ Below 2 TB minimum" 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.set_subtitle(f"{human_size(size)} · {type_label}{meets}")
row.add_prefix(symbolic_icon("drive-harddisk-symbolic")) row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
@@ -655,10 +762,16 @@ class InstallerWindow(Adw.ApplicationWindow):
dlg = Adw.MessageDialog() dlg = Adw.MessageDialog()
dlg.set_transient_for(self) dlg.set_transient_for(self)
dlg.set_heading("OS Drive Too Small") dlg.set_heading("OS Drive Too Small")
dlg.set_body( body = (
f"The selected OS drive (/dev/{os_name}, {human_size(os_size)}) " 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." 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.add_response("ok", "OK")
dlg.present() dlg.present()
return return
@@ -668,12 +781,18 @@ class InstallerWindow(Adw.ApplicationWindow):
dlg = Adw.MessageDialog() dlg = Adw.MessageDialog()
dlg.set_transient_for(self) dlg.set_transient_for(self)
dlg.set_heading("Bitcoin Drive Too Small") dlg.set_heading("Bitcoin Drive Too Small")
dlg.set_body( body = (
f"The selected Bitcoin Timechain & Backups drive " f"The selected Bitcoin Timechain & Backups drive "
f"(/dev/{data_name}, {human_size(data_size)}) " f"(/dev/{data_name}, {human_size(data_size)}) "
f"does not meet the 2 TB minimum. " f"does not meet the 2 TB minimum. "
f"Please choose a larger drive or select \"None\"." 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.add_response("ok", "OK")
dlg.present() dlg.present()
return return
@@ -829,7 +948,12 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── Worker: partition ───────────────────────────────────────────────── # ── Worker: partition ─────────────────────────────────────────────────
def partition_path(self, dev_path, num): def partition_path(self, dev_path, num):
return f"{dev_path}p{num}" if "nvme" in dev_path else f"{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}"
def detect_existing_timechain_data(self, data_path, buf=None): def detect_existing_timechain_data(self, data_path, buf=None):
data_p1 = self.partition_path(data_path, 1) data_p1 = self.partition_path(data_path, 1)
@@ -900,12 +1024,19 @@ class InstallerWindow(Adw.ApplicationWindow):
time.sleep(2) time.sleep(2)
# ── Partition boot disk: 512M ESP + rest as root ── # ── Partition boot disk ──
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk ===\n") if self.boot_mode == "uefi":
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (UEFI) ===\n")
run_stream(["sudo", "sgdisk", run_stream(["sudo", "sgdisk",
"-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP", "-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP",
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root", "-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
boot_path], buf) 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) run_stream(["sudo", "partprobe", boot_path], buf)
time.sleep(2) time.sleep(2)
@@ -925,7 +1056,10 @@ class InstallerWindow(Adw.ApplicationWindow):
boot_p1 = self.partition_path(boot_path, 1) boot_p1 = self.partition_path(boot_path, 1)
boot_p2 = self.partition_path(boot_path, 2) boot_p2 = self.partition_path(boot_path, 2)
if self.boot_mode == "uefi":
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf) 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) run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
if data_path and not self.data_drive_has_timechain: if data_path and not self.data_drive_has_timechain:
@@ -935,8 +1069,11 @@ class InstallerWindow(Adw.ApplicationWindow):
# ── Mount filesystems ── # ── Mount filesystems ──
GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n") GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n")
run_stream(["sudo", "mount", boot_p2, "/mnt"], buf) 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", "mkdir", "-p", "/mnt/boot/efi"], buf)
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/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: if data_path:
data_p1 = self.partition_path(data_path, 1) data_p1 = self.partition_path(data_path, 1)
@@ -966,12 +1103,33 @@ class InstallerWindow(Adw.ApplicationWindow):
is_server = str(self.role == "Server+Desktop").lower() is_server = str(self.role == "Server+Desktop").lower()
is_desktop = str(self.role == "Desktop Only").lower() is_desktop = str(self.role == "Desktop Only").lower()
is_node = str(self.role == "Node (Bitcoin-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. content = f"""# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
{{ config, lib, ... }}: {{ config, lib, ... }}:
{{ {{
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault {is_server}; sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault {is_server};
sovran_systemsOS.roles.desktop = lib.mkDefault {is_desktop}; sovran_systemsOS.roles.desktop = lib.mkDefault {is_desktop};
sovran_systemsOS.roles.node = lib.mkDefault {is_node}; sovran_systemsOS.roles.node = lib.mkDefault {is_node};{boot_overrides}
}} }}
""" """
proc = subprocess.run( proc = subprocess.run(
@@ -1008,6 +1166,11 @@ class InstallerWindow(Adw.ApplicationWindow):
boot_row.set_subtitle(f"/dev/{self.boot_disk}{human_size(self.boot_size)}") boot_row.set_subtitle(f"/dev/{self.boot_disk}{human_size(self.boot_size)}")
details.add(boot_row) 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: if self.data_disk:
data_row = Adw.ActionRow() data_row = Adw.ActionRow()
data_row.set_title("Data Disk") data_row.set_title("Data Disk")
+81 -5
View File
@@ -12,6 +12,11 @@ Options:
--deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install --deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install
--headscale-server URL Headscale login server for post-install Tailnet --headscale-server URL Headscale login server for post-install Tailnet
--headscale-key KEY Headscale pre-auth key for the installed OS --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 USAGE
} }
@@ -25,6 +30,8 @@ DEPLOY_KEY=""
HEADSCALE_SERVER="" HEADSCALE_SERVER=""
HEADSCALE_KEY="" HEADSCALE_KEY=""
DATA_DISK_HAS_TIMECHAIN=false DATA_DISK_HAS_TIMECHAIN=false
BOOT_MODE=""
VIRTUALIZATION=""
FLAKE="/etc/sovran/flake" FLAKE="/etc/sovran/flake"
LOG="/tmp/sovran-headless-install.log" LOG="/tmp/sovran-headless-install.log"
@@ -69,6 +76,22 @@ case "$ROLE" in
*) die "--role must be one of: server, desktop, node" ;; *) die "--role must be one of: server, desktop, node" ;;
esac 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 ───────────────────────────────────────── # ── Validate disk existence and size ─────────────────────────────────────────
log "=== Validating disks ===" log "=== Validating disks ==="
@@ -96,13 +119,26 @@ fi
# ── Helper: partition suffix ────────────────────────────────────────────────── # ── Helper: partition suffix ──────────────────────────────────────────────────
part_suffix() { part_suffix() {
local dev="$1" n="$2" local dev="$1" n="$2"
if [[ "$dev" == *nvme* ]]; then local base
base=$(basename "$dev")
if [[ "$base" == nvme* || "$base" == mmcblk* || "$base" == loop* || "$base" == md* || "$base" =~ [0-9]$ ]]; then
echo "${dev}p${n}" echo "${dev}p${n}"
else else
echo "${dev}${n}" echo "${dev}${n}"
fi 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 ─────────────────────── # ── Detect existing Bitcoin timechain data on data disk ───────────────────────
if [[ -n "$DATA_DISK" ]]; then if [[ -n "$DATA_DISK" ]]; then
DATA_P1=$(part_suffix "$DATA_DISK" 1) DATA_P1=$(part_suffix "$DATA_DISK" 1)
@@ -142,12 +178,19 @@ partprobe "$DISK"
sleep 2 sleep 2
# ── Step 2: Partition OS disk ───────────────────────────────────────────────── # ── Step 2: Partition OS disk ─────────────────────────────────────────────────
log "=== Partitioning OS disk ===" if [[ "$BOOT_MODE" == "uefi" ]]; then
log "=== Partitioning OS disk (UEFI) ==="
sgdisk \ sgdisk \
-n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \ -n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \
-n "2:0:0" -t "2:8300" -c "2:root" \ -n "2:0:0" -t "2:8300" -c "2:root" \
"$DISK" "$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" partprobe "$DISK"
sleep 2 sleep 2
@@ -168,7 +211,11 @@ log "=== Formatting partitions ==="
BOOT_P1=$(part_suffix "$DISK" 1) BOOT_P1=$(part_suffix "$DISK" 1)
BOOT_P2=$(part_suffix "$DISK" 2) BOOT_P2=$(part_suffix "$DISK" 2)
if [[ "$BOOT_MODE" == "uefi" ]]; then
mkfs.vfat -F 32 "$BOOT_P1" mkfs.vfat -F 32 "$BOOT_P1"
else
log "Skipping ESP format for Legacy BIOS install"
fi
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2" mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
@@ -180,8 +227,12 @@ fi
log "=== Mounting filesystems ===" log "=== Mounting filesystems ==="
mount "$BOOT_P2" /mnt mount "$BOOT_P2" /mnt
if [[ "$BOOT_MODE" == "uefi" ]]; then
mkdir -p /mnt/boot/efi mkdir -p /mnt/boot/efi
mount -o umask=0077,defaults "$BOOT_P1" /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 if [[ -n "$DATA_DISK" ]]; then
DATA_P1=$(part_suffix "$DATA_DISK" 1) DATA_P1=$(part_suffix "$DATA_DISK" 1)
@@ -219,16 +270,41 @@ case "$ROLE" in
IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;; IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;;
esac esac
cat > /mnt/etc/nixos/role-state.nix <<EOF {
cat <<EOF
# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT. # THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
{ config, lib, ... }: { config, lib, ... }:
{ {
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER}; sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER};
sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP}; sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP};
sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE}; sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE};
}
EOF 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 ────────────────────────────── # ── Step 10: Write custom.nix with deploy config ──────────────────────────────
log "=== Writing custom.nix ===" log "=== Writing custom.nix ==="
-7
View File
@@ -1,7 +0,0 @@
{ config, pkgs, lib, ... }:
lib.mkIf config.sovran_systemsOS.features.bitcoin-core {
services.bitcoind.package = lib.mkForce config.nix-bitcoin.pkgs.bitcoind;
}
@@ -0,0 +1,70 @@
# RPC calls that are safe for public use
# Vendored from nix-bitcoin - do not fetch from upstream at runtime
[
"echo"
"getinfo"
"getindexinfo"
"help"
"ping"
"uptime"
# Blockchain
"getbestblockhash"
"getblock"
"getblockchaininfo"
"getblockcount"
"getblockfilter"
"getblockfrompeer"
"getblockhash"
"getblockheader"
"getblockstats"
"getchaintips"
"getchaintxstats"
"getdeploymentinfo"
"getdifficulty"
"getmempoolancestors"
"getmempooldescendants"
"getmempoolentry"
"getmempoolinfo"
"getrawmempool"
"gettxout"
"gettxoutproof"
"gettxoutsetinfo"
"scantxoutset"
"verifytxoutproof"
# Mining
"getblocktemplate"
"getmininginfo"
"getnetworkhashps"
# Network
"getnetworkinfo"
"getnodeaddresses"
"getpeerinfo"
# Rawtransactions
"analyzepsbt"
"combinepsbt"
"combinerawtransaction"
"converttopsbt"
"createpsbt"
"createrawtransaction"
"decodepsbt"
"decoderawtransaction"
"decodescript"
"finalizepsbt"
"fundrawtransaction"
"getrawtransaction"
"joinpsbts"
"sendrawtransaction"
"signrawtransactionwithkey"
"testmempoolaccept"
"utxoupdatepsbt"
# Util
"createmultisig"
"deriveaddresses"
"estimatesmartfee"
"getdescriptorinfo"
"signmessagewithprivkey"
"validateaddress"
"verifymessage"
# Zmq
"getzmqnotifications"
]
+507
View File
@@ -0,0 +1,507 @@
{ config, pkgs, lib, ... }:
with lib;
let
options = {
services.bitcoind = {
enable = mkEnableOption "Bitcoin daemon";
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen for peer connections.";
};
port = mkOption {
type = types.port;
default = if !cfg.regtest then 8333 else 18444;
defaultText = "if !cfg.regtest then 8333 else 18444";
description = "Port to listen for peer connections.";
};
onionPort = mkOption {
type = types.nullOr types.port;
# When the bitcoind onion service is enabled, add an onion-tagged socket
# to distinguish local connections from Tor connections
default = if (config.nix-bitcoin.onionServices.bitcoind.enable or false) then 8334 else null;
description = ''
Port to listen for Tor peer connections.
If set, inbound connections to this port are tagged as onion peers.
'';
};
listen = mkOption {
type = types.bool;
default = false;
description = ''
Listen for peer connections at `address:port`
and `address:onionPort` (if {option}`onionPort` is set).
'';
};
listenWhitelisted = mkOption {
type = types.bool;
default = false;
description = ''
Listen for peer connections at `address:whitelistedPort`.
Peers connected through this socket are automatically whitelisted.
'';
};
whitelistedPort = mkOption {
type = types.port;
default = 8335;
description = "See `listenWhitelisted`.";
};
getPublicAddressCmd = mkOption {
type = types.str;
default = "";
description = ''
Bash expression which outputs the public service address to announce to peers.
If left empty, no address is announced.
'';
};
package = mkOption {
type = types.package;
default = pkgs.bitcoind;
defaultText = "pkgs.bitcoind";
description = ''
The package providing bitcoind binaries.
'';
};
extraConfig = mkOption {
type = types.lines;
default = "";
example = ''
par=16
logips=1
'';
description = "Extra lines appended to {file}`bitcoin.conf`.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/bitcoind";
description = "The data directory for bitcoind.";
};
rpc = {
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = ''
Address to listen for JSON-RPC connections.
'';
};
port = mkOption {
type = types.port;
default = if !cfg.regtest then 8332 else 18443;
defaultText = "if !cfg.regtest then 8332 else 18443";
description = "Port to listen for JSON-RPC connections.";
};
threads = mkOption {
type = types.nullOr types.ints.u16;
default = null;
description = "The number of threads to service RPC calls.";
};
allowip = mkOption {
type = types.listOf types.str;
default = [ "127.0.0.1" ];
description = ''
Allow JSON-RPC connections from specified sources.
'';
};
users = mkOption {
default = {};
description = ''
Allowed users for JSON-RPC connections.
'';
example = {
alice = {
passwordHMAC = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
rpcwhitelist = [ "sendtoaddress" "getnewaddress" ];
};
};
type = with types; attrsOf (submodule ({ name, ... }: {
options = {
name = mkOption {
type = types.str;
default = name;
example = "alice";
description = ''
Username for JSON-RPC connections.
'';
};
passwordHMAC = mkOption {
type = types.str;
example = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
description = ''
Password HMAC-SHA-256 for JSON-RPC connections. Must be a string of the
format `<SALT-HEX>$<HMAC-HEX>`.
'';
};
passwordHMACFromFile = mkOption {
type = lib.types.bool;
internal = true;
default = false;
};
rpcwhitelist = mkOption {
type = types.listOf types.str;
default = [];
description = ''
List of allowed rpc calls for each user.
If empty list, rpcwhitelist is disabled for that user.
'';
};
};
}));
};
};
regtest = mkOption {
type = types.bool;
default = false;
description = "Enable regtest mode.";
};
network = mkOption {
readOnly = true;
default = if cfg.regtest then "regtest" else "mainnet";
};
makeNetworkName = mkOption {
readOnly = true;
default = mainnet: regtest: if cfg.regtest then regtest else mainnet;
};
proxy = mkOption {
type = types.nullOr types.str;
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
description = "Connect through SOCKS5 proxy";
};
i2p = mkOption {
type = types.enum [ false true "only-outgoing" ];
default = false;
description = ''
Enable peer connections via i2p.
With `only-outgoing`, incoming i2p connections are disabled.
'';
};
dataDirReadableByGroup = mkOption {
type = types.bool;
default = false;
description = ''
If enabled, data dir content is readable by the bitcoind service group.
Warning: This disables bitcoind's wallet support.
'';
};
sysperms = mkOption {
type = types.nullOr types.bool;
default = null;
description = ''
Create new files with system default permissions, instead of umask 077
(only effective with disabled wallet functionality)
'';
};
disablewallet = mkOption {
type = types.nullOr types.bool;
default = null;
description = ''
Do not load the wallet and disable wallet RPC calls
'';
};
dbCache = mkOption {
type = types.nullOr (intAtLeast 4);
default = null;
example = 4000;
description = "Override the default database cache size in MiB.";
};
prune = mkOption {
type = types.ints.unsigned;
default = 0;
example = 10000;
description = ''
Automatically prune block files to stay under the specified target size in MiB.
Value 0 disables pruning.
'';
};
txindex = mkOption {
type = types.bool;
default = false;
description = "Enable the transaction index.";
};
zmqpubrawblock = mkOption {
type = types.nullOr types.str;
default = null;
example = "tcp://127.0.0.1:28332";
description = "ZMQ address for zmqpubrawblock notifications";
};
zmqpubrawtx = mkOption {
type = types.nullOr types.str;
default = null;
example = "tcp://127.0.0.1:28333";
description = "ZMQ address for zmqpubrawtx notifications";
};
assumevalid = mkOption {
type = types.nullOr types.str;
default = null;
example = "00000000000000000000e5abc3a74fe27dc0ead9c70ea1deb456f11c15fd7bc6";
description = ''
If this block is in the chain assume that it and its ancestors are
valid and potentially skip their script verification.
'';
};
addnodes = mkOption {
type = types.listOf types.str;
default = [];
example = [ "ecoc5q34tmbq54wl.onion" ];
description = "Add nodes to connect to and attempt to keep the connections open";
};
discover = mkOption {
type = types.nullOr types.bool;
default = null;
description = "Discover own IP addresses";
};
addresstype = mkOption {
type = types.nullOr types.str;
default = null;
example = "bech32";
description = "The type of addresses to use";
};
user = mkOption {
type = types.str;
default = "bitcoin";
description = "The user as which to run bitcoind.";
};
group = mkOption {
type = types.str;
default = cfg.user;
description = "The group as which to run bitcoind.";
};
cli = mkOption {
readOnly = true;
type = types.package;
default = pkgs.writers.writeBashBin "bitcoin-cli" ''
exec ${cfg.package}/bin/bitcoin-cli -datadir='${cfg.dataDir}' "$@"
'';
defaultText = "(See source)";
description = "Binary to connect with the bitcoind instance.";
};
tor = nbLib.tor;
};
};
cfg = config.services.bitcoind;
nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
# nixpkgs 26.11 moved i2pd's protocol configuration from
# `services.i2pd.proto` to the RFC42-style `services.i2pd.settings`.
i2pSAM = config.services.i2pd.settings.sam;
configFile = builtins.toFile "bitcoin.conf" ''
# We're already logging via journald
nodebuglogfile=1
logtimestamps=0
startupnotify=/run/current-system/systemd/bin/systemd-notify --ready
${optionalString cfg.regtest ''
regtest=1
[regtest]
''}
${optionalString (cfg.dbCache != null) "dbcache=${toString cfg.dbCache}"}
prune=${toString cfg.prune}
${optionalString cfg.txindex "txindex=1"}
${optionalString (cfg.sysperms != null) "sysperms=${if cfg.sysperms then "1" else "0"}"}
${optionalString (cfg.disablewallet != null) "disablewallet=${if cfg.disablewallet then "1" else "0"}"}
${optionalString (cfg.assumevalid != null) "assumevalid=${cfg.assumevalid}"}
# Connection options
listen=${if (cfg.listen || cfg.listenWhitelisted) then "1" else "0"}
${optionalString cfg.listen
"bind=${cfg.address}:${toString cfg.port}"}
${optionalString (cfg.listen && cfg.onionPort != null)
"bind=${cfg.address}:${toString cfg.onionPort}=onion"}
${optionalString cfg.listenWhitelisted
"whitebind=${cfg.address}:${toString cfg.whitelistedPort}"}
${optionalString (cfg.proxy != null) "proxy=${cfg.proxy}"}
${optionalString (cfg.i2p != false) "i2psam=${nbLib.addressWithPort i2pSAM.address i2pSAM.port}"}
${optionalString (cfg.i2p == "only-outgoing") "i2pacceptincoming=0"}
${optionalString (cfg.discover != null) "discover=${if cfg.discover then "1" else "0"}"}
${lib.concatMapStrings (node: "addnode=${node}\n") cfg.addnodes}
# RPC server options
rpcbind=${cfg.rpc.address}
rpcport=${toString cfg.rpc.port}
rpcconnect=${cfg.rpc.address}
${optionalString (cfg.rpc.threads != null) "rpcthreads=${toString cfg.rpc.threads}"}
rpcwhitelistdefault=0
${concatMapStrings (user: ''
${optionalString (!user.passwordHMACFromFile) "rpcauth=${user.name}:${user.passwordHMAC}"}
${optionalString (user.rpcwhitelist != [])
"rpcwhitelist=${user.name}:${lib.strings.concatStringsSep "," user.rpcwhitelist}"}
'') (builtins.attrValues cfg.rpc.users)
}
${lib.concatMapStrings (rpcallowip: "rpcallowip=${rpcallowip}\n") cfg.rpc.allowip}
# Wallet options
${optionalString (cfg.addresstype != null) "addresstype=${cfg.addresstype}"}
# ZMQ options
${optionalString (cfg.zmqpubrawblock != null) "zmqpubrawblock=${cfg.zmqpubrawblock}"}
${optionalString (cfg.zmqpubrawtx != null) "zmqpubrawtx=${cfg.zmqpubrawtx}"}
# Extra options
${cfg.extraConfig}
'';
zmqServerEnabled = (cfg.zmqpubrawblock != null) || (cfg.zmqpubrawtx != null);
intAtLeast = n: types.addCheck types.int (x: x >= n) // {
name = "intAtLeast";
description = "integer >= ${toString n}";
};
in {
inherit options;
config = mkIf cfg.enable {
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
services.bitcoind = mkMerge [
(mkIf cfg.dataDirReadableByGroup {
disablewallet = true;
sysperms = true;
})
{
rpc.users.privileged = {
passwordHMACFromFile = true;
};
rpc.users.public = {
passwordHMACFromFile = true;
rpcwhitelist = import ./bitcoind-rpc-public-whitelist.nix;
};
}
];
services.i2pd = mkIf (cfg.i2p != false) {
enable = true;
settings.sam = {
enabled = true;
address = "127.0.0.1";
port = 7656;
};
};
systemd.tmpfiles.rules = [
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
];
systemd.services.bitcoind = rec {
wants = [
"network-online.target"
# Use `wants` instead of `requires` for `nix-bitcoin-secrets.target`
# so that bitcoind and all dependent services are not restarted when
# the secrets target restarts.
# The secrets target always restarts when deploying with one of the methods
# in ./deployment.
#
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
# that propagates initial start failures but no restarts
"nix-bitcoin-secrets.target"
];
after = wants;
wantedBy = [ "multi-user.target" ];
preStart = let
extraRpcauth = concatMapStrings (name: let
user = cfg.rpc.users.${name};
in optionalString user.passwordHMACFromFile ''
hmacPayload="$(readValidatedRpcHmac '${secretsDir}/bitcoin-HMAC-${name}')" || exit 1
printf '%s\n' "rpcauth=${user.name}:$hmacPayload"
''
) (builtins.attrNames cfg.rpc.users);
in ''
${optionalString cfg.dataDirReadableByGroup ''
if [[ -e '${cfg.dataDir}/blocks' ]]; then
chmod -R g+rX '${cfg.dataDir}/blocks'
fi
''}
readValidatedRpcHmac() {
local hmacFile="$1"
local hmacPayload
if [[ ! -e "$hmacFile" ]]; then
echo "Error: Bitcoin RPC HMAC file is missing: $hmacFile" >&2
return 1
fi
if [[ ! -r "$hmacFile" ]]; then
echo "Error: Bitcoin RPC HMAC file is unreadable: $hmacFile" >&2
return 1
fi
hmacPayload="$(<"$hmacFile")"
if [[ -z "$hmacPayload" ]]; then
echo "Error: Bitcoin RPC HMAC file is empty: $hmacFile" >&2
return 1
fi
if [[ ! "$hmacPayload" =~ ^[[:xdigit:]]+\$[[:xdigit:]]+$ ]]; then
echo "Error: Bitcoin RPC HMAC file has invalid format: $hmacFile" >&2
return 1
fi
printf '%s\n' "$hmacPayload"
}
cfg=$(
cat ${configFile}
${extraRpcauth}
echo
${optionalString (cfg.getPublicAddressCmd != "") ''
echo "externalip=$(${cfg.getPublicAddressCmd})"
''}
)
confFile='${cfg.dataDir}/bitcoin.conf'
if [[ ! -e $confFile || $cfg != $(cat $confFile) ]]; then
install -o '${cfg.user}' -g '${cfg.group}' -m 640 <(echo "$cfg") $confFile
fi
'';
# Enable RPC access for group
postStart = ''
chmod g=r '${cfg.dataDir}/${optionalString cfg.regtest "regtest/"}.cookie'
'' + (optionalString cfg.regtest) ''
chmod g=x '${cfg.dataDir}/regtest'
'';
serviceConfig = nbLib.defaultHardening // {
Type = "notify";
NotifyAccess = "all";
User = cfg.user;
Group = cfg.group;
TimeoutStartSec = "30min";
TimeoutStopSec = "30min";
ExecStart = "${cfg.package}/bin/bitcoind -datadir='${cfg.dataDir}'";
Restart = "on-failure";
UMask = mkIf cfg.dataDirReadableByGroup "0027";
ReadWritePaths = [ cfg.dataDir ];
} // nbLib.allowedIPAddresses cfg.tor.enforce
// optionalAttrs zmqServerEnabled nbLib.allowNetlink;
};
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
};
users.groups.${cfg.group} = {};
users.groups.bitcoinrpc-public = {};
nix-bitcoin.operator.groups = [ cfg.group ];
nix-bitcoin.secrets = {
bitcoin-rpcpassword-privileged.user = cfg.user;
bitcoin-rpcpassword-public = {
user = cfg.user;
group = "bitcoinrpc-public";
};
bitcoin-HMAC-privileged.user = cfg.user;
bitcoin-HMAC-public.user = cfg.user;
};
nix-bitcoin.generateSecretsCmds.bitcoind = ''
makeBitcoinRPCPassword privileged
makeBitcoinRPCPassword public
'';
};
}
+334
View File
@@ -0,0 +1,334 @@
{ config, lib, pkgs, ... }:
with lib;
let
options.services = {
nbxplorer = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Enable nbxplorer, a lightweight API for Bitcoin HD wallets.
Access API documentation here:
{option}`services.nbxplorer.address`:{option}`services.nbxplorer.port`
'';
};
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen on.";
};
port = mkOption {
type = types.port;
default = 24444;
description = "Port to listen on.";
};
package = mkOption {
type = types.package;
default = pkgs.stable.nbxplorer;
defaultText = "pkgs.stable.nbxplorer";
description = "The package providing nbxplorer binaries.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/nbxplorer";
description = "The data directory for nbxplorer.";
};
# TODO-EXTERNAL:
# The shortcut link `main` in the datadir has changed to a directory
# in version 2.3.3.
# Add a dummy symlink, if it does not already exist, to be compatible with older modules.
# When the old system uses a link and the new system a directory, switching fails with:
# mv: cannot move '/var/lib/nbxplorer/Main' to '/var/lib/nbxplorer/.Main.tmp':
# No such file or directory
#
# Remove this option when it is irrelevant (i.e. when the old system will never
# be nix-bitcoin <=0.0.91)
addNetworkSymlink = mkOption {
readOnly = true;
default = pkgs.stable.nbxplorer != cfg.nbxplorer.package;
description = ''
Whether to add a compatibility symlink (like `${cfg.nbxplorer.dataDir}/Main`)
to the dataDir.
This is enabled by default if the nbxplorer package is set to the version-locked package.
'';
};
user = mkOption {
type = types.str;
default = "nbxplorer";
description = "The user as which to run NBXplorer.";
};
group = mkOption {
type = types.str;
default = cfg.nbxplorer.user;
description = "The group as which to run NBXplorer.";
};
tor = nbLib.tor;
};
btcpayserver = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Enable BTCPay Server, a self-hosted, open-source payment processor.
Extra recommendations:
- Enable `services.btcpayserver.lightningBackend` to provide Lightning payment support.
- Secure this service if the instance is publically accessible. For example, set
{option}`services.btcpayserver.address` to `127.0.0.1` and use a reverse proxy
that enforces TLS (Transport Layer Security).
'';
};
package = mkOption {
type = types.package;
default = pkgs.stable.btcpayserver;
defaultText = "pkgs.stable.btcpayserver";
description = "The package providing BTCPay Server binaries.";
};
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen on.";
};
port = mkOption {
type = types.port;
default = 23000;
description = "Port to listen on.";
};
lightningBackend = mkOption {
type = types.nullOr (types.enum [ "lnd" ]);
default = null;
description = ''
The lightning node to use as a backend.
Enables the node service if not already enabled.
'';
};
lbtc = mkOption {
readOnly = true;
default = false;
description = ''
Enable Liquid support.
'';
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/btcpayserver";
description = "The data directory for BTCPay Server.";
};
user = mkOption {
type = types.str;
default = "btcpayserver";
description = "The user as which to run BTCPay Server.";
};
group = mkOption {
type = types.str;
default = cfg.btcpayserver.user;
description = "The group as which to run BTCPay Server.";
};
tor = nbLib.tor;
};
};
cfg = {
inherit (config.services)
nbxplorer
btcpayserver
bitcoind;
};
nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
in {
inherit options;
config = mkMerge [
(mkIf cfg.nbxplorer.enable {
systemd.tmpfiles.rules = [
"d '${cfg.nbxplorer.dataDir}' 0770 ${cfg.nbxplorer.user} ${cfg.nbxplorer.group} - -"
] ++ optional cfg.nbxplorer.addNetworkSymlink
"L+ '${cfg.nbxplorer.dataDir}/Main' - - - - '${cfg.nbxplorer.dataDir}/main'";
systemd.services.nbxplorer = let
configFile = builtins.toFile "nbxplorer-config" ''
network=${cfg.bitcoind.network}
btcrpcuser=${cfg.bitcoind.rpc.users.btcpayserver.name}
btcrpcurl=http://${nbLib.addressWithPort cfg.bitcoind.rpc.address cfg.bitcoind.rpc.port}
btcnodeendpoint=${nbLib.addressWithPort cfg.bitcoind.address cfg.bitcoind.whitelistedPort}
bind=${cfg.nbxplorer.address}
port=${toString cfg.nbxplorer.port}
postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
'';
in rec {
wantedBy = [ "multi-user.target" ];
requires = [ "postgresql.target" ];
wants = [ "bitcoind.service" ];
after = requires ++ wants ++ [ "nix-bitcoin-secrets.target" ];
preStart = ''
install -m 600 ${configFile} '${cfg.nbxplorer.dataDir}/settings.config'
printf '%s\n' "btcrpcpassword=$(<${secretsDir}/bitcoin-rpcpassword-btcpayserver)" \
>> '${cfg.nbxplorer.dataDir}/settings.config'
'';
serviceConfig = nbLib.defaultHardening // {
ExecStart = ''
${cfg.nbxplorer.package}/bin/nbxplorer --conf=${cfg.nbxplorer.dataDir}/settings.config \
--datadir='${cfg.nbxplorer.dataDir}'
'';
RuntimeDirectory = "nbxplorer";
StateDirectory = "nbxplorer";
User = cfg.nbxplorer.user;
Group = cfg.nbxplorer.group;
Restart = "on-failure";
RestartSec = "10s";
ReadWritePaths = [ cfg.nbxplorer.dataDir ];
MemoryDenyWriteExecute = false;
} // nbLib.allowedIPAddresses cfg.nbxplorer.tor.enforce;
};
services.bitcoind = {
enable = true;
listenWhitelisted = true;
txindex = true;
};
users.users.${cfg.nbxplorer.user} = {
isSystemUser = true;
group = cfg.nbxplorer.group;
home = cfg.nbxplorer.dataDir;
};
users.groups.${cfg.nbxplorer.group} = {};
})
(mkIf cfg.btcpayserver.enable {
services.nbxplorer.enable = true;
services.bitcoind = {
listenWhitelisted = true;
rpc.users.btcpayserver = {
name = "btcpayserver";
passwordHMACFromFile = true;
rpcwhitelist = [
"getblockchaininfo"
"getblock"
"getblockhash"
"getblockheader"
"getblockstats"
"gettransaction"
"getrawtransaction"
"sendrawtransaction"
"getblockcount"
"getbestblockhash"
"getnetworkinfo"
"getpeerinfo"
"estimatesmartfee"
"getmempoolinfo"
"getmempoolentry"
"getrawmempool"
"gettxout"
"scantxoutset"
"importmulti"
"listunspent"
"getwalletinfo"
"listtransactions"
"listreceivedbyaddress"
"getnewaddress"
"uptime"
"getrpcinfo"
];
};
};
systemd.tmpfiles.rules = [
"d '${cfg.btcpayserver.dataDir}' 0770 ${cfg.btcpayserver.user} ${cfg.btcpayserver.group} - -"
];
systemd.services.btcpayserver = let
nbExplorerUrl = "http://${nbLib.addressWithPort cfg.nbxplorer.address cfg.nbxplorer.port}/";
nbExplorerCookie =
"${cfg.nbxplorer.dataDir}/${cfg.bitcoind.makeNetworkName "Main" "RegTest"}/.cookie";
configFile = builtins.toFile "btcpayserver-config" (
''
network=${cfg.bitcoind.network}
bind=${cfg.btcpayserver.address}
port=${toString cfg.btcpayserver.port}
socksendpoint=${config.nix-bitcoin.torClientAddressWithPort}
btcexplorerurl=${nbExplorerUrl}
btcexplorercookiefile=${nbExplorerCookie}
explorer.postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
postgres=User ID=${cfg.btcpayserver.user};Host=/run/postgresql;Database=btcpayserver
'' + optionalString (cfg.btcpayserver.lightningBackend == "lnd")
(
"btclightning=type=lnd-rest;"
+ "server=https://${nbLib.address config.services.lnd.restAddress}:${toString config.services.lnd.restPort}/;"
+ "macaroonfilepath=/run/lnd/btcpayserver.macaroon;"
+ "certfilepath=${config.services.lnd.certPath}\n"
)
);
in rec {
wantedBy = [ "multi-user.target" ];
requires = [ "postgresql.target" "nbxplorer.service" ];
wants = optional (cfg.btcpayserver.lightningBackend == "lnd") "lnd.service";
after = requires ++ wants;
serviceConfig = nbLib.defaultHardening // {
ExecStart = ''
${cfg.btcpayserver.package}/bin/btcpayserver --conf=${configFile} \
--datadir='${cfg.btcpayserver.dataDir}'
'';
WorkingDirectory = "${cfg.btcpayserver.package}/lib/btcpayserver";
RuntimeDirectory = "btcpayserver";
StateDirectory = "btcpayserver";
User = cfg.btcpayserver.user;
Group = cfg.btcpayserver.group;
Restart = "on-failure";
RestartSec = "10s";
ReadWritePaths = [ cfg.btcpayserver.dataDir ];
MemoryDenyWriteExecute = false;
} // nbLib.allowedIPAddresses cfg.btcpayserver.tor.enforce;
};
services.postgresql = {
enable = true;
ensureDatabases = [ "btcpayserver" "nbxplorer" ];
ensureUsers = [
{ name = cfg.btcpayserver.user; ensureDBOwnership = true; }
{ name = cfg.nbxplorer.user; ensureDBOwnership = true; }
];
};
users.users.${cfg.btcpayserver.user} = {
isSystemUser = true;
group = cfg.btcpayserver.group;
home = cfg.btcpayserver.dataDir;
extraGroups = optional (cfg.btcpayserver.lightningBackend == "lnd") config.services.lnd.group;
};
users.groups.${cfg.btcpayserver.group} = {};
nix-bitcoin.secrets = {
bitcoin-rpcpassword-btcpayserver = {
user = cfg.bitcoind.user;
group = cfg.nbxplorer.group;
};
bitcoin-HMAC-btcpayserver.user = cfg.bitcoind.user;
};
nix-bitcoin.generateSecretsCmds.btcpayserver = ''
makeBitcoinRPCPassword btcpayserver
'';
})
(mkIf (cfg.btcpayserver.enable && cfg.btcpayserver.lightningBackend == "lnd") {
services.lnd = {
enable = true;
macaroons.btcpayserver = {
user = cfg.btcpayserver.user;
permissions = ''
{"entity":"address","action":"write"},{"entity":"info","action":"read"},{"entity":"invoices","action":"read"},{"entity":"invoices","action":"write"},{"entity":"offchain","action":"read"},{"entity":"offchain","action":"write"},{"entity":"onchain","action":"read"},{"entity":"onchain","action":"write"},{"entity":"peers","action":"read"},{"entity":"peers","action":"write"}
'';
};
};
users.users.${config.services.lnd.user}.extraGroups = [ cfg.btcpayserver.group ];
})
];
}
+15
View File
@@ -0,0 +1,15 @@
# Common Bitcoin infrastructure: secrets, onion services, nodeinfo, security
# Extracted from nix-bitcoin, tailored for Sovran (lnd-only)
{ config, lib, pkgs, ... }:
{
imports = [
./nix-bitcoin.nix
./secrets/secrets.nix
./operator.nix
./security.nix
./onion-addresses.nix
./onion-services.nix
./nodeinfo.nix
./versioning.nix
];
}
+16
View File
@@ -0,0 +1,16 @@
# Sovran Bitcoin stack - tailored from nix-bitcoin, lnd-only, nixpkgs packages
# Original: https://github.com/fort-nix/nix-bitcoin
{
imports = [
./common.nix
./bitcoind.nix
./electrs.nix
./lnd.nix
./lndconnect.nix
./rtl.nix
./btcpayserver.nix
./mempool.nix
];
disabledModules = [ "services/networking/bitcoind.nix" ];
}
+106
View File
@@ -0,0 +1,106 @@
{ config, lib, pkgs, ... }:
with lib;
let
options.services.electrs = {
enable = mkEnableOption "electrs, an Electrum server implemented in Rust";
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen for RPC connections.";
};
port = mkOption {
type = types.port;
default = 50001;
description = "Port to listen for RPC connections.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/electrs";
description = "The data directory for electrs.";
};
monitoringPort = mkOption {
type = types.port;
default = 4224;
description = "Prometheus monitoring port.";
};
extraArgs = mkOption {
type = types.separatedString " ";
default = "";
description = "Extra command line arguments passed to electrs.";
};
user = mkOption {
type = types.str;
default = "electrs";
description = "The user as which to run electrs.";
};
group = mkOption {
type = types.str;
default = cfg.user;
description = "The group as which to run electrs.";
};
tor.enforce = nbLib.tor.enforce;
};
cfg = config.services.electrs;
nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
bitcoind = config.services.bitcoind;
in {
inherit options;
config = mkIf cfg.enable {
assertions = [
{ assertion = bitcoind.prune == 0;
message = "electrs does not support bitcoind pruning.";
}
];
services.bitcoind = {
enable = true;
listenWhitelisted = true;
};
systemd.tmpfiles.rules = [
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
];
systemd.services.electrs = {
wantedBy = [ "multi-user.target" ];
requires = [ "bitcoind.service" ];
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
preStart = ''
echo "auth = \"${bitcoind.rpc.users.public.name}:$(cat ${secretsDir}/bitcoin-rpcpassword-public)\"" \
> electrs.toml
'';
serviceConfig = nbLib.defaultHardening // {
# electrs only uses the working directory for reading electrs.toml
WorkingDirectory = cfg.dataDir;
ExecStart = ''
${pkgs.electrs}/bin/electrs \
--log-filters=INFO \
--network=${bitcoind.makeNetworkName "bitcoin" "regtest"} \
--db-dir='${cfg.dataDir}' \
--daemon-dir='${bitcoind.dataDir}' \
--electrum-rpc-addr=${cfg.address}:${toString cfg.port} \
--monitoring-addr=${cfg.address}:${toString cfg.monitoringPort} \
--daemon-rpc-addr=${nbLib.addressWithPort bitcoind.rpc.address bitcoind.rpc.port} \
--daemon-p2p-addr=${nbLib.addressWithPort bitcoind.address bitcoind.whitelistedPort} \
${cfg.extraArgs}
'';
User = cfg.user;
Group = cfg.group;
Restart = "on-failure";
RestartSec = "10s";
ReadWritePaths = [ cfg.dataDir ];
} // nbLib.allowedIPAddresses cfg.tor.enforce;
};
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
extraGroups = [ "bitcoinrpc-public" ];
};
users.groups.${cfg.group} = {};
};
}
+132
View File
@@ -0,0 +1,132 @@
lib: pkgs: config:
with lib;
# See `man systemd.exec` and `man systemd.resource-control` for an explanation
# of the systemd-related options available through this file.
let self = {
# These settings roughly follow systemd's "strict" security profile
defaultHardening = {
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
NoNewPrivileges = true;
PrivateDevices = true;
MemoryDenyWriteExecute = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectClock = true;
ProtectProc = "invisible";
ProcSubset = "pid";
ProtectControlGroups = true;
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
RestrictNamespaces = true;
LockPersonality = true;
IPAddressDeny = "any";
PrivateUsers = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
RestrictRealtime = true;
ProtectHostname = true;
CapabilityBoundingSet = "";
# @system-service whitelist and docker seccomp blacklist (except for "clone"
# which is a core requirement for systemd services)
# @system-service is defined in src/shared/seccomp-util.c (systemd source)
SystemCallFilter = [ "@system-service" "~add_key kcmp keyctl mbind move_pages name_to_handle_at personality process_vm_readv process_vm_writev request_key setns unshare userfaultfd" ];
SystemCallArchitectures = "native";
};
allowNetlink = {
RestrictAddressFamilies = self.defaultHardening.RestrictAddressFamilies + " AF_NETLINK";
};
nodejs = {
# Required for JIT compilation
MemoryDenyWriteExecute = false;
# Required by nodejs >= 18
SystemCallFilter = self.defaultHardening.SystemCallFilter ++ [ "@pkey" ];
};
# Allow takes precedence over Deny.
allowLocalIPAddresses = {
IPAddressAllow = [
"127.0.0.1/32"
"::1/128"
"169.254.0.0/16"
];
};
allowAllIPAddresses = { IPAddressAllow = "any"; };
allowTor = self.allowLocalIPAddresses;
allowedIPAddresses = onlyLocal:
if onlyLocal
then self.allowLocalIPAddresses
else self.allowAllIPAddresses;
tor = {
proxy = mkOption {
type = types.bool;
default = false;
description = "Whether to proxy outgoing connections with Tor.";
};
enforce = mkOption {
type = types.bool;
default = false;
description = ''
Whether to enforce Tor on this service by only allowing connections
from and to localhost and link-local addresses.
'';
};
};
script = name: src: pkgs.writers.writeBash name ''
set -eo pipefail
${src}
'';
# Used for ExecStart*
rootScript = name: src: "+${self.script name src}";
cliExec = mkOption {
# Used by netns-isolation to execute the cli in the service's private netns
internal = true;
type = types.str;
default = "exec";
};
mkOnionService = map: {
map = [ map ];
version = 3;
};
# Convert a bind address, which may be a special INADDR_ANY address,
# to an actual IP address
address = addr:
if addr == "0.0.0.0" then
"127.0.0.1"
else if addr == "::" then
"::1"
else
addr;
addressWithPort = addr: port: "${self.address addr}:${toString port}";
optionalAttr = cond: name: if cond then name else null;
mkCertExtraAltNames = cert:
builtins.concatStringsSep "," (
(map (domain: "DNS:${domain}") cert.extraDomains) ++
(map (ip: "IP:${ip}") cert.extraIPs)
);
test = {
mkIfTest = test: mkIf (config.tests.${test} or false);
};
mkAlias = default: mkOption {
internal = true;
readOnly = true;
inherit default;
};
}; in self
+316
View File
@@ -0,0 +1,316 @@
{ config, lib, pkgs, ... }:
with lib;
let
options.services.lnd = {
enable = mkEnableOption "Lightning Network daemon, a Lightning Network implementation in Go";
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen for peer connections";
};
port = mkOption {
type = types.port;
default = 9735;
description = "Port to listen for peer connections";
};
rpcAddress = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen for RPC connections.";
};
rpcPort = mkOption {
type = types.port;
default = 10009;
description = "Port to listen for gRPC connections.";
};
restAddress = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen for REST connections.";
};
restPort = mkOption {
type = types.port;
default = 8080;
description = "Port to listen for REST connections.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/lnd";
description = "The data directory for LND.";
};
networkDir = mkOption {
readOnly = true;
default = "${cfg.dataDir}/chain/bitcoin/${bitcoind.network}";
description = "The network data directory.";
};
tor-socks = mkOption {
type = types.nullOr types.str;
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
description = "Socks proxy for connecting to Tor nodes";
};
macaroons = mkOption {
default = {};
type = with types; attrsOf (submodule {
options = {
user = mkOption {
type = types.str;
description = "User who owns the macaroon.";
};
permissions = mkOption {
type = types.str;
example = ''
{"entity":"info","action":"read"},{"entity":"onchain","action":"read"}
'';
description = "List of granted macaroon permissions.";
};
};
});
description = ''
Extra macaroon definitions.
'';
};
certificate = {
extraIPs = mkOption {
type = with types; listOf str;
default = [];
example = [ "60.100.0.1" ];
description = ''
Extra `subjectAltName` IPs added to the certificate.
This works the same as lnd option {option}`tlsextraip`.
'';
};
extraDomains = mkOption {
type = with types; listOf str;
default = [];
example = [ "example.com" ];
description = ''
Extra `subjectAltName` domain names added to the certificate.
This works the same as lnd option {option}`tlsextradomain`.
'';
};
};
extraConfig = mkOption {
type = types.lines;
default = "";
example = ''
autopilot.active=1
'';
description = ''
Extra lines appended to {file}`lnd.conf`.
See here for all available options:
https://github.com/lightningnetwork/lnd/blob/master/sample-lnd.conf
'';
};
package = mkOption {
type = types.package;
default = pkgs.lnd;
defaultText = "pkgs.lnd";
description = "The package providing lnd binaries.";
};
cli = mkOption {
default = pkgs.writers.writeBashBin "lncli"
# Switch user because lnd makes datadir contents readable by user only
''
${runAsUser} ${cfg.user} ${cfg.package}/bin/lncli \
--rpcserver ${cfg.rpcAddress}:${toString cfg.rpcPort} \
--tlscertpath '${cfg.certPath}' \
--macaroonpath '${networkDir}/admin.macaroon' "$@"
'';
defaultText = "(See source)";
description = "Binary to connect with the lnd instance.";
};
getPublicAddressCmd = mkOption {
type = types.str;
default = "";
description = ''
Bash expression which outputs the public service address to announce to peers.
If left empty, no address is announced.
'';
};
user = mkOption {
type = types.str;
default = "lnd";
description = "The user as which to run LND.";
};
group = mkOption {
type = types.str;
default = cfg.user;
description = "The group as which to run LND.";
};
certPath = mkOption {
readOnly = true;
default = "${secretsDir}/lnd-cert";
description = "LND TLS certificate path.";
};
tor = nbLib.tor;
};
cfg = config.services.lnd;
nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
runAsUser = config.nix-bitcoin.runAsUserCmd;
lndinit = "${pkgs.lndinit}/bin/lndinit";
bitcoind = config.services.bitcoind;
bitcoindRpcAddress = nbLib.address bitcoind.rpc.address;
networkDir = cfg.networkDir;
configFile = pkgs.writeText "lnd.conf" ''
datadir=${cfg.dataDir}
tlscertpath=${cfg.certPath}
tlskeypath=${secretsDir}/lnd-key
# We're logging via journald
logging.file.disable=1
logging.console.no-timestamps=1
listen=${toString cfg.address}:${toString cfg.port}
rpclisten=${cfg.rpcAddress}:${toString cfg.rpcPort}
restlisten=${cfg.restAddress}:${toString cfg.restPort}
bitcoin.${bitcoind.network}=1
bitcoin.node=bitcoind
${optionalString (cfg.tor.proxy) "tor.active=true"}
${optionalString (cfg.tor-socks != null) "tor.socks=${cfg.tor-socks}"}
bitcoind.rpchost=${bitcoindRpcAddress}:${toString bitcoind.rpc.port}
bitcoind.rpcuser=${bitcoind.rpc.users.public.name}
bitcoind.zmqpubrawblock=${zmqHandleSpecialAddress bitcoind.zmqpubrawblock}
bitcoind.zmqpubrawtx=${zmqHandleSpecialAddress bitcoind.zmqpubrawtx}
wallet-unlock-password-file=${secretsDir}/lnd-wallet-password
${cfg.extraConfig}
'';
zmqHandleSpecialAddress = builtins.replaceStrings [ "0.0.0.0" "[::]" ] [ "127.0.0.1" "[::1]" ];
in {
inherit options;
config = mkIf cfg.enable {
assertions = [
{ assertion =
!(config.services ? clightning)
|| true; # clightning enable/port check disabled - option structure differs between nixpkgs versions (f13ff45 has plugins only, 8b8c811 removed). Sovran uses lnd only, so no conflict.
message = ''
LND and clightning can't both bind to lightning port 9735. Either
disable LND/clightning or change services.clightning.port or
services.lnd.port to a port other than 9735.
'';
}
];
services.bitcoind = {
enable = true;
# Increase rpc thread count due to reports that lightning implementations fail
# under high bitcoind rpc load
rpc.threads = 16;
zmqpubrawblock = mkDefault "tcp://${bitcoindRpcAddress}:28332";
zmqpubrawtx = mkDefault "tcp://${bitcoindRpcAddress}:28333";
};
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
systemd.tmpfiles.rules = [
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
];
services.lnd.certificate.extraIPs = mkIf (cfg.rpcAddress != "127.0.0.1") [ "${cfg.rpcAddress}" ];
systemd.services.lnd = {
wantedBy = [ "multi-user.target" ];
requires = [ "bitcoind.service" ];
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
preStart = ''
install -m600 ${configFile} '${cfg.dataDir}/lnd.conf'
{
echo "bitcoind.rpcpass=$(cat ${secretsDir}/bitcoin-rpcpassword-public)"
${optionalString (cfg.getPublicAddressCmd != "") ''
echo "externalip=$(${cfg.getPublicAddressCmd})"
''}
} >> '${cfg.dataDir}/lnd.conf'
if [[ ! -f ${networkDir}/wallet.db ]]; then
seed='${cfg.dataDir}/lnd-seed-mnemonic'
if [[ ! -f "$seed" ]]; then
echo "Create lnd seed"
(umask u=r,go=; ${lndinit} gen-seed > "$seed")
fi
echo "Create lnd wallet"
${lndinit} -v init-wallet \
--file.seed="$seed" \
--file.wallet-password='${secretsDir}/lnd-wallet-password' \
--init-file.output-wallet-dir='${cfg.networkDir}'
fi
'';
serviceConfig = nbLib.defaultHardening // {
Type = "notify";
RuntimeDirectory = "lnd"; # Only used to store custom macaroons
RuntimeDirectoryMode = "711";
ExecStart = "${cfg.package}/bin/lnd --configfile='${cfg.dataDir}/lnd.conf'";
User = cfg.user;
TimeoutSec = "15min";
Restart = "on-failure";
RestartSec = "10s";
ReadWritePaths = [ cfg.dataDir ];
ExecStartPost = let
curl = "${pkgs.curl}/bin/curl -fsS --cacert ${cfg.certPath}";
restUrl = "https://${nbLib.addressWithPort cfg.restAddress cfg.restPort}/v1";
# Setting macaroon permissions for other users needs root permissions
# The admin macaroon is passed to curl via a fd because argv is
# world-readable through /proc/<pid>/cmdline
script = nbLib.rootScript "lnd-create-macaroons" ''
umask ug=r,o=
${lib.concatMapStrings (macaroon: ''
echo "Create custom macaroon ${macaroon}"
macaroonPath="$RUNTIME_DIRECTORY/${macaroon}.macaroon"
adminMacaroonHex=$(${pkgs.xxd}/bin/xxd -ps -u -c 99999 '${networkDir}/admin.macaroon')
${curl} \
-H @<(printf 'Grpc-Metadata-macaroon: %s\n' "$adminMacaroonHex") \
-X POST \
-d '{"permissions":[${cfg.macaroons.${macaroon}.permissions}]}' \
${restUrl}/macaroon |\
${pkgs.jq}/bin/jq -c '.macaroon' | ${pkgs.xxd}/bin/xxd -p -r > "$macaroonPath"
chown ${cfg.macaroons.${macaroon}.user}: "$macaroonPath"
'') (attrNames cfg.macaroons)}
'';
in [
script
];
} // nbLib.allowedIPAddresses cfg.tor.enforce;
};
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
extraGroups = [ "bitcoinrpc-public" ];
home = cfg.dataDir; # lnd creates .lnd dir in HOME
};
users.groups.${cfg.group} = {};
nix-bitcoin.operator = {
groups = [ cfg.group ];
allowRunAsUsers = [ cfg.user ];
};
nix-bitcoin.secrets = {
lnd-wallet-password.user = cfg.user;
lnd-key.user = cfg.user;
lnd-cert.user = cfg.user;
lnd-cert.permissions = "444"; # world readable
};
# Advantages of manually pre-generating certs:
# - Reduces dynamic state
# - Enables deployment of a mesh of server plus client nodes with predefined certs
nix-bitcoin.generateSecretsCmds.lnd = ''
makePasswordSecret lnd-wallet-password
makeCert lnd '${nbLib.mkCertExtraAltNames cfg.certificate}'
'';
};
}
+115
View File
@@ -0,0 +1,115 @@
{ config, lib, pkgs, ... }:
# LND-only lndconnect wrapper. Restored to the fort-nix/nix-bitcoin contract
# after the LND-only rewrite shipped a Zeus QR that Zeus cannot use:
# - unknown flags (--cert / --macaroon instead of --tlscertpath / --adminmacaroonpath)
# - onion hostname read from /var/lib/tor/onion/free/lnd/hostname (does not exist)
# - REST hidden service named "lnd", colliding with the LND P2P onion
# - TLS cert embedded in the URI (localhost CN + QR too dense to scan)
#
# Zeus needs: lndconnect://<lnd-rest-onion>:8080?macaroon=<admin> (no cert over Tor)
with lib;
let
cfg = config.services.lnd;
operatorName = config.nix-bitcoin.operator.name;
nbLib = config.nix-bitcoin.lib;
runAsUser = config.nix-bitcoin.runAsUserCmd;
mkLndconnect = {
name,
shebang ? "#!${pkgs.stdenv.shell} -e",
port,
authSecretPath,
enableOnion,
onionService ? null,
certPath ? null
}:
# lndconnect requires a --configfile argument, although it's unused
# https://github.com/LN-Zap/lndconnect/issues/25
lib.hiPrio (pkgs.writeScriptBin name ''
${shebang}
url=$(
${getExe pkgs.lndconnect} --url \
${optionalString enableOnion "--host=$(cat ${config.nix-bitcoin.onionAddresses.dataDir}/${onionService})"} \
--port=${toString port} \
${if enableOnion || certPath == null then "--nocert" else "--tlscertpath='${certPath}'"} \
--adminmacaroonpath='${authSecretPath}' \
--configfile=/dev/null "$@"
)
# If --url is in args
if [[ " $* " =~ " --url " ]]; then
echo "$url"
else
# UTF-8 QR is smaller than lndconnect's native output
echo -n "$url" | ${getExe pkgs.qrencode} -t UTF8 -o -
fi
'');
in {
options.services.lnd.lndconnect = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Add a `lndconnect` binary to the system environment which prints
connection info for lnd clients (Zeus).
See: https://github.com/LN-Zap/lndconnect
Usage:
```bash
# Print QR code
lndconnect
# Print URL
lndconnect --url
```
'';
};
onion = mkOption {
type = types.bool;
default = false;
description = ''
Create an onion service for the lnd REST server,
which is used by lndconnect / Zeus.
'';
};
};
config = mkIf (cfg.enable && cfg.lndconnect.enable) (mkMerge [
{
environment.systemPackages = [(
mkLndconnect {
name = "lndconnect";
# Run as lnd user because the macaroon and cert are not group-readable
shebang = "#!/usr/bin/env -S ${runAsUser} ${cfg.user} ${pkgs.bash}/bin/bash";
enableOnion = cfg.lndconnect.onion;
onionService = "${cfg.user}/lnd-rest";
port = cfg.restPort;
certPath = cfg.certPath;
authSecretPath = "${cfg.networkDir}/admin.macaroon";
}
)];
# LAN / clearnet Zeus needs REST on all interfaces. Tor-only stays on
# the existing restAddress (loopback) and is reached via lnd-rest.
services.lnd.restAddress = mkIf (!cfg.lndconnect.onion) "0.0.0.0";
}
(mkIf cfg.lndconnect.onion {
services.tor = {
enable = true;
# Dedicated name — must not reuse onionServices.lnd (that's P2P :9735).
relay.onionServices.lnd-rest = nbLib.mkOnionService {
target.addr = nbLib.address cfg.restAddress;
target.port = cfg.restPort;
port = cfg.restPort;
};
};
nix-bitcoin.onionAddresses.access = {
${cfg.user} = [ "lnd-rest" ];
${operatorName} = [ "lnd-rest" ];
};
})
]);
}
+348
View File
@@ -0,0 +1,348 @@
{ config, lib, pkgs, ... }:
with lib;
let
options.services = {
mempool = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Enable Mempool, a fully featured Bitcoin visualizer, explorer, and API service.
Note: Mempool enables `txindex` in bitcoind (this is a requirement).
This module has two components:
- A backend service (systemd service `mempool`)
- An optional web interface run by nginx, defined by options `services.mempool.frontend.*`.
The frontend is enabled by default when mempool is enabled.
For details, see `services.mempool.frontend.enable`.
'';
};
frontend = {
enable = mkOption {
type = types.bool;
default = cfg.enable;
description = ''
Enable the mempool frontend (web interface).
This starts a simple nginx instance, configured for local usage with
settings similar to the `mempool/frontend` Docker image.
IMPORTANT:
If you want to expose the mempool frontend to the internet, you
should create a custom nginx config that includes TLS, backend caching, rate limiting
and performance tuning.
For this task, reuse the config snippets from option `services.mempool.frontend.nginxConfig`.
See also: https://github.com/fort-nix/nixbitcoin.org/blob/master/website/mempool.nix,
which contains a mempool nginx config for public hosting (running at
https://mempool.nixbitcoin.org).
'';
};
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "HTTP server address.";
};
port = mkOption {
type = types.port;
default = 60845; # A random private port
description = "HTTP server port.";
};
settings = mkOption {
type = with types; attrsOf anything;
default = {};
example = {
TESTNET_ENABLED = true;
MEMPOOL_WEBSITE_URL = "mempool.mynode.org";
};
description = ''
Mempool frontend settings.
See here for available options:
https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
(`interface Env` and `defaultEnv`)
'';
};
staticContentRoot = mkOption {
type = types.path;
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-frontend.withConfig cfg.frontend.settings;
defaultText = "mempoolPkgs.mempool-frontend";
description = "
Path of the static frontend content root.
";
};
nginxConfig = mkOption {
readOnly = true;
default = frontend.nginxConfig;
defaultText = "(See source)";
description = "
An attrset of nginx config snippets for assembling a custom
mempool nginx config.
For details, see the source comments at the point of definition.
";
};
};
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Mempool backend address.";
};
port = mkOption {
type = types.port;
default = 8999;
description = "Mempool backend port.";
};
electrumServer = mkOption {
type = types.enum [ "electrs" ];
default = "electrs";
description = ''
The Electrum server to use for fetching address information.
Possible options:
- electrs:
Small database size, slow when querying new addresses.
'';
};
settings = mkOption {
type = with types; attrsOf (attrsOf anything);
example = {
MEMPOOL = {
POLL_RATE_MS = 3000;
STDOUT_LOG_MIN_PRIORITY = "debug";
};
PRICE_DATA_SERVER = {
CLEARNET_URL = "https://myserver.org/prices";
};
};
description = ''
Mempool backend settings.
See here for available options:
https://github.com/mempool/mempool/blob/master/backend/src/config.ts
'';
};
database = {
name = mkOption {
type = types.str;
default = "mempool";
description = "Database name.";
};
};
package = mkOption {
type = types.package;
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-backend;
defaultText = "mempoolPkgs.mempool-backend";
description = "The package providing mempool binaries.";
};
user = mkOption {
type = types.str;
default = "mempool";
description = "The user as which to run Mempool.";
};
group = mkOption {
type = types.str;
default = cfg.user;
description = "The group as which to run Mempool.";
};
tor = nbLib.tor;
};
# Internal read-only options used by `./nodeinfo.nix` and `./onion-services.nix`
mempool-frontend = let
inherit (nbLib) mkAlias;
in {
enable = mkAlias cfg.frontend.enable;
address = mkAlias cfg.frontend.address;
port = mkAlias cfg.frontend.port;
};
};
cfg = config.services.mempool;
nbLib = config.nix-bitcoin.lib;
nbPkgs = pkgs; # vendored: now alias to pkgs
secretsDir = config.nix-bitcoin.secretsDir;
configFile = builtins.toFile "mempool-config" (builtins.toJSON cfg.settings);
cacheDir = "/var/cache/mempool";
inherit (config.services)
bitcoind
electrs;
torSocket = config.services.tor.client.socksListenAddress;
# Vendored mempool package
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
mempoolPkgs = pkgs.callPackage ../../packages/mempool { inherit fetchNodeModules; };
# See the `services.nginx` definition further below
# on how to use these snippets.
frontend.nginxConfig = {
# This must be added to `services.nginx.commonHttpConfig` when
# `mempool/location-static.conf` is used
httpConfig = ''
include ${mempoolPkgs.mempool-nginx-conf}/http-language.conf;
'';
# Config for static website content.
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-redirects.conf
staticContent = ''
index index.html;
add_header Cache-Control "public, no-transform";
add_header Vary Accept-Language;
add_header Vary Cookie;
include ${mempoolPkgs.mempool-nginx-conf}/location-static.conf;
# Redirect /api to /docs/api
location = /api {
return 308 https://$host/docs/api;
}
location = /api/ {
return 308 https://$host/docs/api;
}
'';
# Config for backend API.
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-api.conf.
proxyApi = let
backend = "http://${nbLib.addressWithPort cfg.address cfg.port}";
in ''
location /api/ {
proxy_pass ${backend}/api/v1/;
}
location /api/v1 {
proxy_pass ${backend};
}
# Websocket API
location /api/v1/ws {
proxy_pass ${backend};
# Websocket header settings
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Relevant settings from `recommendedProxyConfig` (nixos/nginx/default.nix)
# (In the above api locations, these are inherited from the parent scope)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
'';
};
in {
inherit options;
config = mkIf cfg.enable {
services.bitcoind.txindex = true;
services.electrs.enable = true;
services.mysql = {
enable = true;
package = pkgs.mariadb;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.user;
ensurePermissions."${cfg.database.name}.*" = "ALL PRIVILEGES";
}
];
};
# Available options:
# https://github.com/mempool/mempool/blob/master/backend/src/config.ts
services.mempool.settings = {
MEMPOOL = {
# mempool doesn't support regtest
NETWORK = "mainnet";
BACKEND = "electrum";
HTTP_PORT = cfg.port;
CACHE_DIR = "${cacheDir}/cache";
STDOUT_LOG_MIN_PRIORITY = mkDefault "info";
AUTOMATIC_POOLS_UPDATE = true;
};
CORE_RPC = {
HOST = bitcoind.rpc.address;
PORT = bitcoind.rpc.port;
USERNAME = bitcoind.rpc.users.public.name;
PASSWORD = "@btcRpcPassword@";
};
ELECTRUM = let
server = config.services.${cfg.electrumServer};
in {
HOST = server.address;
PORT = server.port;
TLS_ENABLED = false;
};
DATABASE = {
ENABLED = true;
DATABASE = cfg.database.name;
SOCKET = "/run/mysqld/mysqld.sock";
PID_DIR = cacheDir;
};
} // optionalAttrs (cfg.tor.proxy) {
# Use Tor for rate fetching and pool updating
SOCKS5PROXY = {
ENABLED = true;
USE_ONION = true;
HOST = torSocket.addr;
PORT = torSocket.port;
};
};
systemd.services.mempool = rec {
wantedBy = [ "multi-user.target" ];
requires = [ "mysql.service" ];
wants = [ "${cfg.electrumServer}.service" ];
after = requires ++ wants;
preStart = ''
mkdir -p '${cacheDir}/cache'
<${configFile} sed \
-e "s|@btcRpcPassword@|$(cat ${secretsDir}/bitcoin-rpcpassword-public)|" \
> '${cacheDir}/config.json'
'';
environment.MEMPOOL_CONFIG_FILE = "${cacheDir}/config.json";
serviceConfig = nbLib.defaultHardening // {
ExecStart = "${cfg.package}/bin/mempool-backend";
CacheDirectory = "mempool";
CacheDirectoryMode = "770";
# Show "mempool" instead of "node" in the journal
SyslogIdentifier = "mempool";
User = cfg.user;
Restart = "on-failure";
RestartSec = "10s";
} // nbLib.allowedIPAddresses cfg.tor.enforce
// nbLib.nodejs;
};
services.nginx = mkIf cfg.frontend.enable {
enable = true;
enableReload = true;
recommendedBrotliSettings = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
commonHttpConfig = frontend.nginxConfig.httpConfig;
virtualHosts."mempool" = {
serverName = "_";
listen = [ { addr = cfg.frontend.address; port = cfg.frontend.port; } ];
root = cfg.frontend.staticContentRoot;
extraConfig =
frontend.nginxConfig.staticContent +
frontend.nginxConfig.proxyApi;
};
};
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
extraGroups = [ "bitcoinrpc-public" ];
};
users.groups.${cfg.group} = {};
};
}
+60
View File
@@ -0,0 +1,60 @@
{ config, pkgs, lib, ... }:
with lib;
{
options = {
nix-bitcoin = {
# Kept for compatibility, now simply aliases system pkgs
pkgs = mkOption {
type = types.attrs;
default = pkgs;
defaultText = "pkgs";
description = "Alias to system pkgs (vendored nix-bitcoin now uses nixpkgs directly).";
};
useVersionLockedPkgs = mkOption {
type = types.bool;
default = false;
description = "Deprecated vendored modules always use system pkgs.";
};
pkgOverlays = mkOption {
internal = true;
type = with types; functionTo attrs;
default = _: _: {};
description = "Deprecated stub.";
};
lib = mkOption {
readOnly = true;
default = import ./lib.nix lib pkgs config;
defaultText = "vendor/nix-bitcoin/lib.nix";
};
torClientAddressWithPort = mkOption {
readOnly = true;
default = with config.services.tor.client.socksListenAddress;
"${addr}:${toString port}";
defaultText = "(See source)";
};
torify = mkOption {
readOnly = true;
default = pkgs.writers.writeBashBin "torify" ''
${pkgs.tor}/bin/torify \
--address ${config.services.tor.client.socksListenAddress.addr} \
"$@"
'';
defaultText = "(See source)";
};
runAsUserCmd = mkOption {
readOnly = true;
default = if config.security.doas.enable
then "doas -u"
else "sudo -u";
defaultText = "(See source)";
};
};
};
}
+154
View File
@@ -0,0 +1,154 @@
{ config, lib, pkgs, ... }:
with lib;
let
options = {
nix-bitcoin.nodeinfo = {
enable = mkEnableOption "nodeinfo";
program = mkOption {
readOnly = true;
default = script;
defaultText = "(See source)";
};
services = mkOption {
internal = true;
type = types.attrs;
default = {};
defaultText = "(See source)";
description = ''
Nodeinfo service definitions.
'';
};
lib = mkOption {
internal = true;
readOnly = true;
default = nodeinfoLib;
defaultText = "(See source)";
description = ''
Helper functions for defining nodeinfo services.
'';
};
};
};
cfg = config.nix-bitcoin.nodeinfo;
nbLib = config.nix-bitcoin.lib;
script = pkgs.writeScriptBin "nodeinfo" ''
#!${pkgs.python3}/bin/python
import json
import subprocess
import sys
from collections import OrderedDict
def success(*args):
return subprocess.call(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
def is_active(unit):
return success("systemctl", "is-active", "--quiet", unit)
def is_enabled(unit):
return success("systemctl", "is-enabled", "--quiet", unit)
def cmd(*args):
return subprocess.run(args, stdout=subprocess.PIPE).stdout.decode('utf-8')
def shell(*args):
return cmd("bash", "-c", *args).strip()
infos = OrderedDict()
operator = "${config.nix-bitcoin.operator.name}"
def get_onion_address(name, port):
path = f"/var/lib/onion-addresses/{operator}/{name}"
try:
with open(path, "r") as f:
onion_address = f.read().strip()
except OSError:
print(f"error reading file {path}", file=sys.stderr)
return
return f"{onion_address}:{port}"
def add_service(service, make_info, systemd_service = None):
systemd_service = systemd_service or service
if not is_active(systemd_service):
infos[service] = f"'{systemd_service}.service' is not running"
else:
info = OrderedDict()
exec(make_info, globals(), locals())
infos[service] = info
if is_enabled("onion-addresses") and not is_active("onion-addresses"):
print("error: service 'onion-addresses' is not running")
exit(1)
${concatStrings infos}
print(json.dumps(infos, indent=2))
'';
infos = map (serviceName:
let serviceCfg = config.services.${serviceName};
in optionalString serviceCfg.enable (cfg.services.${serviceName} serviceName serviceCfg)
) (builtins.attrNames cfg.services);
nodeinfoLib = rec {
mkInfo = extraCode: name: cfg:
mkInfoLong {
inherit extraCode name cfg;
};
mkInfoLong = { extraCode ? "", name, cfg, systemdServiceName ? name }: ''
add_service("${name}", """
info["local_address"] = "${nbLib.addressWithPort cfg.address cfg.port}"
'' + mkIfOnionPort name (onionPort: ''
info["onion_address"] = get_onion_address("${name}", ${onionPort})
'') + extraCode + ''
""", "${systemdServiceName}")
'';
mkIfOnionPort = name: fn:
if onionServices ? ${name} then
fn (toString (builtins.elemAt onionServices.${name}.map 0).port)
else
"";
};
inherit (config.services.tor.relay) onionServices;
in {
inherit options;
config = mkIf cfg.enable {
environment.systemPackages = [ script ];
nix-bitcoin.operator.enable = true;
nix-bitcoin.nodeinfo.services = with nodeinfoLib; {
bitcoind = mkInfo "";
lnd = name: cfg: mkInfo (''
info["rest_address"] = "${nbLib.addressWithPort cfg.restAddress cfg.restPort}"
'' + mkIfOnionPort "lnd-rest" (onionPort: ''
info["onion_rest_address"] = get_onion_address("lnd-rest", ${onionPort})
'') + ''
info["nodeid"] = shell("lncli getinfo | jq -r '.identity_pubkey'")
'') name cfg;
electrs = mkInfo "";
btcpayserver = mkInfo "";
rtl = mkInfo "";
mempool = mkInfo "";
mempool-frontend = name: cfg: mkInfoLong {
inherit name cfg;
systemdServiceName = "nginx";
};
# Only add sshd when it has an onion service
sshd = name: cfg: mkIfOnionPort "sshd" (onionPort: ''
add_service("sshd", """info["onion_address"] = get_onion_address("sshd", ${onionPort})""")
'');
};
};
}
+106
View File
@@ -0,0 +1,106 @@
# This module enables unprivileged users to read onion addresses.
# By default, onion addresses in /var/lib/tor/onion are only readable by the
# tor user.
# The included service copies onion addresses to /var/lib/onion-addresses/<user>/
# and sets permissions according to option 'access'.
{ config, lib, ... }:
with lib;
let
options.nix-bitcoin.onionAddresses = {
access = mkOption {
type = with types; attrsOf (listOf str);
default = {};
description = ''
This option controls who is allowed to access onion addresses.
For example, the following allows user 'myuser' to access bitcoind
and clightning onion addresses:
```nix
{
"myuser" = [ "bitcoind" "clightning" ];
};
```
The onion hostnames can then be read from
{file}`/var/lib/onion-addresses/myuser`.
'';
};
services = mkOption {
type = with types; listOf str;
default = [];
description = ''
Services that can access their onion address via file
{file}`/var/lib/onion-addresses/<service>`
The file is readable only by the service user.
'';
};
dataDir = mkOption {
readOnly = true;
default = "/var/lib/onion-addresses";
};
};
cfg = config.nix-bitcoin.onionAddresses;
nbLib = config.nix-bitcoin.lib;
in {
inherit options;
config = mkIf (cfg.access != {} || cfg.services != []) {
systemd.services.onion-addresses = {
wantedBy = [ "tor.service" ];
bindsTo = [ "tor.service" ];
after = [ "tor.service" ];
serviceConfig = nbLib.defaultHardening // {
Type = "oneshot";
RemainAfterExit = true;
StateDirectory = "onion-addresses";
StateDirectoryMode = "771";
PrivateNetwork = true; # This service needs no network access
PrivateUsers = false;
CapabilityBoundingSet = "CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER";
};
script = ''
waitForFile() {
file=$1
for ((i=0; i<300; i++)); do
if [[ -e $file ]]; then
return;
fi
sleep 0.1
done
echo "Error: File $file did not appear after 30 sec."
exit 1
}
# Wait until tor is up
waitForFile /var/lib/tor/state
cd ${cfg.dataDir}
rm -rf ./*
${concatMapStrings
(user: ''
mkdir -p -m 0700 ${user}
chown ${user} ${user}
${concatMapStrings
(service: ''
onionFile='/var/lib/tor/onion/${service}/hostname'
waitForFile "$onionFile"
cp "$onionFile" '${user}/${service}'
chown '${user}' '${user}/${service}'
'')
cfg.access.${user}
}
'')
(builtins.attrNames cfg.access)
}
${concatMapStrings (service: ''
onionFile=/var/lib/tor/onion/${service}/hostname
waitForFile "$onionFile"
install -D -o ${config.systemd.services.${service}.serviceConfig.User} -m 400 "$onionFile" services/${service}
'') cfg.services}
'';
};
};
}
+119
View File
@@ -0,0 +1,119 @@
# This module creates onion-services for NixOS services.
# An onion service can be enabled for every service that defines
# options 'address', 'port' and optionally 'getPublicAddressCmd'.
#
# See it in use at ./presets/enable-tor.nix
{ config, lib, pkgs, ... }:
with lib;
let
options.nix-bitcoin.onionServices = mkOption {
default = {};
type = with types; attrsOf (submodule (
{ config, ... }: {
options = {
enable = mkOption {
type = types.bool;
default = config.public;
description = ''
Create an onion service for the given service.
The service must define options {option}`address` and {option}`onionPort` (or `port`).
'';
};
public = mkOption {
type = types.bool;
default = false;
description = ''
Make the onion address accessible to the service.
If enabled, the onion service is automatically enabled.
Only available for services that define option {option}`getPublicAddressCmd`.
'';
};
externalPort = mkOption {
type = types.nullOr types.port;
default = null;
description = "Override the external port of the onion service.";
};
};
}
));
};
cfg = config.nix-bitcoin.onionServices;
nbLib = config.nix-bitcoin.lib;
onionServices = builtins.attrNames cfg;
activeServices = builtins.filter (service:
config.services.${service}.enable && cfg.${service}.enable
) onionServices;
publicServices = builtins.filter (service: cfg.${service}.public) activeServices;
in {
inherit options;
config = mkMerge [
(mkIf (activeServices != []) {
# Define hidden services
services.tor = {
enable = true;
relay.onionServices = genAttrs activeServices (name:
let
service = config.services.${name};
inherit (cfg.${name}) externalPort;
in nbLib.mkOnionService {
port = if externalPort != null then externalPort else service.port;
target.port = service.onionPort or service.port;
target.addr = nbLib.address service.address;
}
);
};
nix-bitcoin.onionAddresses = {
# Enable public services to access their own onion addresses
services = publicServices;
# Allow the operator user to access onion addresses for all active services
access.${config.nix-bitcoin.operator.name} = mkIf config.nix-bitcoin.operator.enable activeServices;
};
systemd.services = let
onionAddresses = [ "onion-addresses.service" ];
in genAttrs publicServices (service: {
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
# that propagates initial start failures but no restarts
wants = onionAddresses;
after = onionAddresses;
});
})
# Set getPublicAddressCmd for public services
{
services = let
# publicServices' doesn't depend on config.services.*.enable,
# so we can use it to define config.services without causing infinite recursion
publicServices' = builtins.filter (service:
let srv = cfg.${service};
in srv.public && srv.enable
) onionServices;
in genAttrs publicServices' (service: {
getPublicAddressCmd = "cat ${config.nix-bitcoin.onionAddresses.dataDir}/services/${service}";
});
}
# Set sensible defaults for some services
{
nix-bitcoin.onionServices = {
btcpayserver = {
externalPort = 80;
};
rtl = {
externalPort = 80;
};
mempool-frontend = {
externalPort = 80;
};
};
}
];
}
+58
View File
@@ -0,0 +1,58 @@
{ config, lib, pkgs, ... }:
with lib;
let
options.nix-bitcoin.operator = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Whether to define a user named `operator` for convenient interactive access
to nix-bitcoin features (like `bitcoin-cli`).
When using nix-bitcoin as part of a larger system config, it makes sense
to set your main system user as the operator, by setting option
`nix-bitcoin.operator.name = "MAIN_USER_NAME";`.
'';
};
name = mkOption {
type = types.str;
default = "operator";
description = "Name of the operator user.";
};
groups = mkOption {
type = with types; listOf str;
default = [];
description = "Extra groups of the operatur user.";
};
allowRunAsUsers = mkOption {
type = with types; listOf str;
default = [];
description = "Users as which the operator is allowed to run commands.";
};
};
cfg = config.nix-bitcoin.operator;
in {
inherit options;
config = mkIf cfg.enable {
users.users.${cfg.name} = {
isNormalUser = true;
extraGroups = [
"systemd-journal"
"proc" # Enable full /proc access and systemd-status
] ++ cfg.groups;
};
security = mkIf (cfg.allowRunAsUsers != []) {
# Use doas instead of sudo if enabled
doas.extraConfig = mkIf config.security.doas.enable ''
${lib.concatMapStrings (user: "permit nopass ${cfg.name} as ${user}\n") cfg.allowRunAsUsers}
'';
sudo.extraConfig = mkIf (!config.security.doas.enable) ''
${cfg.name} ALL=(${builtins.concatStringsSep "," cfg.allowRunAsUsers}) NOPASSWD: ALL
'';
};
};
}
+216
View File
@@ -0,0 +1,216 @@
{ config, lib, pkgs, ... }:
with lib;
let
options.services.rtl = {
enable = mkEnableOption "RTL, a web interface for LND";
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen for HTTP connections.";
};
port = mkOption {
type = types.port;
default = 3000;
description = "Port to listen for HTTP connections.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/rtl";
description = "The data directory for RTL.";
};
nightTheme = mkOption {
type = types.bool;
default = false;
description = "Enable night theme by default.";
};
extraCurrency = mkOption {
type = types.nullOr types.str;
default = null;
example = "USD";
description = ''
Additional currency for displaying amounts.
When set, Tor is disabled for the RTL service to allow currency rate fetching.
'';
};
nodes = {
lnd = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable LND node in RTL.";
};
loop = mkOption {
type = types.bool;
default = false;
description = "Enable Lightning Loop integration (requires loopd).";
};
};
clightning = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable Core Lightning node in RTL (not supported in Sovran).";
};
};
};
user = mkOption {
type = types.str;
default = "rtl";
description = "The user as which to run RTL.";
};
group = mkOption {
type = types.str;
default = cfg.user;
description = "The group as which to run RTL.";
};
tor = nbLib.tor;
};
cfg = config.services.rtl;
nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
# Vendored RTL package
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
rtlPackage = pkgs.callPackage ../../packages/rtl { inherit fetchNodeModules; };
runePath = "${cfg.dataDir}/CLN-Rune.env";
rtlConfig = {
multiPass = "@multiPass@";
port = cfg.port;
host = cfg.address;
defaultNodeIndex = 1;
dbDirectoryPath = cfg.dataDir;
SSO = {
rtlSSO = 0;
rtlCookiePath = "";
logoutRedirectLink = "";
};
nodes = optional cfg.nodes.lnd.enable ({
index = 1;
lnNode = "lnd";
lnImplementation = "LND";
authentication = {
macaroonPath = "${cfg.dataDir}/macaroons";
swapMacaroonPath = if cfg.nodes.lnd.loop then "${cfg.dataDir}/loop-macaroons" else "";
boltzMacaroonPath = "";
};
settings = {
userPersona = "OPERATOR";
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
themeColor = "PURPLE";
channelBackupPath = "${cfg.dataDir}/backup";
logLevel = "INFO";
lnServerUrl = "https://${lnd.restAddress}:${toString lnd.restPort}";
swapServerUrl = if cfg.nodes.lnd.loop then "https://127.0.0.1:8081" else "";
boltzServerUrl = "";
fiatConversion = cfg.extraCurrency != null;
unannouncedChannels = true;
} // optionalAttrs (cfg.extraCurrency != null) {
currencyUnit = cfg.extraCurrency;
};
}) ++ optional cfg.nodes.clightning.enable {
index = 2;
lnNode = "clightning";
lnImplementation = "CLN";
authentication = {
runePath = runePath;
};
settings = {
userPersona = "OPERATOR";
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
themeColor = "PURPLE";
logLevel = "INFO";
fiatConversion = cfg.extraCurrency != null;
} // optionalAttrs (cfg.extraCurrency != null) {
currencyUnit = cfg.extraCurrency;
};
};
};
configFile = builtins.toFile "config" (builtins.toJSON rtlConfig);
inherit (config.services)
bitcoind
lnd;
lndLoopEnabled = cfg.nodes.lnd.enable && cfg.nodes.lnd.loop;
in {
inherit options;
config = mkIf cfg.enable {
assertions = [
{ assertion = cfg.nodes.lnd.enable;
message = ''
RTL: At least one node must be enabled. Sovran supports LND only.
'';
}
{ assertion = !cfg.nodes.clightning.enable;
message = ''
RTL: Core Lightning (clightning) is not supported in Sovran. Use LND instead.
'';
}
];
services.lnd.enable = mkIf cfg.nodes.lnd.enable true;
systemd.tmpfiles.rules = [
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
];
services.rtl.tor.enforce = mkIf (cfg.extraCurrency != null) false;
systemd.services.rtl = rec {
wantedBy = [ "multi-user.target" ];
wants = optional cfg.nodes.lnd.enable "lnd.service";
after = wants ++ [ "nix-bitcoin-secrets.target" ];
environment.RTL_CONFIG_PATH = cfg.dataDir;
environment.DB_DIRECTORY_PATH = cfg.dataDir;
serviceConfig = nbLib.defaultHardening // {
ExecStartPre = [
(nbLib.script "rtl-setup-config" ''
<${configFile} sed "s|@multiPass@|$(cat ${secretsDir}/rtl-password)|" \
> '${cfg.dataDir}/RTL-Config.json'
'')
]
++ optional cfg.nodes.lnd.enable
# The lnd admin macaroon is not readable by group `lnd`, so copy it
(nbLib.rootScript "rtl-copy-macaroon" ''
install --compare -m 640 -o ${cfg.user} -g ${cfg.group} -D ${lnd.networkDir}/admin.macaroon \
'${cfg.dataDir}/macaroons/admin.macaroon'
'');
ExecStart = "${rtlPackage}/bin/rtl";
# Show "rtl" instead of "node" in the journal
SyslogIdentifier = "rtl";
User = cfg.user;
Restart = "on-failure";
RestartSec = "10s";
ReadWritePaths = [ cfg.dataDir ];
} // nbLib.allowedIPAddresses cfg.tor.enforce
// nbLib.nodejs;
};
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
extraGroups = optional lndLoopEnabled lnd.group;
};
users.groups.${cfg.group} = {};
nix-bitcoin.secrets.rtl-password.user = cfg.user;
nix-bitcoin.generateSecretsCmds.rtl = ''
makePasswordSecret rtl-password
'';
};
}
+235
View File
@@ -0,0 +1,235 @@
{ config, pkgs, lib, ... }:
with lib;
let
options.nix-bitcoin = {
secretsDir = mkOption {
type = types.path;
default = "/etc/nix-bitcoin-secrets";
description = "Directory to store secrets";
};
setupSecrets = mkOption {
type = types.bool;
default = false;
description = ''
Set permissions for existing secrets in {option}`nix-bitcoin.secretsDir`
before services are started.
'';
};
generateSecrets = mkOption {
type = types.bool;
default = false;
description = ''
Automatically generate all required secrets before services are started.
Note: Make sure to create a backup of the generated secrets.
'';
};
generateSecretsCmds = mkOption {
type = types.attrsOf types.lines;
default = {};
description = ''
Bash expressions for generating secrets.
'';
};
# Currently, this is used only by ../deployment/nixops.nix
deployment.secretsDir = mkOption {
type = types.path;
description = ''
Directory of local secrets that are transferred to the nix-bitcoin node on deployment
'';
};
secrets = mkOption {
default = {};
type = with types; attrsOf (submodule (
{ config, ... }: {
options = {
user = mkOption {
type = str;
default = "root";
};
group = mkOption {
type = str;
default = config.user;
};
permissions = mkOption {
type = str;
default = "440";
};
};
}
));
};
secretsSetupMethod = mkOption {
type = with types; nullOr str;
default = null;
};
generateSecretsScript = mkOption {
internal = true;
default = let
rpcauthSrc = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/bitcoin/bitcoin/d6cde007db9d3e6ee93bd98a9bbfdce9bfa9b15b/share/rpcauth/rpcauth.py";
sha256 = "189mpplam6yzizssrgiyv70c9899ggh8cac76j4n7v0xqzfip07n";
};
rpcauth = pkgs.writers.writeBash "rpcauth" ''
exec ${pkgs.python3}/bin/python ${rpcauthSrc} "$@"
'';
# Writes secrets to PWD
in pkgs.writers.writeBash "generate-secrets" ''
set -euo pipefail
export PATH=${lib.makeBinPath (with pkgs; [ coreutils gnugrep ])}
makePasswordSecret() {
# Passwords have alphabet {a-z, A-Z, 0-9} and ~119 bits of entropy
[[ -e $1 ]] || ${pkgs.pwgen}/bin/pwgen -s 20 1 > "$1"
}
makeBitcoinRPCPassword() {
user=$1
file=bitcoin-rpcpassword-$user
HMACfile=bitcoin-HMAC-$user
makePasswordSecret "$file"
if [[ $file -nt $HMACfile ]]; then
${rpcauth} $user $(cat "$file") | grep rpcauth | cut -d ':' -f 2 > "$HMACfile"
fi
}
makeCert() {
name=$1
# Add leading comma if not empty
extraAltNames=''${2:+,}''${2:-}
if [[ ! -e $name-key ]]; then
# Create new key and cert
doMakeCert "-newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout $name-key"
elif [[ ! -e $name-cert \
|| $(cat "$name-cert-alt-names" 2>/dev/null) != $extraAltNames ]]; then
# Create cert from existing key
doMakeCert "-key $name-key"
fi;
}
doMakeCert() {
# This fn uses global variables `name` and `extraAltNames`
keyOpts=$1
${pkgs.openssl}/bin/openssl req -x509 \
-sha256 -days 3650 $keyOpts -out "$name-cert" \
-subj "/CN=localhost/O=$name" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1$extraAltNames"
echo "$extraAltNames" > "$name-cert-alt-names"
}
umask u=rw,go=
${builtins.concatStringsSep "\n" (builtins.attrValues cfg.generateSecretsCmds)}
'';
};
};
cfg = config.nix-bitcoin;
in {
inherit options;
config = {
assertions = [
{ assertion = cfg.secretsSetupMethod != null;
message = ''
No secrets setup method has been defined.
To fix this, choose one of the following:
- Use one of the deployment methods in ${toString ./../deployment}
- Set `nix-bitcoin.generateSecrets = true` to automatically generate secrets
- Set `nix-bitcoin.secretsSetupMethod = "manual"` if you want to manually setup secrets
'';
}
];
# This target is active when secrets have been setup successfully.
systemd.targets.nix-bitcoin-secrets = mkIf (cfg.secretsSetupMethod != "manual") {
# This ensures that the secrets target is always activated when switching
# configurations.
# In this way `switch-to-configuration` is guaranteed to show an error
# when activating the secrets target fails on deployment.
wantedBy = [ "multi-user.target" ];
};
nix-bitcoin.setupSecrets = mkIf cfg.generateSecrets true;
nix-bitcoin.secretsSetupMethod = mkIf cfg.setupSecrets "setup-secrets";
# Operation of this service:
# - Set owner and permissions for all used secrets
# - Make all other secrets accessible to root only
# For all steps make sure that no secrets are copied to the nix store.
#
systemd.services.setup-secrets = mkIf cfg.setupSecrets {
requiredBy = [ "nix-bitcoin-secrets.target" ];
before = [ "nix-bitcoin-secrets.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
# Use the same sort order for globbing and sorting as in Nix attrsets.
# Required for `comm` below.
export LC_COLLATE=C
${optionalString cfg.generateSecrets ''
mkdir -p "${cfg.secretsDir}"
cd "${cfg.secretsDir}"
chown root: .
chmod 0700 .
${cfg.generateSecretsScript}
''}
setupSecret() {
file="$1"
user="$2"
group="$3"
permissions="$4"
if [[ ! -e $file ]]; then
echo "Error: Secret file '$file' is missing"
exit 1
fi
chown "$user:$group" "$file"
chmod "$permissions" "$file"
processedFiles+=("$file")
}
dir="${cfg.secretsDir}"
if [[ ! -e $dir ]]; then
echo "Error: Secrets dir '$dir' is missing"
exit 1
fi
chown root: "$dir"
cd "$dir"
processedFiles=()
${
concatStrings (mapAttrsToList (n: v: ''
setupSecret ${n} ${v.user} ${v.group} ${v.permissions}
'') cfg.secrets)
}
# Make all other files accessible to root only
unprocessedFiles=$(
comm -23 <(shopt -s nullglob; printf '%s\n' *) <(printf '%s\n' "''${processedFiles[@]}")
)
if [[ $unprocessedFiles ]]; then
IFS=$'\n'
# shellcheck disable=SC2086
chown root: $unprocessedFiles
# shellcheck disable=SC2086
chmod 0440 $unprocessedFiles
fi
# Now make the secrets dir accessible to other users
chmod 0751 "$dir"
'';
};
};
}
+48
View File
@@ -0,0 +1,48 @@
{ config, lib, pkgs, ... }:
with lib;
{
options = {
nix-bitcoin.security.dbusHideProcessInformation = mkOption {
type = types.bool;
default = false;
description = ''
Only allow users with group `proc` to retrieve systemd unit information like
cgroup paths (i.e. (sub)process command lines) via D-Bus.
This mitigates a systemd security issue where (sub)process command lines can
be retrieved by services even when their access to /proc is restricted
(via ProtectProc).
This option works by restricting the D-Bus method `GetUnitProcesses`, which
is also used internally by {command}`systemctl status`.
'';
};
};
config = mkIf config.nix-bitcoin.security.dbusHideProcessInformation {
users.groups.proc = {};
nix-bitcoin.operator.groups = [ "proc" ]; # Enable operator access to systemd-status
services.dbus.packages = lib.mkAfter [ # Apply at the end to override the default policy
(pkgs.writeTextDir "etc/dbus-1/system.d/dbus.conf" ''
<busconfig>
<policy context="default">
<deny
send_destination="org.freedesktop.systemd1"
send_interface="org.freedesktop.systemd1.Manager"
send_member="GetUnitProcesses"
/>
</policy>
<policy group="proc">
<allow
send_destination="org.freedesktop.systemd1"
send_interface="org.freedesktop.systemd1.Manager"
send_member="GetUnitProcesses"
/>
</policy>
</busconfig>
'')
];
};
}
+12
View File
@@ -0,0 +1,12 @@
{ config, lib, ... }:
with lib;
let
options.nix-bitcoin.configVersion = mkOption {
type = with types; nullOr str;
default = null;
description = "Vendored stub no version migration needed.";
};
in {
inherit options;
config = {};
}
+14 -2
View File
@@ -4,7 +4,12 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
services.bitcoind = { services.bitcoind = {
enable = true; enable = true;
package = pkgs.bitcoind-knots; # Keep the normal loopback P2P socket available for local clients such as
# Bisq. Because `address` defaults to 127.0.0.1 this does not expose a
# clearnet or LAN listener. When the existing bitcoind onion service is
# enabled, this also creates its Tor-tagged loopback target on port 8334.
listen = true;
package = pkgs.bitcoind;
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"; dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
txindex = true; txindex = true;
tor.proxy = true; tor.proxy = true;
@@ -16,7 +21,13 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
''; '';
}; };
nix-bitcoin.onionServices.bitcoind.enable = true; nix-bitcoin.onionServices.bitcoind = {
enable = true;
# This is a locally vendored option namespace, not an upstream dependency.
# The onion listener remains available to peers that already know it;
# advertising its address through Bitcoin peer gossip is opt-in in the Hub.
public = config.sovran_systemsOS.features.bitcoin-tor-gossip;
};
nix-bitcoin.onionServices.electrs.enable = true; nix-bitcoin.onionServices.electrs.enable = true;
nix-bitcoin.onionServices.rtl.enable = true; nix-bitcoin.onionServices.rtl.enable = true;
@@ -68,6 +79,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
name = "free"; name = "free";
}; };
# vendored: now no-op (always uses nixpkgs)
nix-bitcoin.useVersionLockedPkgs = false; nix-bitcoin.useVersionLockedPkgs = false;
systemd.services.bitcoind = { systemd.services.bitcoind = {
+122 -17
View File
@@ -1,32 +1,137 @@
{ config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
{ {
# ── Ensure njalla directory and base script exist on every build ── # ── Ensure njalla directory exists on every build ────────────────────────
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /var/lib/njalla 0750 root root -" "d /var/lib/njalla 0750 root root -"
]; ];
# ── Create base njalla.sh if it doesn't exist yet ─────────── # ── Install the shared validation helper so the DDNS runner can import it
systemd.services.njalla-init = { # The exact same _validate_ddns_url() function used by the Hub web application
description = "Initialize Njal.la DDNS script if missing"; # is installed here as a read-only system file. The DDNS runner imports it
wantedBy = [ "multi-user.target" ]; # directly so the two code paths share one validator — no weaker inline copy.
environment.etc."sovran/security_helpers.py" = {
source = ../../app/sovran_systemsos_web/security_helpers.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.
# Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh.
systemd.services.sovran-ddns-update = {
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; User = "root";
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
# Harden the service — it only needs network access and read access to
# /var/lib/njalla/ddns_urls.json.
NoNewPrivileges = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
ReadOnlyPaths = [ "/etc/sovran" ];
ProtectHome = true;
PrivateTmp = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
}; };
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)
## Add DDNS entries below — one curl per line # Run the update every 15 minutes
## Managed via Sovran Hub web interface systemd.timers.sovran-ddns-update = {
SCRIPT description = "Sovran Njal.la DDNS update timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "2min";
OnUnitActiveSec = "15min";
Persistent = true;
};
};
chmod 700 /var/lib/njalla/njalla.sh # Install the Python runner script at build time so the service can find it.
# The script is owned by root and not world-writable.
# Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same
# production validator used by the Hub API — before executing any curl call.
# No shell is used; no redirects; no script execution.
# ${IP} placeholder is preserved in stored URLs and substituted at runtime;
# the URL is validated after substitution so any remaining $ is rejected.
system.activationScripts.sovran-ddns-update-script = ''
install -d -m 0755 /var/lib/sovran
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
#!/usr/bin/env python3
"""Sovran safe DDNS update runner.
Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder,
validates each URL using the production _validate_ddns_url() from
/etc/sovran/security_helpers.py, then calls curl per URL.
No shell interpolation. No redirects. No script execution.
"""
import ipaddress, json, os, subprocess, sys
sys.path.insert(0, '/etc/sovran')
try:
from security_helpers import _validate_ddns_url
except ImportError:
sys.exit(1) # validator missing — fail so systemd logs the misconfiguration
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
try:
with open(URLS_FILE) as f:
urls = json.load(f)
if not isinstance(urls, list):
raise ValueError("not a list")
except Exception:
sys.exit(0) # no URLs configured — nothing to do
# Resolve current public IP via the shared detector — one script, one cache
# (STUN -> DNS -> opt-in HTTPS echo; see /var/lib/sovran/public-ip.py).
# The detector refreshes /var/lib/secrets/external-ip, which the Hub and
# LiveKit read as well, so the whole system shares a single detected value.
public_ip = ""
try:
r = subprocess.run(
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
capture_output=True, text=True, timeout=20,
)
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
ipaddress.ip_address(raw) # validates — raises if not a real IP
public_ip = raw
except Exception:
pass
if not public_ip:
# Last resort: the shared cache file, if the detector is unavailable.
try:
with open("/var/lib/secrets/external-ip") as f:
raw = f.read().strip()
ipaddress.ip_address(raw)
public_ip = raw
except Exception:
pass
if not public_ip:
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
for raw_url in urls:
try:
# Substitute ''${IP} placeholder then validate through production validator.
# After substitution there must be no $ left; _validate_ddns_url rejects
# any remaining $ expression.
url = raw_url.replace("''${IP}", public_ip)
_validate_ddns_url(url)
subprocess.run(
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
timeout=20, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception:
pass
PYEOF
chmod 0500 /var/lib/sovran/ddns-update.py
''; '';
};
} }
+323
View File
@@ -0,0 +1,323 @@
# ── Unified public-IP detection (privacy-first) ─────────────────────────────
#
# One script, one cache file, every consumer on the system reads the same
# value. Previously the public IP was detected independently in three places,
# each phoning home to a different third party:
# * the Hub (server.py _get_external_ip) → api.ipify.org / ifconfig.me /
# icanhazip.com over HTTPS on every /api/network call and every
# background-loop tick
# * DDNS (ddns-update.py) → myip.opendns.com via OpenDNS
# * LiveKit → STUN (its own embedded detection)
#
# This module replaces all of that with a single script
# (/var/lib/sovran/public-ip.py) that detects the IP once per TTL using the
# least-exposing mechanism available, and caches it in
# /var/lib/secrets/external-ip. Consumers (Hub, DDNS, LiveKit) read the cache
# and only invoke the script when it is missing or stale.
#
# Detection chain (first success wins, stops immediately):
# 1. pin — sovran_systemsOS.elementCalling.externalIP (baked in)
# 2. cache — /var/lib/secrets/external-ip if newer than cacheTTL
# 3. STUN — UDP binding request (one packet, no application data,
# no HTTP metadata; the same protocol every WebRTC client
# uses). Server configurable via publicIP.stunServer.
# 4. DNS — "myip.opendns.com" A query via publicIP.dnsResolver
# (single DNS query, no HTTP headers)
# 5. HTTPS echo — ONLY endpoints listed in publicIP.httpsEcho (empty by
# default → never contacted)
#
# Privacy property: while the cache is fresh, zero third parties are
# contacted. When detection runs, at most ONE party learns the IP per
# refresh interval (default 5 minutes), and the STUN/DNS mechanisms expose
# nothing beyond the bare address.
{
config,
pkgs,
lib,
...
}:
let
stunServer = config.sovran_systemsOS.publicIP.stunServer;
stunPort = config.sovran_systemsOS.publicIP.stunPort;
dnsResolver = config.sovran_systemsOS.publicIP.dnsResolver;
httpsEcho = config.sovran_systemsOS.publicIP.httpsEcho;
cacheTTL = config.sovran_systemsOS.publicIP.cacheTTL;
# Optional pin shared with element-calling (baked in at build time).
pin = if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else "";
echoList = lib.concatStringsSep "," (map (u: "'${u}'") httpsEcho);
in
{
options.sovran_systemsOS.publicIP = {
stunServer = lib.mkOption {
type = lib.types.str;
default = "stun.l.google.com";
description = ''
STUN server used to discover the public IP over UDP. STUN is the most
privacy-preserving detection mechanism: a single stateless packet,
no HTTP metadata. Only used when the cache is stale.
'';
};
stunPort = lib.mkOption {
type = lib.types.port;
default = 19302;
};
dnsResolver = lib.mkOption {
type = lib.types.str;
default = "resolver4.opendns.com";
description = ''
DNS resolver used as fallback (myip.opendns.com trick) when STUN is
unavailable (e.g. ISP blocks UDP egress). A single DNS query, no
HTTP headers.
'';
};
httpsEcho = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "https://api.ipify.org" ];
description = ''
OPT-IN HTTPS endpoints that return the caller's public IP as a bare
IPv4 literal. Each listed endpoint observes this server's public IP
and HTTP metadata every time detection runs. Empty by default no
HTTPS echo service is ever contacted unless you add one here. This is
the last-resort fallback after STUN and DNS.
'';
};
cacheTTL = lib.mkOption {
type = lib.types.int;
default = 300;
description = "Seconds the detected public IP is cached before re-detection.";
};
};
# ── Install the unified detector ──────────────────────────────────────────
# This module declares `options` above, so ALL configuration must go under
# the `config` attribute: NixOS forbids mixing bare top-level settings
# (like `system.*`) with the `options`/`config` keyword attributes in the
# same module. (Fixes: "Module ... has an unsupported attribute `system'".)
config.system.activationScripts.sovranPublicIpInstall = lib.stringAfter [ "users" ] ''
install -d -m 0755 /var/lib/sovran
cat > /var/lib/sovran/public-ip.py <<'PYEOF'
#!/usr/bin/env python3
"""sovran-public-ip one detector, one cache, every consumer reads the same IP.
Privacy-first detection chain (first success wins):
1. pin baked in from sovran_systemsOS.elementCalling.externalIP
2. cache /var/lib/secrets/external-ip if newer than CACHE_TTL seconds
3. STUN UDP binding request (one packet, no application data)
4. DNS myip.opendns.com A query via the configured resolver
5. HTTPS ONLY endpoints baked in from publicIP.httpsEcho (opt-in)
Usage:
public-ip.py check print current public IP (cache first; refresh if stale)
public-ip.py refresh force re-detection, update the cache file, print IP
Exit status: 0 with the IP on stdout on success; 1 if no IP is available
(cached value, if any, is still printed to stdout with a warning on stderr).
"""
import ipaddress
import os
import random
import socket
import struct
import sys
import time
import urllib.request
CACHE_FILE = "/var/lib/secrets/external-ip"
PIN = "${pin}"
STUN_SERVER = "${stunServer}"
STUN_PORT = ${toString stunPort}
DNS_RESOLVER = "${dnsResolver}"
DNS_HOST = "myip.opendns.com"
ECHO_URLS = [ ${echoList} ]
CACHE_TTL = ${toString cacheTTL}
TIMEOUT = 3.0
# ---------------------------------------------------------------------------
# Detection primitives
# ---------------------------------------------------------------------------
def is_usable_ip(text: str) -> bool:
"""True if text is a globally routable IPv4 that LiveKit may advertise."""
try:
ip = ipaddress.ip_address(text)
except ValueError:
return False
if ip.version != 4:
return False
if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast
or ip.is_reserved or ip.is_unspecified or not ip.is_global):
return False
# RFC 6598 shared (CGNAT) space — not reachable from the internet.
if ip in ipaddress.ip_network("100.64.0.0/10"):
return False
return True
def stun_public_ip() -> str | None:
"""RFC 5389 Binding request over UDP; returns the mapped (public) IPv4."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(TIMEOUT)
try:
txid = random.randbytes(12)
req = struct.pack("!HHI", 0x0001, 0, 0) + txid # Binding request
sock.sendto(req, (STUN_SERVER, STUN_PORT))
data, _ = sock.recvfrom(2048)
except OSError:
return None
finally:
sock.close()
if len(data) < 20:
return None
mtype, _mlen = struct.unpack("!HH", data[:4])
if mtype != 0x0101: # Binding success response
return None
cookie = data[4:8]
i = 20
while i + 4 <= len(data):
atype, alen = struct.unpack("!HH", data[i : i + 4])
aval = data[i + 4 : i + 4 + alen]
if atype in (0x0001, 0x0020) and len(aval) >= 8: # MAPPED / XOR-MAPPED
family = aval[1]
if family == 0x01: # IPv4
raw = aval[4:8]
if atype == 0x0020: # XOR with magic cookie + txid prefix
raw = bytes(b ^ c for b, c in zip(raw, cookie + txid[:4]))
return socket.inet_ntop(socket.AF_INET, raw)
i += 4 + ((alen + 3) // 4) * 4
return None
def dns_public_ip() -> str | None:
"""Minimal DNS A query for myip.opendns.com against the given resolver."""
qid = random.randint(0, 0xFFFF)
qname = b"".join(bytes([len(p)]) + p.encode() for p in DNS_HOST.split(".")) + b"\x00"
query = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0) + qname + struct.pack("!HH", 1, 1)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(TIMEOUT)
try:
sock.sendto(query, (DNS_RESOLVER, 53))
data, _ = sock.recvfrom(4096)
except OSError:
return None
finally:
sock.close()
try:
if len(data) < 12:
return None
rid, _flags, _qd, an, _ns, _ar = struct.unpack("!HHHHHH", data[:12])
if rid != qid or an == 0:
return None
i = 12
for _ in range(_qd): # skip question
while data[i] != 0:
i += 1 + data[i]
i += 5
for _ in range(an):
if data[i] & 0xC0 == 0xC0:
i += 2
else:
while data[i] != 0:
i += 1 + data[i]
i += 1
rtype, _rclass, _ttl, rdlen = struct.unpack("!HHIH", data[i : i + 10])
i += 10
if rtype == 1 and rdlen == 4:
return socket.inet_ntop(socket.AF_INET, data[i : i + 4])
i += rdlen
except (IndexError, struct.error):
return None
return None
def echo_public_ip() -> str | None:
"""Opt-in HTTPS echo endpoints (baked in at build time; empty by default)."""
for url in ECHO_URLS:
try:
req = urllib.request.Request(url, headers={"User-Agent": "sovran-public-ip"})
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
text = resp.read().decode().strip()
if is_usable_ip(text):
return text
except Exception:
continue
return None
# ---------------------------------------------------------------------------
# Cache handling
# ---------------------------------------------------------------------------
def read_cache() -> str:
try:
with open(CACHE_FILE) as f:
return f.read().strip()
except OSError:
return ""
def write_cache(ip: str) -> None:
try:
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
tmp = f"{CACHE_FILE}.tmp"
with open(tmp, "w") as f:
f.write(ip + "\n")
os.replace(tmp, CACHE_FILE)
except OSError:
pass
def cache_fresh() -> bool:
try:
return time.time() - os.path.getmtime(CACHE_FILE) < CACHE_TTL
except OSError:
return False
def detect() -> str:
"""Run the chain; returns usable IP or an empty string."""
if PIN and is_usable_ip(PIN):
return PIN
for fn in (stun_public_ip, dns_public_ip, echo_public_ip):
try:
cand = fn()
except Exception:
continue
if cand and is_usable_ip(cand):
return cand
return ""
def main() -> int:
force = len(sys.argv) > 1 and sys.argv[1] == "refresh"
ip = ""
if not force and cache_fresh():
ip = read_cache()
if not ip:
ip = detect()
if ip:
write_cache(ip)
else:
stale = read_cache()
if stale:
print(stale)
print("WARNING: detection failed; using last known public IP", file=sys.stderr)
return 0
print("ERROR: could not determine a public IP (STUN/DNS unreachable)", file=sys.stderr)
return 1
print(ip)
return 0
if __name__ == "__main__":
sys.exit(main())
PYEOF
chmod 0555 /var/lib/sovran/public-ip.py
'';
}
+4 -4
View File
@@ -3,9 +3,9 @@
{ {
config = lib.mkMerge [ config = lib.mkMerge [
# nix-bitcoin is globally imported by the flake (nixosModules.Sovran_SystemsOS). # Vendored nix-bitcoin is always imported via modules/vendor/nix-bitcoin/modules.nix.
# This default satisfies nix-bitcoin's generateSecrets assertion so that Desktop # This default satisfies the secrets assertion so Desktop-Only systems evaluate
# Only systems can evaluate without enabling any Bitcoin services. # without enabling any Bitcoin services.
{ {
nix-bitcoin.generateSecrets = lib.mkDefault true; nix-bitcoin.generateSecrets = lib.mkDefault true;
} }
@@ -33,7 +33,7 @@
haven = lib.mkForce false; haven = lib.mkForce false;
mempool = lib.mkForce false; mempool = lib.mkForce false;
element-calling = lib.mkForce false; element-calling = lib.mkForce false;
bitcoin-core = lib.mkForce false; bitcoin-tor-gossip = lib.mkForce false;
"nwc-wallets" = lib.mkForce false; "nwc-wallets" = lib.mkForce false;
}; };
+44 -22
View File
@@ -45,23 +45,20 @@
haven = lib.mkEnableOption "Haven NOSTR relay"; haven = lib.mkEnableOption "Haven NOSTR relay";
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer"; mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
element-calling = lib.mkEnableOption "Element Video and Audio Calling"; element-calling = lib.mkEnableOption "Element Video and Audio Calling";
bitcoin-core = lib.mkEnableOption "Bitcoin Core"; bitcoin-tor-gossip = lib.mkEnableOption "Advertise the Bitcoin Core onion service through Bitcoin peer gossip";
"nwc-wallets" = lib.mkEnableOption "Lightning Wallet Connections"; # Compatibility shim for Hub-managed settings from releases where Core
rdp = lib.mkEnableOption "Gnome Remote Desktop"; # was an optional replacement for the default node. Core is now always
sshd = lib.mkEnableOption "SSH remote access"; # selected when the Bitcoin service is enabled.
bitcoin-core = lib.mkOption {
# 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; type = lib.types.nullOr lib.types.bool;
default = null; default = null;
internal = true; internal = true;
visible = false; visible = false;
description = "(Deprecated, no-op) BIP-110 is now built into Bitcoin Knots."; 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";
}; };
# ── Web exposure (controls Caddy vhosts) ────────────────── # ── Web exposure (controls Caddy vhosts) ──────────────────
@@ -82,6 +79,41 @@
}; };
}; };
# ── 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
module auto-detects the public IP at runtime (HTTPS egress
detection, falling back to STUN). Set it only to override a
mis-detected address (e.g. multi-WAN/VPN setups).
'';
};
};
# ── Domain setup registry ───────────────────────────────── # ── Domain setup registry ─────────────────────────────────
domainRequirements = lib.mkOption { domainRequirements = lib.mkOption {
type = lib.types.listOf (lib.types.submodule { type = lib.types.listOf (lib.types.submodule {
@@ -103,14 +135,4 @@
}; };
}; };
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.
''
];
};
} }
+70 -26
View File
@@ -3,6 +3,16 @@
let let
cfg = config.sovran_systemsOS; 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 = monitoredServices =
# ── Infrastructure — System Passwords (always present) ───── # ── Infrastructure — System Passwords (always present) ─────
[ [
@@ -27,13 +37,10 @@ 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"; } { label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above"; }
]; } ]; }
] ]
# ── Bitcoin Base (node implementations) ──────────────────── # ── Bitcoin Base ────────────────────────────────────────────
++ lib.optionals cfg.services.bitcoin [ ++ lib.optionals cfg.services.bitcoin [
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [ { name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; } { label = "Tor Bitcoin P2P Address Reachable only through Tor"; file = "/var/lib/tor/onion/bitcoind/hostname"; suffix = ":8333"; }
]; }
{ 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) ───────────── # ── Bitcoin Apps (services on top of the node) ─────────────
@@ -55,7 +62,6 @@ let
]; } ]; }
{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [ { 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 = "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 = [ { 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 = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
@@ -116,19 +122,29 @@ let
role = activeRole; role = activeRole;
services = monitoredServices; services = monitoredServices;
feature_manager = true; feature_manager = true;
feature_states = {
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
};
sovran_version = sovranVersion;
}); });
generatedVersions = pkgs.writeText "sovran-hub-versions.json" (builtins.toJSON { generatedVersions = pkgs.writeText "sovran-hub-versions.json" (builtins.toJSON {
"caddy.service" = if pkgs ? caddy then pkgs.caddy.version else "2.8.4"; "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"; "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"; "gnome-remote-desktop.service" = if pkgs ? gnome-remote-desktop then pkgs.gnome-remote-desktop.version else "46.0";
"bitcoind.service" = if pkgs ? bitcoind-knots then pkgs.bitcoind-knots.version else (if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.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"; "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"; "lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
"rtl.service" = if pkgs ? clightning-rtl then pkgs.clightning-rtl.version else (if pkgs ? rtl then pkgs.rtl.version else "0.15.2"); # Keep the fallbacks aligned with the vendored packages used by the
"btcpayserver.service" = if pkgs ? btcpayserver then pkgs.btcpayserver.version else "2.0.0"; # service modules (RTL 0.15.10 and Mempool 3.2.1). The nixpkgs attrs are
# optional because these packages are built locally in this repository.
"rtl.service" = if pkgs ? clightning-rtl then pkgs.clightning-rtl.version else (if pkgs ? rtl then pkgs.rtl.version else "0.15.10");
# BTCPay Server is intentionally sourced from pkgs.stable by the service
# module. Read the configured package here rather than pkgs.btcpayserver
# (unstable), otherwise the Hub can advertise a version that is not running.
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
"albyhub.service" = if pkgs ? albyhub then pkgs.albyhub.version else "1.8.0"; "albyhub.service" = if pkgs ? albyhub then pkgs.albyhub.version else "1.8.0";
"mempool.service" = if pkgs ? mempool then pkgs.mempool.version else "3.0.0"; "mempool.service" = if pkgs ? mempool then pkgs.mempool.version else "3.2.1";
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0"; "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"; "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"; "vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
@@ -144,8 +160,10 @@ let
LOG="/var/log/sovran-hub-update.log" LOG="/var/log/sovran-hub-update.log"
STATUS="/var/log/sovran-hub-update.status" STATUS="/var/log/sovran-hub-update.status"
GENERATION="/var/log/sovran-hub-update.generation"
echo "RUNNING" > "$STATUS" echo "RUNNING" > "$STATUS"
rm -f "$GENERATION"
: > "$LOG" : > "$LOG"
exec > >(tee -a "$LOG") 2>&1 exec > >(tee -a "$LOG") 2>&1
@@ -169,16 +187,23 @@ let
if [ "$RC" -eq 0 ]; then if [ "$RC" -eq 0 ]; then
echo " Step 2/3: nixos-rebuild boot (stage next reboot) " echo " Step 2/3: nixos-rebuild boot (stage next reboot) "
BOOT_OUT=$(nixos-rebuild boot --flake /etc/nixos --print-build-logs \ # Stream output straight into $LOG (see rebuild-script) so the Hub UI
# shows live progress instead of an empty log during long builds.
nixos-rebuild boot --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \ --option connect-timeout 10 \
--option stalled-download-timeout 90 \ --option stalled-download-timeout 90 \
--option download-attempts 7 \ --option download-attempts 7 \
--option fallback true 2>&1) --option fallback true
BOOT_RC=$? BOOT_RC=$?
echo "$BOOT_OUT"
if [ "$BOOT_RC" -ne 0 ]; then if [ "$BOOT_RC" -ne 0 ]; then
echo "[ERROR] nixos-rebuild boot failed" echo "[ERROR] nixos-rebuild boot failed"
RC=1 RC=1
elif ! 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 fi
echo "" echo ""
fi fi
@@ -224,20 +249,23 @@ let
echo "" echo ""
echo "" echo ""
echo " Rebuilding system configuration " echo " Rebuilding system configuration "
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \ # 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.
nixos-rebuild switch --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \ --option connect-timeout 10 \
--option stalled-download-timeout 90 \ --option stalled-download-timeout 90 \
--option download-attempts 7 \ --option download-attempts 7 \
--option fallback true 2>&1) --option fallback true
SWITCH_RC=$? SWITCH_RC=$?
echo "$SWITCH_OUT"
if [ "$SWITCH_RC" -eq 0 ]; then if [ "$SWITCH_RC" -eq 0 ]; then
echo "" echo ""
echo "" echo ""
echo " Rebuild completed successfully" echo " Rebuild completed successfully"
echo "" echo ""
echo "SUCCESS" > "$STATUS" echo "SUCCESS" > "$STATUS"
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then elif grep -q "switchInhibitors\|Pre-switch checks failed" "$LOG"; then
echo "" echo ""
echo " Build succeeded a reboot is required to apply this rebuild" echo " Build succeeded a reboot is required to apply this rebuild"
echo " (Critical system components changed; running nixos-rebuild boot instead)" echo " (Critical system components changed; running nixos-rebuild boot instead)"
@@ -262,17 +290,31 @@ let
fi fi
''; '';
# ── Brave launcher wrapper: stable profile dir so Wayland app_id is # ── Brave Origin launcher wrapper: a *persistent* per-user profile dir.
# deterministic and GNOME Shell can match the window to the .desktop # It must NOT be wiped on exit: the Hub's logout marker cookie
# entry (fixes generic gear icon appearing in the dock). # (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).
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" '' hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
export PATH="${lib.makeBinPath [ pkgs.brave pkgs.coreutils ]}:$PATH" export PATH="${lib.makeBinPath [ pkgs.brave-origin pkgs.coreutils ]}:$PATH"
HUB_DATA="/tmp/sovran-hub-brave-$(id -u)" # 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
mkdir -p "$HUB_DATA" 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 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" export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
brave --app=http://localhost:8937/auto-login \ # 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 \
--class=sovran-hub \ --class=sovran-hub \
--user-data-dir="$HUB_DATA" \ --user-data-dir="$HUB_DATA" \
--password-store=basic \ --password-store=basic \
@@ -307,7 +349,8 @@ let
sovran-hub-web = pkgs.python3Packages.buildPythonApplication { sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
pname = "sovran-systemsos-hub-web"; pname = "sovran-systemsos-hub-web";
version = "1.0.0"; # Keep the package metadata in lockstep with the version shown in the Hub.
version = sovranVersion;
format = "other"; format = "other";
src = ../../app; src = ../../app;
@@ -331,6 +374,7 @@ let
cp ${generatedConfig} $out/lib/sovran-hub-web/config.json cp ${generatedConfig} $out/lib/sovran-hub-web/config.json
cp ${generatedVersions} $out/lib/sovran-hub-web/versions.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 install -d $out/share/sovran-hub/icons
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
+175
View File
@@ -0,0 +1,175 @@
#!/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()
+44 -15
View File
@@ -44,6 +44,35 @@ let
fi fi
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 # Already applied — skip
if [ -f "$STAMP" ]; then if [ -f "$STAMP" ]; then
exit 0 exit 0
@@ -61,11 +90,11 @@ let
mkdir -p "$HOME/.config" mkdir -p "$HOME/.config"
cat > "$HOME/.config/mimeapps.list" << EOF cat > "$HOME/.config/mimeapps.list" << EOF
[Default Applications] [Default Applications]
text/html=brave-browser.desktop text/html=brave-origin.desktop
x-scheme-handler/http=brave-browser.desktop x-scheme-handler/http=brave-origin.desktop
x-scheme-handler/https=brave-browser.desktop x-scheme-handler/https=brave-origin.desktop
x-scheme-handler/about=brave-browser.desktop x-scheme-handler/about=brave-origin.desktop
x-scheme-handler/unknown=brave-browser.desktop x-scheme-handler/unknown=brave-origin.desktop
EOF EOF
${pkgs.dconf}/bin/dconf load / << EOF ${pkgs.dconf}/bin/dconf load / << EOF
@@ -104,7 +133,7 @@ search-filter-time-type='last_modified'
[org/gnome/shell] [org/gnome/shell]
disabled-extensions=['just-perfection-desktop@just-perfection'] 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'] enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'light-style@gnome-shell-extensions.gcampax.github.com']
favorite-apps=['brave-browser.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop'] 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']
welcome-dialog-last-shown-version='48.4' welcome-dialog-last-shown-version='48.4'
[org/gnome/desktop/app-folders] [org/gnome/desktop/app-folders]
@@ -112,7 +141,7 @@ folder-children=['Browsers', 'Office', 'Terminal', 'Chat', 'Bitcoin', 'Media', '
[org/gnome/desktop/app-folders/folders/Browsers] [org/gnome/desktop/app-folders/folders/Browsers]
name='Browsers' name='Browsers'
apps=['brave-browser.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop'] apps=['brave-origin.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
[org/gnome/desktop/app-folders/folders/Office] [org/gnome/desktop/app-folders/folders/Office]
name='Office' name='Office'
@@ -269,7 +298,7 @@ in
]; ];
favorite-apps = [ favorite-apps = [
"brave-browser.desktop" "brave-origin.desktop"
"org.gnome.Settings.desktop" "org.gnome.Settings.desktop"
"org.gnome.Nautilus.desktop" "org.gnome.Nautilus.desktop"
"sovran-hub.desktop" "sovran-hub.desktop"
@@ -292,7 +321,7 @@ in
"org/gnome/desktop/app-folders/folders/Browsers" = { "org/gnome/desktop/app-folders/folders/Browsers" = {
name = "Browsers"; name = "Browsers";
apps = [ apps = [
"brave-browser.desktop" "brave-origin.desktop"
"firefox.desktop" "firefox.desktop"
"org.gnome.Epiphany.desktop" "org.gnome.Epiphany.desktop"
]; ];
@@ -433,13 +462,13 @@ in
]; ];
xdg.mime.defaultApplications = { xdg.mime.defaultApplications = {
"text/html" = "brave-browser.desktop"; "text/html" = "brave-origin.desktop";
"x-scheme-handler/http" = "brave-browser.desktop"; "x-scheme-handler/http" = "brave-origin.desktop";
"x-scheme-handler/https" = "brave-browser.desktop"; "x-scheme-handler/https" = "brave-origin.desktop";
"x-scheme-handler/about" = "brave-browser.desktop"; "x-scheme-handler/about" = "brave-origin.desktop";
"x-scheme-handler/unknown" = "brave-browser.desktop"; "x-scheme-handler/unknown" = "brave-origin.desktop";
}; };
environment.sessionVariables.BROWSER = "brave-browser"; environment.sessionVariables.BROWSER = "brave-origin";
} }
+33 -12
View File
@@ -11,11 +11,10 @@
# (u:sovran-support:---) by the Hub API as soon as a session is started. # (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 # • The Hub web UI lets the user grant time-limited access to wallet files
# and view a full audit log of every session event. # and view a full audit log of every session event.
# • Scoped sudo rules allow support staff to edit custom.nix, trigger rebuilds, # • Scoped sudo rules allow support staff to restart specific services and
# restart services, and read logs — without full root or wallet access. # read logs — without full root, wallet access, Nix editing, or rebuilds.
# # • journalctl access is provided only through the root-owned
# The `acl` package provides the `setfacl` / `getfacl` utilities required by # sovran-journal-helper script (see below) with an allowlist of safe flags.
# the Hub's _apply_wallet_acls() and _revoke_wallet_acls() helpers.
{ {
# ── System packages ──────────────────────────────────────────────────────── # ── System packages ────────────────────────────────────────────────────────
environment.systemPackages = [ pkgs.acl ]; environment.systemPackages = [ pkgs.acl ];
@@ -42,18 +41,40 @@
"d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -" "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 ─────────────────────────────────── # ── Scoped sudo rules for support staff ───────────────────────────────────
# Grants only the minimum privileges needed for a support session. # Grants only the minimum privileges needed for diagnostic support.
# Support staff cannot stop/disable/mask services or access wallet files. # 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.
security.sudo.extraRules = [ security.sudo.extraRules = [
{ {
users = [ "sovran-support" ]; users = [ "sovran-support" ];
commands = [ commands = [
{ command = "/run/current-system/sw/bin/nano /etc/nixos/custom.nix"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl restart sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/nano /etc/nixos/configuration.nix"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl restart caddy.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/nixos-rebuild switch --flake /etc/nixos"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl restart bitcoind.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart *"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl restart lnd.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/journalctl *"; 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" ]; }
]; ];
} }
]; ];
+9 -1
View File
@@ -111,7 +111,15 @@ in
echo "$ROOT_PASS" > "$SECRET_FILE" echo "$ROOT_PASS" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE" chmod 600 "$SECRET_FILE"
fi fi
echo "root:$(cat "$SECRET_FILE")" | chpasswd # If the file contains a scrypt hash (salt:hash), skip chpasswd — the
# password was already set via the Hub security reset endpoint and this
# service is only re-running as a manual recovery step.
CONTENT="$(cat "$SECRET_FILE")"
if echo "$CONTENT" | grep -qE '^[0-9a-f]{32}:[0-9a-f]{64,}$'; then
echo "root-password-setup: stored value is already hashed skipping chpasswd" >&2
else
echo "root:$CONTENT" | chpasswd
fi
''; '';
}; };
+261 -21
View File
@@ -33,9 +33,15 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
''; '';
}; };
####### ENSURE SERVICES START AFTER KEY EXISTS ####### ####### ENSURE SERVICES START AFTER KEY & NETWORK EXIST #######
systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-setup.service" ]; # Ordering against network-online.target matters: livekit-turn-setup detects
systemd.services.livekit.wants = [ "livekit-key-setup.service" "livekit-turn-setup.service" ]; # 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" ];
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ]; systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ]; systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
@@ -114,7 +120,12 @@ EOF
# substituted) that the overridden ExecStart loads. # substituted) that the overridden ExecStart loads.
systemd.services.livekit-turn-setup = { systemd.services.livekit-turn-setup = {
description = "Stage TURN cert and generate LiveKit runtime config from domain files"; description = "Stage TURN cert and generate LiveKit runtime config from domain files";
after = [ "caddy.service" "livekit-key-setup.service" ]; # 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" ];
before = [ "livekit.service" ]; before = [ "livekit.service" ];
requiredBy = [ "livekit.service" ]; requiredBy = [ "livekit.service" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@@ -125,19 +136,39 @@ EOF
unitConfig = { unitConfig = {
ConditionPathExists = "/var/lib/domains/element-calling"; ConditionPathExists = "/var/lib/domains/element-calling";
}; };
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ]; path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk pkgs.python3 ];
script = '' script = ''
MATRIX=$(cat /var/lib/domains/matrix) MATRIX=$(cat /var/lib/domains/matrix)
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
mkdir -p /run/livekit mkdir -p /run/livekit
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir. # 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. # 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) CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | 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 "$CRT" /var/lib/livekit/turn.crt
cp "$KEY" /var/lib/livekit/turn.key cp "$KEY" /var/lib/livekit/turn.key
chmod 640 /var/lib/livekit/turn.crt /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. # Detect the primary network interface from the IPv4 default route.
# Restricting LiveKit to this single interface prevents it from # Restricting LiveKit to this single interface prevents it from
@@ -158,6 +189,56 @@ EOF
# rtc.interfaces.includes are only known at runtime, so they are # rtc.interfaces.includes are only known at runtime, so they are
# substituted here. The cert/key paths point at the LoadCredential-staged # substituted here. The cert/key paths point at the LoadCredential-staged
# copies under /run/credentials. # 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.
#
# Reuse the shared detector (/var/lib/sovran/public-ip.py — see
# modules/core/public-ip.nix) instead of running our own: one script,
# one cache, privacy-first (STUN -> DNS -> opt-in HTTPS echo). Priority:
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
# 2. /var/lib/secrets/external-ip (the shared cache)
# 3. run the detector now (it refreshes the cache)
# 4. STUN auto-detection (use_external_ip) as the fallback, with a
# warning — this is where broken installs used to silently end up
# advertising a private IP, causing "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" ] && [ -x /var/lib/sovran/public-ip.py ]; then
PUBLIC_IP=$(python3 /var/lib/sovran/public-ip.py check 2>/dev/null | head -n1)
fi
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
# A detected/pinned address like this must never be advertised.
if [ -n "$PUBLIC_IP" ] && printf '%s' "$PUBLIC_IP" | grep -qE \
'^(0\.|127\.|10\.|100\.64\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
echo "WARNING: external IP '$PUBLIC_IP' is not routable; falling back to STUN auto-detection." >&2
PUBLIC_IP=""
fi
if [ -n "$PUBLIC_IP" ]; then
cat > /run/livekit/livekit.yaml <<EOF
port: 7880
rtc:
use_external_ip: false
node_ip: $PUBLIC_IP
tcp_port: 7881
udp_port: 7882
port_range_start: 30000
port_range_end: 40000
interfaces:
includes:
- $IFACE
EOF
echo "LiveKit will advertise public IP: $PUBLIC_IP"
else
cat > /run/livekit/livekit.yaml <<EOF cat > /run/livekit/livekit.yaml <<EOF
port: 7880 port: 7880
rtc: rtc:
@@ -170,6 +251,20 @@ rtc:
interfaces: interfaces:
includes: includes:
- $IFACE - $IFACE
EOF
echo "WARNING: could not determine a public IP for LiveKit; using STUN auto-detection. If calls connect without media, check STUN egress or set sovran_systemsOS.elementCalling.externalIP." >&2
fi
# 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:]')
cat >> /run/livekit/livekit.yaml <<EOF
room: room:
auto_create: false auto_create: false
turn: turn:
@@ -179,6 +274,10 @@ turn:
udp_port: 3478 udp_port: 3478
cert_file: /run/credentials/livekit.service/turn-cert cert_file: /run/credentials/livekit.service/turn-cert
key_file: /run/credentials/livekit.service/turn-key key_file: /run/credentials/livekit.service/turn-key
webhook:
api_key: $LK_KEY
urls:
- https://$ELEMENT_CALLING/livekit/jwt/sfu_webhook
EOF EOF
chmod 644 /run/livekit/livekit.yaml chmod 644 /run/livekit/livekit.yaml
@@ -186,24 +285,17 @@ EOF
}; };
####### LIVEKIT SERVICE ####### ####### 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 = { services.livekit = {
enable = true; enable = true;
openFirewall = true; openFirewall = false;
keyFile = livekitKeyFile; keyFile = livekitKeyFile;
settings = {
rtc.use_external_ip = true;
rtc.skip_external_ip_validation = true;
rtc.tcp_port = 7881;
rtc.udp_port = 7882;
rtc.port_range_start = 30000;
rtc.port_range_end = 40000;
room.auto_create = false;
turn = {
enabled = true;
tls_port = 5349;
udp_port = 3478;
};
};
}; };
# Override ExecStart to load the runtime-generated config (which carries the # Override ExecStart to load the runtime-generated config (which carries the
@@ -247,12 +339,26 @@ EOF
script = '' script = ''
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling) ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
MATRIX=$(cat /var/lib/domains/matrix) 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 mkdir -p /run/lk-jwt-service
cat > /run/lk-jwt-service/env <<EOF cat > /run/lk-jwt-service/env <<EOF
LIVEKIT_URL=wss://$ELEMENT_CALLING LIVEKIT_URL=wss://$ELEMENT_CALLING
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX 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 EOF
chmod 640 /run/lk-jwt-service/env chmod 640 /run/lk-jwt-service/env
@@ -264,6 +370,9 @@ EOF
enable = true; enable = true;
port = 8073; port = 8073;
keyFile = livekitKeyFile; 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"; livekitUrl = "wss://placeholder.local";
}; };
@@ -271,6 +380,126 @@ EOF
"/run/lk-jwt-service/env" "/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.
# Re-run the config generator and restart LiveKit when a rebuild regenerates
# the runtime config, or when the Hub persists a new external IP (dynamic
# WAN IPs), so the advertised ICE candidate stays current without a manual
# restart. The trigger chain: external-ip change → livekit-turn-setup
# re-runs → rewrites livekit.yaml → livekit restarts with the new config.
systemd.services.livekit-turn-setup.restartTriggers = [ "/var/lib/secrets/external-ip" ];
systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
####### 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) ####### ####### SYNAPSE RUNTIME CONFIG (element-calling additions) #######
systemd.services.element-calling-synapse-config = { systemd.services.element-calling-synapse-config = {
description = "Generate Synapse runtime config for Element Calling"; description = "Generate Synapse runtime config for Element Calling";
@@ -287,6 +516,7 @@ EOF
path = [ pkgs.coreutils ]; path = [ pkgs.coreutils ];
script = '' script = ''
MATRIX=$(cat /var/lib/domains/matrix) MATRIX=$(cat /var/lib/domains/matrix)
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
mkdir -p /run/matrix-synapse mkdir -p /run/matrix-synapse
@@ -296,7 +526,17 @@ public_baseurl: "https://$MATRIX"
serve_server_wellknown: true serve_server_wellknown: true
experimental_features: experimental_features:
msc3266_enabled: true 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 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" max_event_delay_duration: "24h"
rc_message: rc_message:
per_second: 0.5 per_second: 0.5
+1 -1
View File
@@ -17,6 +17,7 @@
./core/no-sleep.nix ./core/no-sleep.nix
./core/cpu-performance.nix ./core/cpu-performance.nix
./core/local-domain-loopback.nix ./core/local-domain-loopback.nix
./core/public-ip.nix
# ── Always on (no flag) ─────────────────────────────────── # ── Always on (no flag) ───────────────────────────────────
./php.nix ./php.nix
@@ -35,7 +36,6 @@
./nwc-wallets.nix ./nwc-wallets.nix
./element-calling.nix ./element-calling.nix
./mempool.nix ./mempool.nix
./bitcoin-core.nix
./rdp.nix ./rdp.nix
./sshd.nix ./sshd.nix
]; ];
+24
View File
@@ -229,6 +229,30 @@ CREDS
chmod 600 "$CREDS_FILE" chmod 600 "$CREDS_FILE"
fi 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 # 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). # 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. # If passwords were not freshly generated above, parse them from the bulk file.
+22 -7
View File
@@ -47,29 +47,44 @@ EOF
systemd.services.zeus-connect-setup = { systemd.services.zeus-connect-setup = {
description = "Save Zeus lndconnect URL"; description = "Save Zeus lndconnect URL";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "lnd.service" ]; after = [ "lnd.service" "onion-addresses.service" ];
wants = [ "lnd.service" "onion-addresses.service" ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
}; };
path = [ pkgs.coreutils "/run/current-system/sw" ]; # 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" ];
script = '' script = ''
SECRET_FILE="/var/lib/secrets/zeus-connect-url" SECRET_FILE="/var/lib/secrets/zeus-connect-url"
mkdir -p /var/lib/secrets 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="" URL=""
ATTEMPTS=0
while [ "$ATTEMPTS" -lt 60 ]; do
if command -v lndconnect >/dev/null 2>&1; then if command -v lndconnect >/dev/null 2>&1; then
URL=$(lndconnect --url 2>/dev/null || true) URL=$(lndconnect --url 2>/dev/null | tr -d '\r' | tail -n 1 || true)
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
fi 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 if [ -n "$URL" ]; then
echo "$URL" > "$SECRET_FILE" printf '%s\n' "$URL" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE" chmod 600 "$SECRET_FILE"
echo "Zeus connect URL saved." echo "Zeus connect URL saved."
else else
echo "No lndconnect URL available yet." echo "No valid lndconnect URL available yet."
fi fi
''; '';
}; };
@@ -0,0 +1,79 @@
# Vendored from fort-nix/nix-bitcoin commit 360e30fee.
# This local copy does not fetch or import nix-bitcoin.
# This is a modified version of
# https://github.com/NixOS/nixpkgs/pull/128749
{ lib, stdenvNoCC, makeWrapper, nodejs, cacert }:
{ src
, hash ? ""
, runScripts ? false
, preferLocalBuild ? true
, npmFlags ? ""
, ...
} @ args:
stdenvNoCC.mkDerivation ({
inherit src preferLocalBuild;
name = "${src.name}-node_modules";
nativeBuildInputs = [
makeWrapper
(if args ? nodejs then args.nodejs else nodejs)
];
outputHashMode = "recursive";
impureEnvVars = lib.fetchers.proxyImpureEnvVars;
phases = "unpackPhase patchPhase buildPhase installPhase";
# npm doesn't support var `SSL_CERT_FILE`.
NODE_EXTRA_CA_CERTS = "${cacert}/etc/ssl/certs/ca-bundle.crt";
buildPhase = ''
runHook preBuild
if [[ ! -f package.json ]]; then
echo "Error: file `package.json` doesn't exist"
exit 1
fi
if [[ ! -f package-lock.json ]]; then
echo "Error: file `package-lock.json` doesn't exist"
exit 1
fi
export SOURCE_DATE_EPOCH=1
export npm_config_cache=/tmp
NPM_FLAGS="--omit=dev --omit=optional --no-update-notifier $npmFlags"
# Scripts may result in non-deterministic behavior.
# Some packages (e.g., Puppeteer) use postinstall scripts to download extra data.
if [[ ! $runScripts ]]; then
NPM_FLAGS+=" --ignore-scripts"
fi
echo "Running npm ci $NPM_FLAGS"
npm ci $NPM_FLAGS
cp package.json \
package-lock.json node_modules/
rm -f node_modules/.package-lock.json
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/lib
cp -r node_modules $out/lib
runHook postInstall
'';
} // (
if hash == "" then {
outputHashAlgo = "sha256";
outputHash = "";
} else {
outputHash = hash;
}
) // (builtins.removeAttrs args [ "hash" ]))
@@ -0,0 +1,41 @@
From e4b3ebaf0451c1bddbd7dcf8527c296938ebb607 Mon Sep 17 00:00:00 2001
From: Erik Arvstedt <erik.arvstedt@gmail.com>
Date: Sun, 1 Jun 2025 11:17:22 +0200
Subject: [PATCH] allow disabling mining pool fetching in offline environments
Previously, Mempool strictly required fetching mining pool data from
Github and failed when this was not possible, e.g. in offline
environments.
This patch allows disabling pool fetching.
When disabled, empty pool data is inserted into the DB, which
effectively turns off block pool classification.
---
backend/src/tasks/pools-updater.ts | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/backend/src/tasks/pools-updater.ts b/backend/src/tasks/pools-updater.ts
index 6b0520dfc..a74259b95 100644
--- a/backend/src/tasks/pools-updater.ts
+++ b/backend/src/tasks/pools-updater.ts
@@ -75,7 +75,7 @@ class PoolsUpdater {
} else {
logger.warn(`pools-v2.json is outdated, fetching latest from ${this.poolsUrl} over ${network}`, this.tag);
}
- const poolsJson = await this.query(this.poolsUrl);
+ const poolsJson = (githubSha == "disable-pool-fetching") ? [] : await this.query(this.poolsUrl);
if (poolsJson === undefined) {
return;
}
@@ -136,6 +136,9 @@ class PoolsUpdater {
* Fetch our latest pools-v2.json sha from github
*/
private async fetchPoolsSha(): Promise<string | null> {
+ if (this.poolsUrl == "disable-pool-fetching") {
+ return "disable-pool-fetching";
+ }
const response = await this.query(this.treeUrl);
if (response !== undefined) {
--
2.47.2
+203
View File
@@ -0,0 +1,203 @@
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
# This local copy does not fetch or import nix-bitcoin.
{ lib
, stdenvNoCC
, nodejs_22
, nodejs-slim_22
, fetchFromGitHub
, fetchNodeModules
, runCommand
, makeWrapper
, curl
, cacert
, rsync
# for rust-gbt (backend module)
, cargo
, rustc
, rustPlatform
, napi-rs-cli
}:
rec {
nodejs = nodejs_22;
nodejsRuntime = nodejs-slim_22;
version = "3.2.1";
src = fetchFromGitHub {
owner = "mempool";
repo = "mempool";
tag = "v${version}";
hash = "sha256-O2XPD1/BXQnzuOP/vMVyRfmFZEgjA85r+PShWne0vqU=";
};
nodeModules = {
frontend = fetchNodeModules {
inherit src nodejs;
sourceRoot = "source/frontend";
hash = "sha256-+jfgsAkDdYvgso8uSHaBj/sQL3fC/ABQWzVTXfdZcU0=";
};
backend = fetchNodeModules {
inherit src nodejs;
sourceRoot = "source/backend";
hash = "sha256-y5l2SYZYK9SKSy6g0+mtTWD6JFkkdQHHBboECpEvWZ4=";
};
};
frontendAssets = fetchFiles {
name = "mempool-frontend-assets";
hash = "sha256-r6GfOY8Pdh15o2OQMk8syfvWMV6WMCReToAEkQm7tqQ=";
fetcher = ./frontend-assets-fetch.sh;
};
mempool-backend = mkDerivationMempool {
pname = "mempool-backend";
patches = [ ./0001-allow-disabling-mining-pool-fetching.patch ];
buildPhase = ''
cd backend
${sync} --chmod=+w ${nodeModules.backend}/lib/node_modules .
patchShebangs node_modules
${sync} ${mempool-rust-gbt}/ rust-gbt
npm run package
runHook postBuild
'';
installPhase = ''
mkdir -p $out/lib/mempool-backend
${sync} package/ $out/lib/mempool-backend
makeWrapper ${nodejsRuntime}/bin/node $out/bin/mempool-backend \
--add-flags $out/lib/mempool-backend/index.js
runHook postInstall
'';
passthru = {
inherit nodejs nodejsRuntime;
nodeModules = nodeModules.backend;
};
};
mempool-frontend = mkFrontend {};
# Argument `config` (type: attrset) defines the mempool frontend config.
# If `{}`, the default config is used.
# See here for available options:
# https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
# (`interface Env` and `defaultEnv`)
mkFrontend = config: mkDerivationMempool {
pname = "mempool-frontend";
buildPhase = ''
cd frontend
${sync} --chmod=+w ${nodeModules.frontend}/lib/node_modules .
patchShebangs node_modules
# sync-assets.js is called during `npm run build` and downloads assets from the
# internet. Disable this script and instead add the assets manually after building.
: > sync-assets.js
${lib.optionalString (config != {}) ''
ln -s ${builtins.toFile "mempool-frontend-config" (builtins.toJSON config)} mempool-frontend-config.json
''}
npm run build
# Add assets that would otherwise be downloaded by sync-assets.js
${sync} ${frontendAssets}/ dist/mempool/browser/resources
runHook postBuild
'';
installPhase = ''
${sync} dist/mempool/browser/ $out
runHook postInstall
'';
passthru = {
withConfig = mkFrontend;
assets = frontendAssets;
nodeModules = nodeModules.frontend;
};
};
mempool-rust-gbt = stdenvNoCC.mkDerivation rec {
pname = "mempool-rust-gbt";
inherit version src meta;
sourceRoot = "source/rust/gbt";
nativeBuildInputs = [
rustPlatform.cargoSetupHook
cargo
rustc
napi-rs-cli
];
cargoDeps = rustPlatform.fetchCargoVendor {
inherit src;
name = "${pname}-${version}";
inherit sourceRoot;
hash = "sha256-eox/K3ipjAqNyFt87lZnxaU/okQLF/KIhqXrX86n+qw=";
};
buildPhase = ''
runHook preBuild
# napi doesn't accept an absolute path as dest dir, so we can't directly write to $out
napi build --platform --release --strip out
runHook postBuild
'';
installPhase = ''
mv out $out
cp package.json $out
'';
passthru = { inherit cargoDeps; };
};
mempool-nginx-conf = runCommand "mempool-nginx-conf" {} ''
${sync} --chmod=u+w ${./nginx-conf}/ $out
${sync} ${src}/production/nginx/http-language.conf $out
'';
sync = "${rsync}/bin/rsync -a --inplace";
mkDerivationMempool = args: stdenvNoCC.mkDerivation ({
inherit version src meta;
nativeBuildInputs = [
makeWrapper
nodejs
rsync
];
phases = "unpackPhase patchPhase buildPhase installPhase";
} // args);
fetchFiles = { name, hash, fetcher }: stdenvNoCC.mkDerivation {
inherit name;
outputHashMode = "recursive";
outputHashAlgo = "sha256";
outputHash = hash;
nativeBuildInputs = [ curl cacert ];
buildCommand = ''
mkdir $out
cd $out
${builtins.readFile fetcher}
'';
};
meta = with lib; {
description = "Bitcoin blockchain and mempool explorer";
homepage = "https://github.com/mempool/mempool/";
license = licenses.agpl3Plus;
maintainers = with maintainers; [ erikarvstedt ];
platforms = platforms.unix;
};
}
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
# Fetch hash-locked versions of assets that are dynamically fetched via
# https://github.com/mempool/mempool/blob/master/frontend/sync-assets.js
# when running `npm run build` in the frontend.
#
# This file is updated by ./frontend-assets-update.sh
declare -A revs=(
["mempool/mining-pool-logos"]=53972ebbd08373cf4910cbb3e6421a1f3bba4563
)
fetchFile() {
repo=$1
file=$2
rev=${revs["$repo"]}
curl -fsS "https://raw.githubusercontent.com/$repo/$rev/$file"
}
fetchRepo() {
repo=$1
rev=${revs["$repo"]}
curl -fsSL "https://github.com/$repo/archive/$rev.tar.gz"
}
mkdir mining-pools
fetchRepo "mempool/mining-pool-logos" | tar xz --strip-components=1 -C mining-pools
@@ -0,0 +1,47 @@
# Settings adapted from
# https://github.com/mempool/mempool/blob/v3.2.1/production/nginx/server-common.conf
# see order of nginx location rules
# https://stackoverflow.com/questions/5238377/nginx-location-priority
# for exact / requests, redirect based on $lang
# cache redirect for 5 minutes
location = / {
if ($lang != '') {
return 302 $scheme://$host/$lang/;
}
try_files /en-US/index.html =404;
expires 5m;
}
# cache /<lang>/main.f40e91d908a068a2.js forever since they never change
location ~ ^/([a-z][a-z])/(.+\..+\.(js|css))$ {
try_files $uri =404;
expires 1y;
}
# cache everything else for 5 minutes
location ~ ^/([a-z][a-z])$ {
try_files $uri /$1/index.html /en-US/index.html =404;
expires 5m;
}
location ~ ^/([a-z][a-z])/ {
try_files $uri /$1/index.html /en-US/index.html =404;
expires 5m;
}
# cache /resources/** for 1 week since they don't change often
location /resources {
try_files $uri /en-US/index.html;
expires 1w;
}
# cache /main.f40e91d908a068a2.js forever since they never change
location ~* ^/.+\..+\.(js|css)$ {
try_files /$lang/$uri /en-US/$uri =404;
expires 1y;
}
# catch-all for all URLs i.e. /address/foo /tx/foo /block/000
# cache 5 minutes since they change frequently
location / {
try_files /$lang/$uri $uri /en-US/$uri /en-US/index.html =404;
expires 5m;
}
+66
View File
@@ -0,0 +1,66 @@
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
# This local copy does not fetch or import nix-bitcoin.
{ lib
, stdenvNoCC
, nodejs_22
, nodejs-slim_22
, fetchNodeModules
, fetchurl
, makeWrapper
}:
let self = stdenvNoCC.mkDerivation {
pname = "rtl";
version = "0.15.10";
src = fetchurl {
url = "https://github.com/Ride-The-Lightning/RTL/archive/refs/tags/v${self.version}.tar.gz";
hash = "sha256-r5riYV2FN0OKi0mwj9I1jBeeU1LOv2HVB6CEovPlUuY=";
};
passthru = {
nodejs = nodejs_22;
nodejsRuntime = nodejs-slim_22;
nodeModules = fetchNodeModules {
inherit (self) src nodejs;
# TODO-EXTERNAL: Remove `npmFlags` when no longer required
# See: https://github.com/Ride-The-Lightning/RTL/issues/1182
npmFlags = "--legacy-peer-deps";
hash = "sha256-NKiWcjqYcHBVIB+vbF3aKXLe2fJRmh/quu8obztP3TA=";
};
};
nativeBuildInputs = [
makeWrapper
];
phases = "unpackPhase patchPhase installPhase";
# `src` already contains the precompiled frontend and backend.
# Copy all files required for packaging, like in
# https://github.com/Ride-The-Lightning/RTL/blob/master/dockerfiles/Dockerfile
installPhase = ''
dest=$out/lib/node_modules/rtl
mkdir -p $dest
cp -r \
rtl.js \
package.json \
frontend \
backend \
${self.nodeModules}/lib/node_modules \
$dest
makeWrapper ${self.nodejsRuntime}/bin/node "$out/bin/rtl" \
--add-flags "$dest/rtl.js"
runHook postInstall
'';
meta = with lib; {
description = "A web interface for LND, c-lightning and Eclair";
homepage = "https://github.com/Ride-The-Lightning/RTL";
license = licenses.mit;
maintainers = with maintainers; [ nixbitcoin erikarvstedt ];
platforms = platforms.unix;
};
}; in self
+377 -117
View File
@@ -20,6 +20,11 @@
set -euo pipefail 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 # Colors
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@@ -29,11 +34,31 @@ CYAN='\033[0;36m'
NC='\033[0m' NC='\033[0m'
# Configuration # Configuration
GITEA_REMOTE="gitea" GITEA_REMOTE_DEFAULT="gitea"
GITHUB_REMOTE="origin" GITHUB_REMOTE_DEFAULT="origin"
GITEA_API_URL="https://git.sovransystems.com/api/v1" GITEA_API_URL="https://git.sovransystems.com/api/v1"
CHANGELOG_FILE="CHANGELOG.md" 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}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Sovran_SystemsOS Automated Stable Release Script ║${NC}" echo -e "${BLUE}║ Sovran_SystemsOS Automated Stable Release Script ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}" echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
@@ -42,8 +67,28 @@ echo
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Helper: Get latest tag # 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() { get_latest_tag() {
git tag --list 'v*' --sort=-version:refname | head -1 || echo "" 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
} }
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -65,10 +110,84 @@ suggest_next_version() {
} }
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 0: Fetch everything # Step 0: Preflight and fetch everything
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo -e "${BLUE}Step 0: Fetching latest from all remotes...${NC}" echo -e "${BLUE}Step 0: Running release preflight...${NC}"
git fetch --all --tags --prune --force || git fetch --all --tags --prune --force 2>/dev/null || true
# 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) LATEST_TAG=$(get_latest_tag)
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG") NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
@@ -92,6 +211,23 @@ fi
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
TAG="v${VERSION}" 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 # Get release message
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -110,7 +246,7 @@ done
if [[ -z "$RELEASE_MESSAGE" ]]; then if [[ -z "$RELEASE_MESSAGE" ]]; then
echo echo
read -rp "Enter release message (or press Enter for default): " input_msg read -rp "Enter release headline (or press Enter for default): " input_msg
if [[ -n "$input_msg" ]]; then if [[ -n "$input_msg" ]]; then
RELEASE_MESSAGE="$input_msg" RELEASE_MESSAGE="$input_msg"
else else
@@ -118,6 +254,98 @@ if [[ -z "$RELEASE_MESSAGE" ]]; then
fi fi
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
echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}" echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Preparing Release${NC}" echo -e "${YELLOW} Preparing Release${NC}"
@@ -135,59 +363,44 @@ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
fi fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 1: Push main to stable # Step 1: Prepare and commit all release metadata
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo echo
echo -e "${BLUE}Step 1: Pushing main → stable on Gitea...${NC}" echo -e "${BLUE}Step 1: Preparing release metadata for ${TAG}...${NC}"
git push "${GITEA_REMOTE}" main:stable --force-with-lease
# ───────────────────────────────────────────────────────────────────────────── # VERSION drives ISO naming.
# Step 2: Create annotated tag
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 2: Creating annotated tag ${TAG}...${NC}"
git tag -a "${TAG}" -m "${RELEASE_MESSAGE}
- Stable release of Sovran_SystemsOS
- See CHANGELOG.md for full details"
git push "${GITEA_REMOTE}" "${TAG}"
# Update VERSION file for ISO builds
echo "${VERSION}" > VERSION echo "${VERSION}" > VERSION
git add VERSION
git commit -m "chore: bump VERSION to ${TAG} for ISO naming" || true
echo -e " ${GREEN}${NC} VERSION file updated to ${VERSION}"
# ───────────────────────────────────────────────────────────────────────────── # Update every versioned ISO filename in README.md.
# Step 3: Auto-update CHANGELOG.md README_FILE="README.md"
# ───────────────────────────────────────────────────────────────────────────── if [[ ! -f "$README_FILE" ]]; then
echo echo -e "${RED}Error: ${README_FILE} not found.${NC}" >&2
echo -e "${BLUE}Step 3: Updating ${CHANGELOG_FILE}...${NC}" 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) TODAY=$(date +%Y-%m-%d)
# Create new changelog entry
NEW_ENTRY="## [${VERSION}] - ${TODAY} NEW_ENTRY="## [${VERSION}] - ${TODAY}
### Added ${RELEASE_NOTES}
- (Add new features here) [${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
### Changed
- (Add changes here)
### Fixed
- (Add bug fixes here)
[${VERSION}]: ${GITEA_API_URL%/*}/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
" "
# Prepend to changelog (after the header) if [[ ! -f "$CHANGELOG_FILE" ]]; then
if [ -f "$CHANGELOG_FILE" ]; then echo -e "${RED}Error: ${CHANGELOG_FILE} not found.${NC}" >&2
# Backup exit 1
cp "$CHANGELOG_FILE" "${CHANGELOG_FILE}.bak" fi
# Insert new section after the first --- line
awk -v new_entry="$NEW_ENTRY" ' awk -v new_entry="$NEW_ENTRY" '
BEGIN { printed=0 } BEGIN { printed=0 }
/^---$/ && !printed { /^---$/ && !printed {
@@ -198,93 +411,134 @@ if [ -f "$CHANGELOG_FILE" ]; then
next next
} }
{ print } { print }
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" && mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE" 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"
echo -e " ${GREEN}${NC} CHANGELOG.md updated with new section for ${TAG}" git add VERSION "$README_FILE" "$CHANGELOG_FILE"
else
echo -e " ${YELLOW}${NC} CHANGELOG.md not found — skipping"
fi
# Commit the changelog update
git add "$CHANGELOG_FILE"
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo " (No changes to commit in changelog)" echo -e "${RED}Error: release preparation produced no changes.${NC}" >&2
else exit 1
git commit -m "docs: update CHANGELOG.md for ${TAG}" fi
echo -e " ${GREEN}${NC} Committed changelog update" git commit -m "chore(release): prepare ${TAG}"
RELEASE_COMMIT=$(git rev-parse HEAD)
# Ask if user wants to push echo -e " ${GREEN}${NC} VERSION, README, and CHANGELOG committed"
echo echo -e " Release commit: ${CYAN}${RELEASE_COMMIT}${NC}"
read -rp "Push the changelog commit to GitHub now? (y/N): " push_confirm
if [[ "$push_confirm" =~ ^[Yy]$ ]]; then
echo -e "${BLUE}Pushing changelog commit...${NC}"
git push "${GITHUB_REMOTE}" main
echo -e " ${GREEN}${NC} Changelog pushed to GitHub"
else
echo " (Changelog commit left local — remember to push later)"
fi
fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 4: Create GitHub Release via gh CLI # Step 2: Publish the final release commit to every release branch
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo echo
echo -e "${BLUE}Step 4: Creating GitHub Release...${NC}" echo -e "${BLUE}Step 2: Publishing the final release commit...${NC}"
if command -v gh &>/dev/null; then # GitHub main and Gitea staging-dev were verified equal during preflight, so
if gh release create "${TAG}" \ # 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 \ --repo naturallaw777/Sovran_SystemsOS \
--title "${TAG}" \ --title "${TAG}${title_suffix}" \
--notes "${RELEASE_MESSAGE}" \ --notes-file "$NOTES_FILE"
--target main 2>/dev/null; then
echo -e " ${GREEN}${NC} GitHub release created successfully" echo -e " ${GREEN}${NC} GitHub release created successfully"
else
echo -e " ${YELLOW}${NC} GitHub release may already exist or failed"
fi
else
echo -e " ${YELLOW}${NC} gh CLI not found — skipping GitHub release"
fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 5: Create Gitea Release via API # Step 5: Create Gitea release via API
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo echo
echo -e "${BLUE}Step 5: Creating Gitea Release via API...${NC}" echo -e "${BLUE}Step 5: Creating Gitea release via API...${NC}"
# ── Gitea Token Handling ─────────────────────────────────────────────────────
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo
echo -e "${YELLOW}GITEA_TOKEN is not set.${NC}"
read -rsp "Enter your Gitea API token (input will be hidden): " GITEA_TOKEN
echo
if [[ -z "$GITEA_TOKEN" ]]; then
echo -e " ${YELLOW}${NC} No token provided — skipping Gitea release"
GITEA_TOKEN=""
fi
fi
if [[ -n "${GITEA_TOKEN:-}" ]]; then
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS" GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
RESPONSE=$(curl -s -X POST \ # 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 "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{ -d "$PAYLOAD" \
\"tag_name\": \"${TAG}\", "${GITEA_API_URL}/repos/${GITEA_REPO}/releases")
\"name\": \"${TAG}\",
\"body\": \"${RELEASE_MESSAGE}\",
\"draft\": false,
\"prerelease\": false
}" \
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases" 2>/dev/null || echo "")
if echo "$RESPONSE" | grep -q '"id"'; then 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" echo -e " ${GREEN}${NC} Gitea release created successfully"
else
echo -e " ${YELLOW}${NC} Gitea release creation failed or already exists"
echo " Response: $RESPONSE"
fi
fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Final Summary # Final Summary
@@ -295,9 +549,15 @@ echo -e "${GREEN}║ ✅ Release ${TAG} completed successfully!
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}" echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
echo echo
echo "Next manual steps (recommended):" echo "Next manual steps (recommended):"
echo " • Review and enhance the new section in CHANGELOG.md" echo " • Build the installer ISO:"
echo " • Push the changelog commit: git push origin main" 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 " • Verify releases on both GitHub and Gitea"
echo echo
echo -e "${CYAN}Tag created: ${TAG}${NC}" echo -e "${CYAN}Tag created: ${TAG}${NC}"
git show "${TAG}" --quiet git show "${TAG}" --quiet
# Clean up temp notes file
rm -f "${NOTES_FILE}"
+168
View File
@@ -0,0 +1,168 @@
#!/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
+146
View File
@@ -0,0 +1,146 @@
{ nixpkgs, overlay-stable, system ? "x86_64-linux" }:
let
lib = nixpkgs.lib;
pkgs = import nixpkgs {
inherit system;
overlays = [ overlay-stable ];
};
normalize = s:
lib.replaceStrings [ "\n" "\\" " " ] [ " " "" " " ] s;
extractAfter = prefix: str:
let
match = builtins.match ".*${prefix} ([^ ]+).*" (normalize str);
in
if match == null then
throw "Unable to extract ${prefix} from: ${normalize str}"
else
builtins.head match;
extractFlagValue = flag: str:
let
match = builtins.match ".*${flag}=([^ ]+).*" (normalize str);
in
if match == null then
throw "Unable to extract ${flag} from: ${normalize str}"
else
builtins.head match;
config = (lib.nixosSystem {
inherit system;
modules = [
{ nixpkgs.hostPlatform = system; nixpkgs.overlays = [ overlay-stable ]; }
../modules/bitcoin
{
nix-bitcoin.generateSecrets = true;
nix-bitcoin.secretsDir = "/build/secrets";
services.btcpayserver.enable = true;
services.btcpayserver.lightningBackend = "lnd";
services.nbxplorer.dataDir = "/build/nbxplorer";
services.btcpayserver.dataDir = "/build/btcpayserver";
services.lnd.dataDir = "/build/lnd";
services.bitcoind.dataDir = "/build/bitcoind";
}
];
}).config;
nbxplorerPreStart = config.systemd.services.nbxplorer.preStart;
bitcoindPreStart = config.systemd.services.bitcoind.preStart;
btcpayExecStart = config.systemd.services.btcpayserver.serviceConfig.ExecStart;
btcpayWorkingDir = config.systemd.services.btcpayserver.serviceConfig.WorkingDirectory;
nbxplorerConfigPath = extractAfter "install -m 600" nbxplorerPreStart;
btcpayConfigPath = extractFlagValue "--conf" btcpayExecStart;
nbxplorerConfig = builtins.readFile nbxplorerConfigPath;
btcpayConfig = builtins.readFile btcpayConfigPath;
in
assert lib.assertMsg
(config.users.users.${config.services.btcpayserver.user}.home == config.services.btcpayserver.dataDir)
"btcpayserver user home must match btcpayserver dataDir";
assert lib.assertMsg
(config.users.users.${config.services.nbxplorer.user}.home == config.services.nbxplorer.dataDir)
"nbxplorer user home must match nbxplorer dataDir";
assert lib.assertMsg
(config.nix-bitcoin.secrets.bitcoin-HMAC-btcpayserver.user == config.services.bitcoind.user)
"bitcoin-HMAC-btcpayserver must be owned by bitcoind";
assert lib.assertMsg
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.user == config.services.bitcoind.user)
"bitcoin-rpcpassword-btcpayserver must be owned by bitcoind";
assert lib.assertMsg
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.group == config.services.nbxplorer.group)
"bitcoin-rpcpassword-btcpayserver must be group-readable by nbxplorer";
assert lib.assertMsg
(!(lib.elem config.services.nbxplorer.group config.users.users.${config.services.btcpayserver.user}.extraGroups))
"btcpayserver must not receive the nbxplorer group";
assert lib.assertMsg
(lib.elem "nix-bitcoin-secrets.target" config.systemd.services.nbxplorer.after)
"nbxplorer must wait for nix-bitcoin-secrets.target";
assert lib.assertMsg
(config.systemd.services.nbxplorer.serviceConfig.MemoryDenyWriteExecute == false)
"nbxplorer needs MemoryDenyWriteExecute = false";
assert lib.assertMsg
(config.systemd.services.btcpayserver.serviceConfig.MemoryDenyWriteExecute == false)
"btcpayserver needs MemoryDenyWriteExecute = false";
assert lib.assertMsg
(lib.hasInfix "network=mainnet" nbxplorerConfig
&& lib.hasInfix "btcrpcuser=btcpayserver" nbxplorerConfig
&& lib.hasInfix "btcnodeendpoint=127.0.0.1:8335" nbxplorerConfig
&& lib.hasInfix "bind=127.0.0.1" nbxplorerConfig
&& lib.hasInfix "port=24444" nbxplorerConfig
&& lib.hasInfix "postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" nbxplorerConfig)
"nbxplorer base config must contain the expected non-secret settings";
assert lib.assertMsg
(lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
&& lib.hasInfix "btcexplorercookiefile=/build/nbxplorer/Main/.cookie" btcpayConfig)
"btcpayserver config must contain btcexplorerurl and btcexplorercookiefile";
assert lib.assertMsg
(lib.hasSuffix "/lib/btcpayserver" btcpayWorkingDir)
"btcpayserver WorkingDirectory must end with /lib/btcpayserver";
assert lib.assertMsg
(!lib.hasInfix "/build/btcpayserver/settings.config" btcpayExecStart
&& lib.hasInfix "--datadir='/build/btcpayserver'" btcpayExecStart)
"btcpayserver must use a deterministic config file plus --datadir";
assert lib.assertMsg
(lib.hasInfix "network=mainnet" btcpayConfig
&& lib.hasInfix "bind=127.0.0.1" btcpayConfig
&& lib.hasInfix "port=23000" btcpayConfig
&& lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
&& lib.hasInfix "explorer.postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" btcpayConfig
&& lib.hasInfix "postgres=User ID=btcpayserver;Host=/run/postgresql;Database=btcpayserver" btcpayConfig
&& lib.hasInfix "btclightning=type=lnd-rest;server=https://127.0.0.1:8080/;macaroonfilepath=/run/lnd/btcpayserver.macaroon;certfilepath=/build/secrets/lnd-cert" btcpayConfig)
"btcpayserver config must preserve BTCPay, NBXplorer, database, and LND settings";
assert lib.assertMsg
(lib.hasInfix "readValidatedRpcHmac()" bitcoindPreStart
&& lib.hasInfix ''if [[ ! -e "$hmacFile" ]]; then'' bitcoindPreStart
&& lib.hasInfix ''if [[ ! -r "$hmacFile" ]]; then'' bitcoindPreStart
&& lib.hasInfix ''if [[ -z "$hmacPayload" ]]; then'' bitcoindPreStart
&& lib.hasInfix ''^[[:xdigit:]]+\$[[:xdigit:]]+$'' bitcoindPreStart
&& lib.hasInfix ''Bitcoin RPC HMAC file has invalid format'' bitcoindPreStart
&& lib.hasInfix ''hmacPayload="$(readValidatedRpcHmac '/build/secrets/bitcoin-HMAC-btcpayserver')" || exit 1'' bitcoindPreStart)
"bitcoind preStart must validate missing, unreadable, empty, and malformed HMAC files";
pkgs.runCommand "bitcoin-btcpay-hardening" {} ''
mkdir -p /build/secrets /build/nbxplorer
printf '%s' 'first-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
test "$(stat -c '%a' /build/nbxplorer/settings.config)" = "600"
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
printf '%s' 'rotated-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
! grep -q 'first-password' /build/nbxplorer/settings.config
grep -q 'rotated-password' /build/nbxplorer/settings.config
touch "$out"
''
+78
View File
@@ -0,0 +1,78 @@
"""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_bitcoind_loopback_listener_is_always_enabled(self):
ecosystem = _read("modules", "bitcoinecosystem.nix")
self.assertIn("listen = true;", ecosystem)
self.assertIn("peerbloomfilters=1", ecosystem)
def test_gossip_is_opt_in(self):
ecosystem = _read("modules", "bitcoinecosystem.nix")
self.assertIn(
"public = config.sovran_systemsOS.features.bitcoin-tor-gossip;",
ecosystem,
)
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()
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
"""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()
+91
View File
@@ -0,0 +1,91 @@
"""Regression tests for the Hub Zeus Connect QR.
The LND-only rewrite of modules/bitcoin/lndconnect.nix shipped a wrapper
that Zeus cannot use. These tests lock the contract the Hub QR depends on
without needing lnd / tor / qrencode at test time.
"""
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 TestLndconnectWrapper(unittest.TestCase):
"""The system `lndconnect` wrapper must emit a Zeus-scannable URI."""
@classmethod
def setUpClass(cls):
cls.src = _read("modules/bitcoin/lndconnect.nix")
def test_uses_official_lndconnect_flags(self):
self.assertIn("--adminmacaroonpath=", self.src)
self.assertIn("--configfile=/dev/null", self.src)
self.assertIn("--nocert", self.src)
self.assertIn("--tlscertpath=", self.src)
def test_does_not_pass_unknown_short_flags(self):
# The broken rewrite called `lndconnect --cert … --macaroon …`.
# Those flags do not exist; Zeus then never got a valid URI.
self.assertIsNone(re.search(r"--cert=", self.src))
self.assertIsNone(re.search(r"--macaroon=", self.src))
def test_uses_dedicated_lnd_rest_onion(self):
self.assertIn("lnd-rest", self.src)
self.assertIn('onionServices.lnd-rest', self.src)
# Must not collide with the LND P2P onion named `lnd`.
self.assertNotIn("onionServices.lnd =", self.src)
self.assertNotIn('onionService = "${operatorName}/lnd"', self.src)
def test_reads_onion_from_onion_addresses_dir(self):
self.assertIn("onionAddresses.dataDir", self.src)
self.assertNotIn("/var/lib/tor/onion/${onionService}/hostname", self.src)
def test_omits_tls_cert_over_tor(self):
# Onion host + embedded localhost cert = Zeus rejects the QR.
self.assertIn('then "--nocert"', self.src)
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()