38 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
46 changed files with 2830 additions and 1130 deletions
+185 -1
View File
@@ -7,6 +7,190 @@ 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
@@ -160,7 +344,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Integrated Sparrow Wallet, Bisq, and Bisq 2
- Comprehensive Sovran Hub for service management
- NixOS-based operating system with privacy and sovereignty focus
- Support for Bitcoin 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
- Automated installer with graphical GNOME desktop
- Tor integration and onion services for all major components
+22 -21
View File
@@ -21,12 +21,12 @@ Lightning infrastructure, private cloud, and communications platform when you
are ready.
[Visit the Website](https://sovransystems.com) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso) ·
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso.sha256) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso.sha256) ·
[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.*
@@ -73,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
company holds user funds, and no exchange account stands between buyers and
sellers.
- **Verify your own Bitcoin** with a full node: [Bitcoin
Knots](https://bitcoinknots.org) and
- **Verify your own Bitcoin** with a full node: [Bitcoin Core](https://bitcoin.org) and
[Electrs](https://github.com/romanz/electrs), so your wallets connect to
*your* node instead of a stranger's.
- **Use Lightning** with [LND](https://github.com/lightningnetwork/lnd) and
@@ -150,13 +149,13 @@ presents and manages the features available on your system.
### 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
Wallet Connect (NWC) connections
- BTCPay Server
- Sparrow Wallet, Bisq, and Bisq 2, with automatic wallet-to-node connections
- 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.
@@ -186,7 +185,7 @@ hardware you control.
### Your 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
- File management, email, calendar, and office applications
- System monitoring and administration tools
@@ -202,7 +201,7 @@ Bitcoin and self-hosting infrastructure runs on the machine.
| 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 |
| **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 |
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
@@ -282,6 +281,10 @@ From one place, the Hub helps you:
- Reach your Bitcoin tools, private cloud, and communications
- 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
```text
@@ -342,8 +345,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
### 1. Download the ISO and checksum
- [Download Sovran_SystemsOS-1.0.6.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso)
- [Download Sovran_SystemsOS-1.0.6.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso.sha256)
- [Download Sovran_SystemsOS-1.1.2.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso)
- [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
verifying it, and keep both files in the same folder.
@@ -361,16 +364,16 @@ checksum exactly.
Open a terminal in the download folder and run:
```bash
sha256sum --check Sovran_SystemsOS-1.0.6.iso.sha256
sha256sum --check Sovran_SystemsOS-1.1.2.iso.sha256
```
A successful comparison reports:
```text
Sovran_SystemsOS-1.0.6.iso: OK
Sovran_SystemsOS-1.1.2.iso: OK
```
You can also run `sha256sum Sovran_SystemsOS-1.0.6.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.
</details>
@@ -381,11 +384,11 @@ against the checksum file manually.
Open Terminal in the download folder and run:
```bash
shasum -a 256 Sovran_SystemsOS-1.0.6.iso
shasum -a 256 Sovran_SystemsOS-1.1.2.iso
```
Compare the value shown in Terminal with the value inside
`Sovran_SystemsOS-1.0.6.iso.sha256`.
`Sovran_SystemsOS-1.1.2.iso.sha256`.
</details>
@@ -395,7 +398,7 @@ Compare the value shown in Terminal with the value inside
Open PowerShell in the download folder and run:
```powershell
Get-FileHash .\Sovran_SystemsOS-1.0.6.iso -Algorithm SHA256
Get-FileHash .\Sovran_SystemsOS-1.1.2.iso -Algorithm SHA256
```
Compare the value under `Hash` with the published checksum.
@@ -410,7 +413,7 @@ match exactly.
1. Download and install [Balena Etcher](https://etcher.balena.io), then
connect the USB drive.
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.0.6.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
carefully.
4. Choose **Flash** and wait for the writing and verification process to
@@ -688,7 +691,6 @@ rebuilds the machine into the selected declarative state.
| Shared credentials | `modules/credentials.nix` |
| Bitcoin and Lightning stack | `modules/bitcoinecosystem.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/` |
| Matrix Synapse | `modules/synapse.nix` |
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
@@ -777,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:
- [Bitcoin Knots](https://github.com/bitcoinknots/bitcoin)
- [Bitcoin Core](https://github.com/bitcoin/bitcoin)
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
- [Bisq](https://github.com/bisq-network/bisq)
@@ -855,7 +856,7 @@ primary location for collaboration. Please read our
## Privacy. Sovereignty. Bitcoin.
[Visit Sovran Systems](https://sovransystems.com) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso) ·
[View the License](LICENSE)
</div>
+1 -1
View File
@@ -1 +1 @@
1.0.6
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,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
"/>
</svg>

Before

Width:  |  Height:  |  Size: 18 KiB

@@ -491,6 +491,8 @@ if [[ -d /home ]]; then
--exclude='.config/chromium/*/Code Cache/' \
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
--exclude='.config/BraveSoftware/Brave-Origin/*/Cache/' \
--exclude='.config/BraveSoftware/Brave-Origin/*/Code Cache/' \
--exclude='.local/share/baloo/' \
--exclude='.thumbnails/' \
--exclude='.xsession-errors' \
@@ -11,7 +11,11 @@ from __future__ import annotations
import base64
import ipaddress
import json
import os
import re
import tempfile
import time
import urllib.parse
# ── Nix string escaping ────────────────────────────────────────────────────────
@@ -239,3 +243,71 @@ def _validate_ssh_pubkey(key: str) -> str:
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
+278 -306
View File
@@ -21,6 +21,7 @@ import subprocess
import tempfile
import threading
import time
import sys
import urllib.error
import urllib.parse
import urllib.request
@@ -52,7 +53,10 @@ from .security_helpers import (
_SSH_PUBKEY_ALGORITHMS,
_bech32_decode,
_bech32_convertbits_decode,
load_session_store,
save_session_store,
)
from .update_state import effective_update_status
logger = logging.getLogger(__name__)
@@ -64,6 +68,7 @@ GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovr
UPDATE_LOG = "/var/log/sovran-hub-update.log"
UPDATE_STATUS = "/var/log/sovran-hub-update.status"
UPDATE_GENERATION = "/var/log/sovran-hub-update.generation"
UPDATE_UNIT = "sovran-hub-update.service"
REBUILD_LOG = "/var/log/sovran-hub-rebuild.log"
@@ -130,6 +135,7 @@ _SERVICE_DOMAIN_KEYS = frozenset([
])
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
EXTERNAL_IP_FILE = "/var/lib/secrets/external-ip"
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
@@ -142,10 +148,32 @@ FREE_PASSWORD_FILE_WEB = "/var/lib/secrets/free-password-web"
MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass"
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
SESSION_COOKIE_NAME = "hub_session"
MANUAL_LOGOUT_COOKIE_NAME = "hub_manual_logout"
SESSION_MAX_AGE = 86400 # 24 hours
# Chromium limits persistent cookies to roughly 400 days. This marker only
# suppresses desktop auto-login until the next successful password login.
MANUAL_LOGOUT_MAX_AGE = 400 * 86400
# In-memory session store: token → expiry timestamp (float)
# Sessions are persisted here so logins survive a restart of the Hub service.
# nixos-rebuild switch restarts sovran-hub-web.service during activation (its
# unit definition changes with every feature toggle — e.g. the Bitcoin
# PATH). Without persistence the browser session dies mid-rebuild, the
# /api/rebuild/status polling starts receiving 401s and the rebuild modal
# hangs forever showing "Applying changes…". The file lives in
# /var/lib/secrets so a security reset wipes it and forces a re-login, like
# the session secret itself.
SESSIONS_FILE = "/var/lib/secrets/hub-sessions.json"
# Session store: token → expiry timestamp (float). Loaded lazily from
# SESSIONS_FILE on first use and written back on every meaningful change.
_sessions: dict[str, float] = {}
_sessions_loaded = False
_sessions_lock = Lock()
# Sliding the expiry on every authenticated request would rewrite the store on
# every poll, so persist slide-only updates at most this often.
_SESSION_PERSIST_MIN_INTERVAL = 30.0 # seconds
_sessions_last_persist = 0.0
# Failed login tracking: ip → list of failure timestamps
_login_failures: dict[str, list[float]] = {}
@@ -298,15 +326,24 @@ FEATURE_REGISTRY = [
"port_requirements": [],
},
{
"id": "bitcoin-core",
"name": "Bitcoin Core",
"description": "Only one Bitcoin node implementation can be active: Bitcoin Knots + BIP110 (default) or Bitcoin Core. Enabling this replaces Knots + BIP110 with Bitcoin Core. Your timechain data is preserved.",
"id": "bitcoin-tor-gossip",
"name": "Advertise Tor IBD Node",
"description": "Advertise this Bitcoin Core node's onion address through Bitcoin peer gossip so more Tor-capable nodes can discover it and request blocks.",
"details": [
"Your Tor IBD listener remains available whether or not advertising is enabled.",
"Enabling this announces only the node's .onion P2P address; it does not publish your home IP address.",
"Other Tor nodes can discover your node and request historical blocks while performing Initial Block Download (IBD).",
"No clearnet port or router port forwarding is opened.",
"Serving additional IBD peers can use significant upload bandwidth.",
],
"category": "bitcoin",
"modal_only": True,
"needs_domain": False,
"domain_name": None,
"needs_ddns": False,
"extra_fields": [],
"conflicts_with": [],
"requires": ["bitcoin-service"],
"port_requirements": [],
},
{
@@ -342,7 +379,7 @@ FEATURE_REGISTRY = [
# Feature ids that have been removed/deprecated. The Hub must never write these
# back into custom.nix, and should strip any it finds (see startup migration).
DEPRECATED_FEATURE_IDS: set[str] = {"bip110"}
DEPRECATED_FEATURE_IDS: set[str] = {"bitcoin-core"}
# Map feature IDs to their systemd units in config.json
FEATURE_SERVICE_MAP = {
@@ -350,7 +387,7 @@ FEATURE_SERVICE_MAP = {
"haven": "haven-relay.service",
"element-calling": "livekit.service",
"mempool": "mempool.service",
"bitcoin-core": None,
"bitcoin-tor-gossip": None,
"btcpay-web": "btcpayserver.service",
"nwc-wallets": "albyhub.service",
"sshd": "sshd.service",
@@ -405,9 +442,7 @@ SERVICE_DOMAIN_MAP: dict[str, str] = {
}
# For features that share a unit, disambiguate by icon field
FEATURE_ICON_MAP = {
"bitcoin-core": "bitcoin-core",
}
FEATURE_ICON_MAP: dict[str, str] = {}
ROLE_LABELS = {
"server_plus_desktop": "Server + Desktop",
@@ -426,7 +461,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = {
ROLE_FEATURES: dict[str, set[str] | None] = {
"server_plus_desktop": None,
"desktop": {"rdp", "sshd"},
"node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "nwc-wallets", "sshd"},
"node": {"rdp", "bitcoin-tor-gossip", "mempool", "btcpay-web", "nwc-wallets", "sshd"},
}
SERVICE_DESCRIPTIONS: dict[str, str] = {
@@ -456,9 +491,9 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
"Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch."
),
"zeus-connect-setup.service": (
"Connect the Zeus mobile wallet to your Lightning node via LND REST. Send and receive "
"Connect the Zeus mobile wallet to your Lightning node via LND REST over Tor. Send and receive "
"Lightning payments from your phone using a direct node connection. "
"Scan the QR code to add your node to Zeus — this gives full node admin access."
"Scan the QR code to add your node to Zeus, then enable Use Tor — this gives full node admin access."
),
"mempool.service": (
"Your own blockchain explorer and mempool visualizer. Monitor transactions, "
@@ -606,38 +641,74 @@ def _get_or_create_session_secret() -> bytes:
return token_hex
def _load_sessions_once() -> None:
"""Lazily load the persisted session store on first use (idempotent)."""
global _sessions_loaded
with _sessions_lock:
if _sessions_loaded:
return
_sessions.update(load_session_store(SESSIONS_FILE))
_sessions_loaded = True
def _persist_sessions(force: bool = False) -> None:
"""Write the session store to SESSIONS_FILE (best-effort).
Expiry slides happen on every authenticated request, so non-forced
persists are throttled; create/destroy/purge pass ``force=True``.
"""
global _sessions_last_persist
now = time.time()
with _sessions_lock:
if not force and (now - _sessions_last_persist) < _SESSION_PERSIST_MIN_INTERVAL:
return
snapshot = dict(_sessions)
_sessions_last_persist = now
save_session_store(SESSIONS_FILE, snapshot)
def _create_session() -> str:
"""Create a new opaque session token and register it in the store."""
_load_sessions_once()
_purge_expired_sessions()
token = secrets.token_hex(32)
_sessions[token] = time.time() + SESSION_MAX_AGE
_persist_sessions(force=True)
return token
def _destroy_session(token: str) -> None:
"""Remove a session token from the store."""
_sessions.pop(token, None)
_load_sessions_once()
if _sessions.pop(token, None) is not None:
_persist_sessions(force=True)
def _purge_expired_sessions() -> None:
"""Remove all expired sessions from the in-memory store."""
"""Remove all expired sessions from the store."""
_load_sessions_once()
now = time.time()
expired = [tok for tok, exp in _sessions.items() if exp <= now]
for tok in expired:
del _sessions[tok]
if expired:
_persist_sessions(force=True)
def _is_authenticated(request: Request) -> bool:
"""Return True if the request carries a valid, unexpired session cookie."""
_load_sessions_once()
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
return False
expiry = _sessions.get(token)
if expiry is None or time.time() >= expiry:
_sessions.pop(token, None)
if _sessions.pop(token, None) is not None:
_persist_sessions(force=True)
return False
# Slide the expiry window on activity
_sessions[token] = time.time() + SESSION_MAX_AGE
_persist_sessions() # throttled — don't rewrite the store on every poll
return True
@@ -883,21 +954,43 @@ def _save_internal_ip(ip: str):
pass
def _get_external_ip() -> str:
MAX_IP_LENGTH = 46
for url in [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
]:
def _save_external_ip(ip: str):
"""Write the external IP to a file so other services (e.g. LiveKit) can
reference it without running their own detection."""
if ip and ip != "unavailable":
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=8) as resp:
ip = resp.read().decode().strip()
if ip and len(ip) < MAX_IP_LENGTH:
return ip
os.makedirs(os.path.dirname(EXTERNAL_IP_FILE), exist_ok=True)
with open(EXTERNAL_IP_FILE, "w") as f:
f.write(ip)
except OSError:
pass
def _get_external_ip() -> str:
"""Public IP via the shared detector (/var/lib/sovran/public-ip.py).
The detector owns discovery (STUN -> DNS -> opt-in HTTPS echo), caches the
result in /var/lib/secrets/external-ip, and contacts at most one third
party per refresh interval. This function only reads the cache and asks
the detector to refresh when it is missing or stale — it performs no
per-call external queries of its own.
"""
try:
r = subprocess.run(
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
capture_output=True, text=True, timeout=20,
)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip().splitlines()[0]
except Exception:
continue
pass
try:
with open(EXTERNAL_IP_FILE) as f:
ip = f.read().strip()
if ip:
return ip
except OSError:
pass
return "unavailable"
@@ -1465,10 +1558,16 @@ def _evaluate_domain_checklist(
def _generate_qr_png_bytes(data: str, scale: int = 6, margin: int = 2) -> bytes | None:
"""Generate a QR code PNG and return the raw bytes.
Uses qrencode CLI (available on the system via credentials.nix)."""
Uses qrencode CLI (available on the system via credentials.nix).
High error-correction (H) is preferred for short payloads. Long
lndconnect URIs can exceed version-40 capacity at H, so fall back to
quartile then low ECC — otherwise the Hub shows an empty Zeus QR.
"""
for ecc in ("H", "Q", "L"):
try:
result = subprocess.run(
["qrencode", "-o", "-", "-t", "PNG", "-s", str(scale), "-m", str(margin), "-l", "H", data],
["qrencode", "-o", "-", "-t", "PNG", "-s", str(scale), "-m", str(margin), "-l", ecc, data],
capture_output=True, timeout=10,
)
if result.returncode == 0 and result.stdout:
@@ -1481,9 +1580,10 @@ def _generate_qr_png_bytes(data: str, scale: int = 6, margin: int = 2) -> bytes
def _generate_qr_svg(data: str, scale: int = 10, margin: int = 4) -> str | None:
"""Generate a QR code SVG document (resolution-independent, ideal if the
user wants to embed the QR in a website or print it at any size)."""
for ecc in ("H", "Q", "L"):
try:
result = subprocess.run(
["qrencode", "-o", "-", "-t", "SVG", "-s", str(scale), "-m", str(margin), "-l", "H", data],
["qrencode", "-o", "-", "-t", "SVG", "-s", str(scale), "-m", str(margin), "-l", ecc, data],
capture_output=True, timeout=10,
)
if result.returncode == 0 and result.stdout:
@@ -1560,15 +1660,6 @@ def _nwc_lnurl_bech32(alias: str, domain: str) -> str:
# ── Update helpers (file-based, no systemctl) ────────────────────
def _read_update_status() -> str:
"""Read the status file. Returns RUNNING, SUCCESS, REBOOT_REQUIRED, FAILED, or IDLE."""
try:
with open(UPDATE_STATUS, "r") as f:
return f.read().strip()
except FileNotFoundError:
return "IDLE"
def _write_update_status(status: str):
"""Write to the status file."""
try:
@@ -1578,6 +1669,36 @@ def _write_update_status(status: str):
pass
def _read_update_status() -> str:
"""Read and reconcile the persistent update status.
``REBOOT_REQUIRED`` survives Hub/browser restarts before the reboot, but
it is a CLAIM about live NixOS state, not the source of truth: the boot
default (``/nix/var/nix/profiles/system``) versus the running
``/run/current-system``. Re-validating on every read keeps the Hub
correct when the system was updated from a terminal or support session
(which never writes Hub markers), and lets an old marker that predates
the reconciliation feature self-heal instead of demanding reboots
forever. The stale marker file is removed once cleared.
"""
try:
with open(UPDATE_STATUS, "r") as f:
status = f.read().strip()
except FileNotFoundError:
return "IDLE"
effective = effective_update_status(status)
if effective != status:
_write_update_status(effective)
try:
os.remove(UPDATE_GENERATION)
except OSError:
pass
return effective
return status
def _read_log(offset: int = 0) -> tuple[str, int]:
"""Read the update log file from the given byte offset.
Returns (new_text, new_offset)."""
@@ -1613,6 +1734,9 @@ def _resolve_credential(cred: dict) -> dict | None:
qr_data = _generate_qr_base64(result["value"])
if qr_data:
result["qrcode"] = qr_data
else:
# Don't hide the URI if we could not render a scannable QR.
qronly = False
if qronly:
result["qronly"] = True
return result
@@ -1647,6 +1771,9 @@ def _resolve_credential(cred: dict) -> dict | None:
qr_data = _generate_qr_base64(value)
if qr_data:
result["qrcode"] = qr_data
else:
# Don't hide the URI if we could not render a scannable QR.
qronly = False
if qronly:
result["qronly"] = True
@@ -2017,14 +2144,24 @@ def _migrate_strip_deprecated_features() -> None:
# ── Feature status helpers ─────────────────────────────────────────
def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
"""Check if a feature's service appears as enabled in the running config.json.
Returns True/False if found, None if the feature has no mapped service."""
"""Check whether a feature is enabled in the evaluated Hub configuration.
Most features map directly to a systemd service. Modal-only settings are
represented separately in ``config.json``. Returns ``None`` only when no
evaluated state is available.
"""
if feature_id == "btcpay-web":
return False # Default off in Node role; only on via explicit hub toggle
cfg = load_config()
if feature_id == "bitcoin-tor-gossip":
state = cfg.get("feature_states", {}).get(feature_id)
return bool(state) if state is not None else None
unit = FEATURE_SERVICE_MAP.get(feature_id)
if unit is None:
return None # bitcoin-core — can't determine from config
cfg = load_config()
return None
for svc in cfg.get("services", []):
if svc.get("unit") == unit:
return svc.get("enabled", False)
@@ -2444,6 +2581,10 @@ async def auto_login_redirect(request: Request):
client_ip = request.client.host if request.client else "unknown"
if client_ip not in ("127.0.0.1", "::1"):
raise HTTPException(status_code=403, detail="Forbidden")
# An explicit logout must take precedence over the desktop launcher's
# localhost auto-login, including after the Hub window is closed/reopened.
if request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME) == "1":
return RedirectResponse(url="/login", status_code=303)
token = _create_session()
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
@@ -2480,17 +2621,27 @@ async def api_login(req: LoginRequest, request: Request):
samesite="lax",
secure=False, # LAN-only appliance; no TLS on the Hub port
)
# A successful password login explicitly reverses a prior manual logout.
response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)
return response
@app.post("/api/logout")
async def api_logout(request: Request):
"""Clear the session cookie and destroy the server-side session."""
"""Destroy the session and prevent desktop auto-login until password login."""
token = request.cookies.get(SESSION_COOKIE_NAME)
if token:
_destroy_session(token)
response = JSONResponse({"ok": True})
response.delete_cookie(key=SESSION_COOKIE_NAME)
response.set_cookie(
key=MANUAL_LOGOUT_COOKIE_NAME,
value="1",
max_age=MANUAL_LOGOUT_MAX_AGE,
httponly=True,
samesite="lax",
secure=False, # LAN-only appliance; no TLS on the Hub port
)
return response
@@ -2766,21 +2917,11 @@ BITCOIN_DATADIR = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"
_btc_sync_cache: tuple[float, dict | None] = (0.0, None)
_BTC_SYNC_CACHE_TTL = 5 # seconds
_btc_version_cache: tuple[float, dict | None] = (0.0, None)
_BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime
# Cache for ``bitcoind --version`` output (available even before RPC is ready)
_btcd_version_cache: tuple[float, str | None] = (0.0, None)
# Cache for ``bitcoin-cli getdeploymentinfo`` output (BIP-110 live status)
_btc_deployment_cache: tuple[float, dict | None] = (0.0, None)
# Bitcoin Knots exposes BIP-110 as the `reduced_data` versionbits deployment
# (RDTS, bit 4) in getdeploymentinfo. See Knots src/deploymentinfo.cpp,
# src/kernel/chainparams.cpp, and doc/bips.md.
BIP110_DEPLOYMENT_NAMES = {"reduced_data", "rdts", "bip110", "uasf-bip110"}
BIP110_VERSIONBITS_BIT = 4
BIP110_SUBVERSION_MARKERS = {"bip110", "uasf-bip110", "reduced_data", "rdts"}
# ── Generic service version detection (NixOS store path) ─────────
@@ -2906,213 +3047,12 @@ def _get_service_version(unit: str) -> str | None:
return version
def _parse_bitcoin_subversion(subversion: str) -> str:
"""Parse a subversion string like '/Bitcoin Knots:27.1.0/' into 'v27.1.0'.
Examples:
'/Bitcoin Knots:27.1.0/''v27.1.0'
'/Satoshi:27.0.0/''v27.0.0'
'/Bitcoin Knots:27.1.0(bip110)/''v27.1.0 (bip110)'
Falls back to the raw subversion string if parsing fails.
"""
m = re.search(r":(\d+\.\d+(?:\.\d+)*)", subversion)
if m:
ver = "v" + m.group(1)
if "(bip110)" in subversion.lower():
ver += " (bip110)"
return ver
return subversion
def _get_bitcoin_version_info() -> dict | None:
"""Call bitcoin-cli getnetworkinfo and return parsed JSON, or None on error.
Results are cached for _BTC_VERSION_CACHE_TTL seconds since the version
does not change while the service is running.
"""
global _btc_version_cache
now = time.monotonic()
cached_at, cached_val = _btc_version_cache
if now - cached_at < _BTC_VERSION_CACHE_TTL:
return cached_val
try:
result = subprocess.run(
["bitcoin-cli", f"-datadir={BITCOIN_DATADIR}", "getnetworkinfo"],
capture_output=True,
text=True,
# This is a dashboard hint; do not make an unavailable RPC delay
# the entire service tile response.
timeout=3,
)
if result.returncode != 0:
_btc_version_cache = (now, None)
return None
info = json.loads(result.stdout)
_btc_version_cache = (now, info)
return info
except Exception:
_btc_version_cache = (now, None)
return None
def _get_bitcoin_deployment_info() -> dict | None:
"""Call bitcoin-cli getdeploymentinfo and return parsed JSON, or None on error.
Results are cached for _BTC_VERSION_CACHE_TTL seconds. Never raises.
"""
global _btc_deployment_cache
now = time.monotonic()
cached_at, cached_val = _btc_deployment_cache
if now - cached_at < _BTC_VERSION_CACHE_TTL:
return cached_val
try:
result = subprocess.run(
["bitcoin-cli", f"-datadir={BITCOIN_DATADIR}", "getdeploymentinfo"],
capture_output=True,
text=True,
# BIP-110 is supplementary tile metadata; an RPC timeout must not
# block the dashboard from rendering.
timeout=3,
)
if result.returncode != 0:
_btc_deployment_cache = (now, None)
return None
info = json.loads(result.stdout)
_btc_deployment_cache = (now, info)
return info
except Exception:
_btc_deployment_cache = (now, None)
return None
def _get_bip110_status() -> dict:
"""Return a dict describing the live BIP-110 deployment/signaling state.
The returned struct has four stable keys::
{
"supported": bool, # node build is BIP-110-capable
"signaling": bool, # node is actively signaling / locked-in / active
"state": str, # "active" | "locked_in" | "signaling" |
# "not_signaling" | "unsupported" | "unknown"
"source": str, # "getdeploymentinfo" | "subversion" | "none"
}
Resolution order (authoritative → fallback → honest unknown):
1. ``getdeploymentinfo`` (authoritative) — scan ``deployments`` for BIP-110.
Bitcoin Knots currently exposes BIP-110 as ``reduced_data`` (RDTS, bit 4;
see Knots deploymentinfo.cpp / chainparams.cpp / doc/bips.md), so matching
first uses known deployment names, then falls back to versionbits bit 4.
2. Subversion fallback — if getdeploymentinfo is unavailable or yields no
recognisable BIP-110 entry, inspect the ``subversion`` field from
``getnetworkinfo``. A case-insensitive match for known BIP-110 markers
(including "bip110", "uasf-bip110", "reduced_data", "rdts") is treated as
"signaling".
3. Unknown — if the node is entirely unreachable or neither source is
conclusive, return state="unknown", signaling=False, source="none".
"""
_unknown: dict = {"supported": False, "signaling": False, "state": "unknown", "source": "none"}
def _deployment_bit(entry: dict) -> int | None:
bip9 = entry.get("bip9", {}) or {}
bip8 = entry.get("bip8", {}) or {}
bit = bip9.get("bit")
if bit is None:
bit = bip8.get("bit")
if bit is None:
bit = entry.get("bit")
return bit
# ── 1. getdeploymentinfo (authoritative) ──────────────────────────
deploy_info = _get_bitcoin_deployment_info()
if deploy_info is not None:
deployments = deploy_info.get("deployments", {})
if isinstance(deployments, dict):
matched_entry: dict | None = None
# Primary match: known deployment names (case-insensitive exact match)
for key, entry in deployments.items():
if not isinstance(entry, dict):
continue
key_lower = key.lower()
if key_lower not in BIP110_DEPLOYMENT_NAMES:
continue
matched_entry = entry
break
# Secondary match: versionbits bit (fallback only)
if matched_entry is None:
for _, entry in deployments.items():
if not isinstance(entry, dict):
continue
if _deployment_bit(entry) != BIP110_VERSIONBITS_BIT:
continue
matched_entry = entry
break
if matched_entry is not None:
entry = matched_entry
# bip9 / bip8 status field
bip9 = entry.get("bip9", {}) or {}
bip8 = entry.get("bip8", {}) or {}
status = (
bip9.get("status")
or bip8.get("status")
or entry.get("status")
or ""
).lower()
active = entry.get("active", False)
if active or status == "active":
return {"supported": True, "signaling": True, "state": "active", "source": "getdeploymentinfo"}
if status == "locked_in":
return {"supported": True, "signaling": True, "state": "locked_in", "source": "getdeploymentinfo"}
if status in ("started", "defined"):
# Check whether deployment is currently signaling in this period.
stats = bip9.get("statistics") or bip8.get("statistics") or {}
# Some Knots outputs expose only ``count`` (not explicit signaling bool),
# so treat count>0 as a conservative signaling indicator for this period.
count = stats.get("count")
signaling = bool(
stats.get("signaling")
or stats.get("signalling")
or (isinstance(count, int) and count > 0)
)
if signaling:
return {"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"}
return {"supported": True, "signaling": False, "state": "not_signaling", "source": "getdeploymentinfo"}
if status == "failed":
return {"supported": True, "signaling": False, "state": "not_signaling", "source": "getdeploymentinfo"}
# Entry found but status unrecognised — node supports BIP-110 but state unclear
return {"supported": True, "signaling": False, "state": "unknown", "source": "getdeploymentinfo"}
# ── 2. Subversion fallback ─────────────────────────────────────────
net_info = _get_bitcoin_version_info()
if net_info is not None:
subversion = net_info.get("subversion", "") or ""
sv_lower = subversion.lower()
if any(marker in sv_lower for marker in BIP110_SUBVERSION_MARKERS):
return {"supported": True, "signaling": True, "state": "signaling", "source": "subversion"}
# Node is reachable via RPC but no BIP-110 marker found anywhere
return {"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"}
# ── 3. Node unreachable / RPC not ready ───────────────────────────
return _unknown
def _get_bitcoind_version() -> str | None:
"""Run ``bitcoind --version`` and return the raw version string, or None on error.
Parses the first output line to extract the token after "version ".
For example: "Bitcoin Knots daemon version v29.3.knots20260508"
returns "v29.3.knots20260508".
Works regardless of whether the RPC server is ready (IBD, warmup, etc.).
Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL).
"""
@@ -3143,17 +3083,9 @@ def _get_bitcoind_version() -> str | None:
return None
def _format_bitcoin_version(raw_version: str, icon: str = "") -> str:
"""Format a raw version string from ``bitcoind --version`` for tile display.
For the BIP110 tile (icon == "bip110") a " (bip110)" tag is appended,
since mainline Bitcoin Knots (29.3.knots20260508+) now includes BIP-110
and no longer carries a separate ``+bip110-vX.Y.Z`` suffix.
"""
display = raw_version
if icon == "bip110" and "(bip110)" not in display.lower():
display += " (bip110)"
return display
def _format_bitcoin_version(raw_version: str) -> str:
"""Format a raw version string from ``bitcoind --version`` for display."""
return raw_version
def _get_bitcoin_sync_info() -> dict | None:
@@ -3222,19 +3154,6 @@ async def api_bitcoin_version():
}
@app.get("/api/bitcoin/bip110")
async def api_bitcoin_bip110():
"""Return live BIP-110 deployment/signaling status from bitcoin-cli.
Always returns HTTP 200. When bitcoind is unreachable or the node is mid-IBD
the response will contain ``state = "unknown"`` so the UI can render a neutral
badge rather than an error toast.
"""
loop = asyncio.get_event_loop()
status = await loop.run_in_executor(None, _get_bip110_status)
return status
@app.get("/api/services")
async def api_services():
started_at = time.monotonic()
@@ -3314,39 +3233,27 @@ async def api_services():
dns_states_future = asyncio.gather(
*(resolve_domain(domain) for domain in sorted(domain_names))
)
# Bitcoin sync and BIP-110 are supplementary tile metadata. Fetch each
# once, concurrently with the other diagnostics, instead of doing duplicate
# RPC calls inside both bitcoind tile coroutines.
# Bitcoin sync is supplementary tile metadata. Fetch it once alongside
# the other diagnostics instead of duplicating RPC calls per tile.
has_enabled_bitcoin = any(
entry.get("unit") == "bitcoind.service" and enabled
for entry, enabled in effective_entries
)
has_enabled_bip110 = any(
entry.get("icon") == "bip110" and enabled
for entry, enabled in effective_entries
)
bitcoin_sync_future = (
loop.run_in_executor(None, _get_bitcoin_sync_info)
if has_enabled_bitcoin
else asyncio.sleep(0, result=None)
)
bip110_future = (
loop.run_in_executor(None, _get_bip110_status)
if has_enabled_bip110
else asyncio.sleep(0, result=None)
)
(
active_states,
port_states,
dns_states,
bitcoin_sync_info,
bip110_status,
) = await asyncio.gather(
active_states_future,
port_states_future,
dns_states_future,
bitcoin_sync_future,
bip110_future,
)
listening_ports, firewall_ports = port_states
resolved_domains = dict(dns_states)
@@ -3502,11 +3409,9 @@ async def api_services():
if unit == "bitcoind.service" and enabled:
raw_ver = await loop.run_in_executor(None, _get_bitcoind_version)
if raw_ver is not None:
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
btc_ver = _format_bitcoin_version(raw_ver)
service_data["bitcoin_version"] = btc_ver # backwards compat
service_data["version"] = btc_ver
if icon == "bip110" and bip110_status is not None:
service_data["bip110"] = bip110_status
# ── Generic version for all services (Nix store path) ──────────
if enabled and unit and "version" not in service_data:
ver = await loop.run_in_executor(None, _get_service_version, unit)
@@ -3751,6 +3656,37 @@ async def api_service_detail(unit: str, icon: str | None = None):
"port_requirements": feat_meta.get("port_requirements", []),
}
# Modal-only settings related to this service. These are intentionally not
# rendered as standalone feature cards: the user encounters them in the
# context where their consequences are easiest to understand.
related_features: list[dict] = []
if icon == "bitcoin-core":
related_id = "bitcoin-tor-gossip"
related_meta = next((f for f in FEATURE_REGISTRY if f["id"] == related_id), None)
if related_meta is not None:
if related_id in overrides:
related_enabled = bool(overrides[related_id])
else:
config_state = _is_feature_enabled_in_config(related_id)
related_enabled = bool(config_state) if config_state is not None else False
related_features.append({
"id": related_id,
"name": related_meta["name"],
"description": related_meta["description"],
"details": related_meta.get("details", []),
"category": related_meta["category"],
"enabled": related_enabled,
"available": bool(enabled),
"needs_domain": False,
"domain_configured": True,
"domain_name": None,
"needs_ddns": False,
"extra_fields": [],
"conflicts_with": related_meta.get("conflicts_with", []),
"requires": related_meta.get("requires", []),
"port_requirements": [],
})
service_detail: dict = {
"name": entry.get("name", ""),
"unit": unit,
@@ -3773,6 +3709,7 @@ async def api_service_detail(unit: str, icon: str | None = None):
"external_ip": external_ip,
"internal_ip": internal_ip,
"feature": feature_entry,
"related_features": related_features,
}
if sync_ibd is not None:
service_detail["sync_ibd"] = sync_ibd
@@ -3783,11 +3720,9 @@ async def api_service_detail(unit: str, icon: str | None = None):
loop = asyncio.get_event_loop()
raw_ver = await loop.run_in_executor(None, _get_bitcoind_version)
if raw_ver is not None:
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
btc_ver = _format_bitcoin_version(raw_ver)
service_detail["bitcoin_version"] = btc_ver # backwards compat
service_detail["version"] = btc_ver
if icon == "bip110":
service_detail["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
# ── Generic version for all services (Nix store path) ──────────
if enabled and unit and "version" not in service_detail:
ver = await loop.run_in_executor(None, _get_service_version, unit)
@@ -3807,6 +3742,9 @@ async def api_network():
# Keep the internal-ip file in sync for credential lookups
_save_internal_ip(internal)
_cached_external_ip = external
# Persist the external IP so other services (e.g. LiveKit) can reuse the
# Hub's detection instead of running their own.
_save_external_ip(external)
return {"internal_ip": internal, "external_ip": external}
@@ -3944,9 +3882,15 @@ async def api_ports_health():
@app.get("/api/updates/check")
async def api_updates_check():
loop = asyncio.get_event_loop()
status = await loop.run_in_executor(None, _read_update_status)
if status in {"RUNNING", "REBOOT_REQUIRED"}:
# Avoid a slow remote update check when there is already an operation
# the dashboard needs to surface.
return {"available": True, "status": status.lower()}
available = await loop.run_in_executor(None, check_for_updates)
# None means inconclusive (check failed) — report as available so the UI doesn't block
return {"available": available is not False}
return {"available": available is not False, "status": status.lower()}
@app.get("/api/ping")
@@ -4397,8 +4341,10 @@ async def api_features():
role = load_config().get("role", "server_plus_desktop")
allowed_features = ROLE_FEATURES.get(role)
registry = FEATURE_REGISTRY if allowed_features is None else [
f for f in FEATURE_REGISTRY if f["id"] in allowed_features
registry = [
f for f in FEATURE_REGISTRY
if not f.get("modal_only")
and (allowed_features is None or f["id"] in allowed_features)
]
features = []
@@ -4472,6 +4418,22 @@ async def api_features_toggle(req: FeatureToggleRequest):
features, nostr_npub, cur_tz, cur_locale = await loop.run_in_executor(None, _read_hub_overrides)
if req.enabled:
# Onion-address advertising is only meaningful while the Bitcoin Core
# service is enabled. The control is shown in that service's modal, but
# enforce the dependency server-side as well.
if req.feature == "bitcoin-tor-gossip":
bitcoin_core_enabled = any(
svc.get("unit") == "bitcoind.service"
and svc.get("icon") == "bitcoin-core"
and bool(svc.get("enabled", False))
for svc in load_config().get("services", [])
)
if not bitcoin_core_enabled:
raise HTTPException(
status_code=400,
detail="Enable the Bitcoin service before advertising its Tor IBD service.",
)
# Element-calling requires matrix domain
if req.feature == "element-calling":
if not os.path.exists(os.path.join(DOMAINS_DIR, "matrix")):
@@ -6152,6 +6114,9 @@ async def _startup_session_secret():
"""Ensure the session secret exists on disk at startup."""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _get_or_create_session_secret)
# Preload persisted sessions so browser logins survive this restart (the
# Hub service is restarted by nixos-rebuild switch during every rebuild).
await loop.run_in_executor(None, _load_sessions_once)
# ── Startup: recover stale RUNNING status files ──────────────────
@@ -6265,18 +6230,26 @@ async def _startup_recover_stale_status():
@app.on_event("startup")
async def _startup_migrate_deprecated_features():
"""Strip deprecated feature lines (e.g. bip110) from the Hub Managed section
of custom.nix so they are never re-written and do not cause stale warnings."""
"""Strip deprecated feature lines from the Hub Managed section of custom.nix."""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _migrate_strip_deprecated_features)
async def _background_domain_reachability_checker():
"""Periodically curl configured domains and cache reachability results."""
global _cached_external_ip
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
consecutive_failures = 0
while True:
try:
# Keep the persisted external IP fresh (dynamic WAN IPs), so
# services like LiveKit can read /var/lib/secrets/external-ip.
loop = asyncio.get_event_loop()
external = await loop.run_in_executor(None, _get_external_ip)
if external != "unavailable":
_cached_external_ip = external
_save_external_ip(external)
cfg = load_config()
services = cfg.get("services", [])
@@ -6286,7 +6259,6 @@ async def _background_domain_reachability_checker():
if unit is not None
}
loop = asyncio.get_event_loop()
overrides, *_ = await loop.run_in_executor(None, _read_hub_overrides)
domains_to_check: list[str] = []
+39 -62
View File
@@ -178,68 +178,6 @@
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 ───────────────────────────────── */
@@ -427,6 +365,45 @@
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 {
margin-top: 8px;
margin-bottom: 8px;
@@ -5,6 +5,16 @@
const POLL_INTERVAL_SERVICES = 5000;
const POLL_INTERVAL_UPDATES = 1800000;
const UPDATE_POLL_INTERVAL = 2000;
// A pending fetch never rejects by itself. Bound every status request so a
// wedged browser connection cannot leave the modal spinning forever.
const STATUS_POLL_FETCH_TIMEOUT = 15000;
// Eight timed-out requests plus the poll interval is a little over two minutes.
// A brief Hub restart or a heavily loaded Nix build remains well inside this.
const STATUS_POLL_MAX_FAILURES = 8;
// Keep verbose Nix output from making textContent updates quadratic and
// freezing the Hub renderer (especially noticeable over RDP).
const UPDATE_VISIBLE_LOG_MAX_CHARS = 250000;
const UPDATE_VISIBLE_LOG_TRIM_CHARS = 200000;
const REBOOT_CHECK_INTERVAL = 5000;
const REBOOT_FETCH_TIMEOUT = 12000;
const REBOOT_REQUEST_TIMEOUT = 4000;
@@ -6,6 +6,16 @@
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
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 ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
@@ -248,6 +258,11 @@ async function init() {
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
loadAutolaunchToggle();
}
// If the page was reloaded or the RDP/browser session resumed during an
// update, reopen the modal from the persisted backend state. This also
// surfaces a completed update that is waiting for its activation reboot.
await restoreUpdateModalIfNeeded();
}
document.addEventListener("DOMContentLoaded", init);
@@ -342,8 +342,11 @@ async function performFeatureToggle(featId, enabled, extra) {
function handleFeatureToggle(feat, newEnabled) {
if (!newEnabled) {
// Disable: ask confirmation
var disableMessage = (feat.id === "bitcoin-tor-gossip")
? "This will stop advertising your Bitcoin Core onion address to other peers. Nodes that already know the address can still connect through Tor, and no clearnet port will be opened. The system will rebuild. Continue?"
: "This will disable " + feat.name + ". The system will rebuild. Continue?";
openFeatureConfirm(
"This will disable " + feat.name + ". The system will rebuild. Continue?",
disableMessage,
function() { performFeatureToggle(feat.id, false, {}); }
);
return;
@@ -403,9 +406,9 @@ function handleFeatureToggle(feat, newEnabled) {
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
}
if (feat.id === "bitcoin-core") {
var confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will replace Bitcoin Knots + BIP110 as the active node. Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
if (feat.id === "bitcoin-tor-gossip") {
var torGossipConfirmMsg = "This will advertise your Bitcoin Core .onion P2P address through Bitcoin peer gossip. More Tor-capable nodes may discover your node and request historical blocks during IBD, which can use significant upload bandwidth. Your home IP remains hidden and no clearnet port or router forwarding is opened. Continue?";
openFeatureConfirm(torGossipConfirmMsg, proceedAfterConflictCheck);
} else if (conflictNames.length > 0) {
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
} else {
+29 -13
View File
@@ -111,8 +111,19 @@ function formatDuration(seconds) {
// ── Fetch wrappers ────────────────────────────────────────────────
// Avoid issuing multiple redirects when several startup requests discover an
// expired session at the same time.
let _authRedirectInProgress = false;
async function apiFetch(path, options) {
const res = await fetch(path, options || {});
if (res.status === 401) {
if (!_authRedirectInProgress) {
_authRedirectInProgress = true;
window.location.replace("/login");
}
throw new Error("Unauthenticated");
}
if (!res.ok) {
let detail = res.status + " " + res.statusText;
try {
@@ -134,16 +145,21 @@ async function apiFetch(path, options) {
return res.json();
}
// ── BIP-110 badge state config ────────────────────────────────────
// Shared lookup used by tiles.js and service-detail.js.
// Keys match the "state" values returned by /api/bitcoin/bip110.
var BIP110_BADGE_CONFIG = {
active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' },
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' },
signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' },
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' },
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' },
unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' }
};
async function apiFetchWithTimeout(path, options, timeoutMs) {
var controller = new AbortController();
var fetchOptions = Object.assign({}, options || {});
fetchOptions.signal = controller.signal;
var timer = setTimeout(function() { controller.abort(); }, timeoutMs);
try {
return await apiFetch(path, fetchOptions);
} catch (err) {
if (controller.signal.aborted) {
var timeoutError = new Error("Request timed out");
timeoutError.name = "TimeoutError";
throw timeoutError;
}
throw err;
} finally {
clearTimeout(timer);
}
}
+23 -2
View File
@@ -8,6 +8,8 @@ function openRebuildModal() {
_rebuildLogOffset = 0;
_rebuildServerDown = false;
_rebuildFinished = false;
_rebuildPollInFlight = false;
_rebuildPollFailures = 0;
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
var label = _rebuildFeatureName || "feature";
@@ -33,6 +35,7 @@ function appendRebuildLog(text) {
}
function startRebuildPoll() {
if (_rebuildPollTimer) clearInterval(_rebuildPollTimer);
pollRebuildStatus();
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
}
@@ -42,9 +45,15 @@ function stopRebuildPoll() {
}
async function pollRebuildStatus() {
if (_rebuildFinished) return;
if (_rebuildFinished || _rebuildPollInFlight) return;
_rebuildPollInFlight = true;
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 (data.log) appendRebuildLog(data.log);
_rebuildLogOffset = data.offset;
@@ -57,7 +66,19 @@ async function pollRebuildStatus() {
onRebuildDone(data.result === "success");
}
} catch (err) {
_rebuildPollFailures += 1;
// The Hub restarts itself during activation, which briefly drops this poll.
// If polling stays broken long past a normal restart, reload to
// re-authenticate and show the resulting feature state.
if (_rebuildPollFailures >= STATUS_POLL_MAX_FAILURES) {
_rebuildFinished = true;
stopRebuildPoll();
window.location.reload();
return;
}
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
} finally {
_rebuildPollInFlight = false;
}
}
@@ -11,9 +11,9 @@ function _getZeusConnectGuideHtml() {
'<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. Review them, then tap <strong>Save Wallet Config</strong>.</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.</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>';
}
@@ -704,20 +704,6 @@ async function openServiceDetailModal(unit, name, icon) {
'</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)
if (data.needs_domain) {
@@ -901,9 +887,7 @@ async function openServiceDetailModal(unit, name, icon) {
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
var addonSectionTitle = (feat.id === "bitcoin-core")
? "\u20BF Bitcoin Node Selection"
: "\uD83D\uDD27 Addon Feature";
var addonSectionTitle = "\uD83D\uDD27 Addon Feature";
// Description: prefer the feature's own description over a generic fallback
var addonDesc = feat.description
@@ -934,6 +918,54 @@ async function openServiceDetailModal(unit, name, icon) {
'</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") {
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
'<div class="svc-detail-section-title">Troubleshooting</div>' +
@@ -994,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 restartResult = document.getElementById("svc-detail-restart-result");
if (restartBtn && restartResult) {
@@ -6,9 +6,13 @@ let _servicesCache = [];
let _categoryLabels = {};
let _updateLog = "";
let _updatePollTimer = null;
let _updatePollInFlight = false;
let _updateLogOffset = 0;
let _updateVisibleLogChars = 0;
let _serverWasDown = false;
let _updateFinished = false;
let _updateStatusUnavailable = false;
let _updatePollFailures = 0; // consecutive failed update-status polls
let _supportTimerInt = null;
let _supportEnabledAt = null;
let _supportStatus = null; // last fetched /api/support/status payload
@@ -23,8 +27,10 @@ let _featuresData = null;
let _rebuildLog = "";
let _rebuildLogOffset = 0;
let _rebuildPollTimer = null;
let _rebuildPollInFlight = false;
let _rebuildFinished = false;
let _rebuildServerDown = false;
let _rebuildPollFailures = 0; // consecutive failed rebuild-status polls
let _pendingToggle = null; // {feature, extra} waiting for domain/confirm
let _rebuildFeatureName = "";
let _rebuildIsEnabling = true;
@@ -46,6 +52,7 @@ const $modalStatus = document.getElementById("modal-status");
const $modalLog = document.getElementById("modal-log");
const $btnReboot = document.getElementById("btn-reboot");
const $btnSave = document.getElementById("btn-save-report");
const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
const $btnCloseModal = document.getElementById("btn-close-modal");
const $rebootOverlay = document.getElementById("reboot-overlay");
+11 -28
View File
@@ -4,14 +4,6 @@
// Keyed by tileId: { progress: float, timestamp: ms }
var _btcSyncPrev = {};
// ── BIP-110 badge helper ──────────────────────────────────────────
function _renderBip110Badge(bip110) {
if (!bip110) return '';
var state = bip110.state || 'unknown';
var cfg = BIP110_BADGE_CONFIG[state] || BIP110_BADGE_CONFIG.unknown;
return '<div class="tile-bip110-badge ' + cfg.cls + '" title="' + escHtml(cfg.title) + '">' + escHtml(cfg.label) + '</div>';
}
function _firstElementFromHtml(html) {
var tmp = document.createElement("div");
@@ -175,8 +167,7 @@ function buildTile(svc) {
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>' + bip110Badge + '<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><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
tile.style.cursor = "pointer";
tile.addEventListener("click", function() {
@@ -244,23 +235,6 @@ function updateTiles(services) {
var text = tile.querySelector(".status-text");
if (dot) dot.className = "status-dot " + sc;
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 {
var data = await apiFetch("/api/updates/check");
var hasUpdates = !!data.available;
var updateStatus = data.status || "idle";
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
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.backgroundColor = "rgba(46, 194, 126, 0.08)";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
+169 -21
View File
@@ -2,20 +2,45 @@
// ── Update modal ──────────────────────────────────────────────────
function openUpdateModal() {
async function openUpdateModal() {
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) {
if (!data.available) {
stopUpdatePoll();
_updateLog = "";
_updateLogOffset = 0;
_updateVisibleLogChars = 0;
_updateFinished = true;
_updateStatusUnavailable = false;
if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false;
$modal.classList.add("open");
return;
@@ -27,22 +52,69 @@ function openUpdateModal() {
});
}
function _doOpenUpdateModal() {
function prepareUpdateModal() {
if (!$modal) return;
stopUpdatePoll();
_updateLog = "";
_updateLogOffset = 0;
_updateVisibleLogChars = 0;
_updatePollInFlight = false;
_serverWasDown = false;
_updateFinished = false;
_updateStatusUnavailable = false;
_updatePollFailures = 0;
if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "Starting update…";
if ($modalSpinner) $modalSpinner.classList.add("spinning");
if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = true;
$modal.classList.add("open");
}
function _doOpenUpdateModal() {
prepareUpdateModal();
startUpdate();
}
function showExistingUpdate(data) {
prepareUpdateModal();
if (data.log) appendLog(data.log);
_updateLogOffset = Number(data.offset) || 0;
if (data.running) {
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() {
if (!$modal) return;
$modal.classList.remove("open");
@@ -52,21 +124,36 @@ function closeUpdateModal() {
function appendLog(text) {
if (!text) return;
_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() {
fetch("/api/updates/run", { method: "POST" })
.then(function(response) {
if (!response.ok) return response.text().then(function(t) { throw new Error(t); });
return response.json();
})
apiFetchWithTimeout(
"/api/updates/run",
{ method: "POST" },
STATUS_POLL_FETCH_TIMEOUT * 2
)
.then(function(data) {
if (data.status === "no_updates") {
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnReboot) $btnReboot.style.display = "none";
if ($btnSave) $btnSave.style.display = "none";
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false;
_updateFinished = true;
return;
@@ -82,6 +169,7 @@ function startUpdate() {
}
function startUpdatePoll() {
if (_updatePollTimer) clearInterval(_updatePollTimer);
pollUpdateStatus();
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
}
@@ -91,32 +179,45 @@ function stopUpdatePoll() {
}
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 {
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) {
_serverWasDown = false;
if (!data.running) {
// The update finished while the server was restarting. Reset to
// offset 0 and re-fetch so the complete log is shown from the top.
// The update finished while the server or browser connection was away.
// Re-fetch from offset 0 so the final result and complete tail agree.
_updateLog = "";
_updateLogOffset = 0;
_updateVisibleLogChars = 0;
if ($modalLog) $modalLog.textContent = "";
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);
_updateLogOffset = fullData.offset;
} catch (e) {
// If the re-fetch fails, fall through with whatever we have.
data = fullData;
} catch (_) {
if (data.log) appendLog(data.log);
_updateLogOffset = data.offset;
}
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") {
appendLog("[Server restarted — update completed successfully.]\n");
appendLog("[Reconnected — update completed successfully.]\n");
} else {
appendLog("[Server restarted — update encountered an error.]\n");
appendLog("[Reconnected — update encountered an error.]\n");
}
_updateFinished = true;
stopUpdatePoll();
@@ -127,7 +228,7 @@ async function pollUpdateStatus() {
}
return;
}
appendLog("[Server reconnected]\n");
appendLog("[Update status reconnected]\n");
if ($modalStatus) $modalStatus.textContent = "Updating…";
}
if (data.log) appendLog(data.log);
@@ -143,12 +244,58 @@ async function pollUpdateStatus() {
onUpdateDone(false);
}
} catch (err) {
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
_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) {
_updateStatusUnavailable = false;
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
if ($btnCloseModal) $btnCloseModal.disabled = false;
if (result === true) {
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
@@ -175,6 +322,7 @@ function saveErrorReport() {
URL.revokeObjectURL(url);
}
// ── Reboot ────────────────────────────────────────────────────────
var _rebootStartTime = 0;
@@ -74,6 +74,7 @@
<div class="modal-log" id="modal-log" aria-live="polite"></div>
<div class="modal-footer">
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
<button class="btn btn-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-close-modal" id="btn-close-modal" disabled>Close</button>
</div>
+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
+1 -1
View File
@@ -5,7 +5,7 @@
"bitcoind.service": "27.1.0",
"electrs.service": "0.10.6",
"lnd.service": "0.18.0",
"rtl.service": "0.15.8",
"rtl.service": "0.15.10",
"btcpayserver.service": "2.4.2",
"albyhub.service": "1.8.0",
"mempool.service": "3.2.1",
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

+2 -1
View File
@@ -145,12 +145,13 @@
ranger fastfetch gedit openssl pwgen
aspell aspellDicts.en lm_sensors
hunspell hunspellDicts.en_US
synadm brave dua
synadm brave-origin dua
gparted pv unzip parted screen zenity
libargon2 gnome-terminal libreoffice-fresh
dig firefox wp-cli axel
lk-jwt-service livekit-libwebrtc livekit
matrix-synapse age onlyoffice-desktopeditors
tor-browser
];
# ── Shell ──────────────────────────────────────────────────
Generated
+19 -18
View File
@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1785778881,
"narHash": "sha256-yHJJTm3o7ZhiVp68G+hh+dz1GKV+ACOT6mQj+eYvJLQ=",
"lastModified": 1787246616,
"narHash": "sha256-TTbXIBwoaPbzk2o+rGEayqOvrXqKo9JdYJAMDwqjCQ4=",
"owner": "emmanuelrosa",
"repo": "btc-clients-nix",
"rev": "fc1aca94d839f82e7501fe0d01279d5c1fba6e63",
"rev": "8b10c40cb13bac5d100ae1d1fb42eccc0d9c3223",
"type": "github"
},
"original": {
@@ -26,11 +26,11 @@
]
},
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"lastModified": 1785627969,
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
"type": "github"
},
"original": {
@@ -56,11 +56,11 @@
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1786201459,
"narHash": "sha256-CiOTEjmwAmG2AWnaIno9YaCJJmpca2FXPhMAsnrolCg=",
"lastModified": 1787101114,
"narHash": "sha256-gwrPcFf/rDjHPaVflbDZ040ZDmBTRj/7+s8ZmE2SaIM=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "8b8c811c7c2541c30382c5de7ed26be055569c60",
"rev": "b18a4b905f8d028dc4476412e6d6891728695379",
"type": "github"
},
"original": {
@@ -72,11 +72,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1786106723,
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
"lastModified": 1787135253,
"narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
"rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46",
"type": "github"
},
"original": {
@@ -88,11 +88,11 @@
},
"nixpkgs_3": {
"locked": {
"lastModified": 1784555310,
"narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=",
"lastModified": 1787111413,
"narHash": "sha256-sFosWtq21eHGJRnTc/hvf4M1obRgLEUMNm/IzllkHMA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493",
"rev": "afe3d8ac4395617bdcdac9f188ac8717a062e014",
"type": "github"
},
"original": {
@@ -109,11 +109,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1785763201,
"narHash": "sha256-wA373y/B9orM3HatLu9oS+Ke5lmdZyBl/bdjd2gLMq4=",
"lastModified": 1787151631,
"narHash": "sha256-EblMdrDFBFNNlUPm5zUQdh0j6gDx+OFQPv7LFE4B5AA=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "c7be49306b23a952c0151cf4bbeacd944ed82f2a",
"rev": "d0d62a2b5027da689b4e8d5ee43f1cf83f2e975d",
"type": "github"
},
"original": {
@@ -131,6 +131,7 @@
}
},
"systems": {
"flake": false,
"locked": {
"lastModified": 1774449309,
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
-7
View File
@@ -1,7 +0,0 @@
{ config, pkgs, lib, ... }:
lib.mkIf config.sovran_systemsOS.features.bitcoin-core {
# Vendored nix-bitcoin now uses nixpkgs directly; no nix-bitcoin.pkgs indirection.
# Use standard bitcoind from nixpkgs (override to knots if desired via pkgs.bitcoind-knots)
services.bitcoind.package = lib.mkForce pkgs.bitcoind;
}
+8 -7
View File
@@ -62,11 +62,6 @@ let
description = ''
The package providing bitcoind binaries.
You can use this option to select other bitcoind-compatible implementations.
Example:
```nix
services.bitcoind.package = pkgs.bitcoind-knots;
```
'';
};
extraConfig = mkOption {
@@ -289,7 +284,9 @@ let
nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
i2pSAM = config.services.i2pd.proto.sam;
# 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
@@ -379,7 +376,11 @@ in {
services.i2pd = mkIf (cfg.i2p != false) {
enable = true;
proto.sam.enable = true;
settings.sam = {
enabled = true;
address = "127.0.0.1";
port = 7656;
};
};
systemd.tmpfiles.rules = [
+5 -2
View File
@@ -150,7 +150,7 @@ let
nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
runAsUser = config.nix-bitcoin.runAsUserCmd;
lndinit = "${(pkgs.callPackage ../../packages/lndinit {})}/bin/lndinit";
lndinit = "${pkgs.lndinit}/bin/lndinit";
bitcoind = config.services.bitcoind;
@@ -264,13 +264,16 @@ in {
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 "Grpc-Metadata-macaroon: $(${pkgs.xxd}/bin/xxd -ps -u -c 99999 '${networkDir}/admin.macaroon')" \
-H @<(printf 'Grpc-Metadata-macaroon: %s\n' "$adminMacaroonHex") \
-X POST \
-d '{"permissions":[${cfg.macaroons.${macaroon}.permissions}]}' \
${restUrl}/macaroon |\
+83 -31
View File
@@ -1,63 +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, isClightning ? false, enableOnion, onionService, port, certPath, authSecretPath }:
let
lnd = config.services.lnd;
getOnionAddress = "cat ${config.nix-bitcoin.secretsDir}/onion-address-${onionService} 2>/dev/null || echo ${onionService}.onion";
in pkgs.writeScriptBin name ''
#!${pkgs.bash}/bin/bash
set -e
certPath="${certPath}"
authSecretPath="${authSecretPath}"
if [ "${toString enableOnion}" = "1" ]; then
host=$(cat /var/lib/tor/onion/${onionService}/hostname 2>/dev/null || echo "${onionService}.onion")
port="${toString port}"
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
host="${nbLib.address lnd.restAddress}"
port="${toString lnd.restPort}"
# UTF-8 QR is smaller than lndconnect's native output
echo -n "$url" | ${getExe pkgs.qrencode} -t UTF8 -o -
fi
# lndconnect is provided by pkgs.lndconnect
${getExe pkgs.lndconnect} --host="$host" --port="$port" --cert="$certPath" --macaroon="$authSecretPath" "$@"
'';
'');
in {
options.services.lnd.lndconnect = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable lndconnect for LND";
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 = "Expose lndconnect via Tor onion service";
description = ''
Create an onion service for the lnd REST server,
which is used by lndconnect / Zeus.
'';
};
};
config = mkIf cfg.enable (mkMerge [
(mkIf cfg.lndconnect.enable {
environment.systemPackages = [
(mkLndconnect {
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 = "${operatorName}/lnd";
onionService = "${cfg.user}/lnd-rest";
port = cfg.restPort;
certPath = cfg.certPath;
authSecretPath = "${cfg.networkDir}/admin.macaroon";
})
];
})
(mkIf (cfg.lndconnect.enable && cfg.lndconnect.onion) {
services.tor.relay.onionServices.lnd = nbLib.mkOnionService {
}
)];
# 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;
target = { addr = nbLib.address cfg.restAddress; port = cfg.restPort; };
};
nix-bitcoin.onionAddresses.access.${operatorName} = [ "lnd" ];
};
nix-bitcoin.onionAddresses.access = {
${cfg.user} = [ "lnd-rest" ];
${operatorName} = [ "lnd-rest" ];
};
})
]);
}
+13 -2
View File
@@ -4,7 +4,12 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
services.bitcoind = {
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";
txindex = 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.rtl.enable = true;
+17 -4
View File
@@ -33,7 +33,7 @@
# /var/lib/njalla/ddns_urls.json.
NoNewPrivileges = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/njalla" ];
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
ReadOnlyPaths = [ "/etc/sovran" ];
ProtectHome = true;
PrivateTmp = true;
@@ -88,12 +88,15 @@ try:
except Exception:
sys.exit(0) # no URLs configured nothing to do
# Resolve current public IP once
# 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(
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
capture_output=True, text=True, timeout=10,
[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
@@ -101,6 +104,16 @@ try:
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}
+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
'';
}
+1 -1
View File
@@ -33,7 +33,7 @@
haven = lib.mkForce false;
mempool = lib.mkForce false;
element-calling = lib.mkForce false;
bitcoin-core = lib.mkForce false;
bitcoin-tor-gossip = lib.mkForce false;
"nwc-wallets" = lib.mkForce false;
};
+44 -22
View File
@@ -45,23 +45,20 @@
haven = lib.mkEnableOption "Haven NOSTR relay";
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
"nwc-wallets" = lib.mkEnableOption "Lightning Wallet Connections";
rdp = lib.mkEnableOption "Gnome Remote Desktop";
sshd = lib.mkEnableOption "SSH remote access";
# Deprecated: BIP-110 is now built into mainline Bitcoin Knots and is the
# default node. This option is retained ONLY so that existing machines with
# `sovran_systemsOS.features.bip110 = lib.mkForce true;` left in their local
# custom.nix continue to evaluate. It has no effect and will be removed in a
# future release once the Hub has cleaned up old custom.nix files.
bip110 = lib.mkOption {
bitcoin-tor-gossip = lib.mkEnableOption "Advertise the Bitcoin Core onion service through Bitcoin peer gossip";
# Compatibility shim for Hub-managed settings from releases where Core
# was an optional replacement for the default node. Core is now always
# selected when the Bitcoin service is enabled.
bitcoin-core = lib.mkOption {
type = lib.types.nullOr lib.types.bool;
default = null;
internal = true;
visible = false;
description = "(Deprecated, no-op) BIP-110 is now built into Bitcoin Knots.";
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) ──────────────────
@@ -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 ─────────────────────────────────
domainRequirements = lib.mkOption {
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.
''
];
};
}
+49 -23
View File
@@ -37,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"; }
]; }
]
# ── Bitcoin Base (node implementations) ────────────────────
# ── Bitcoin Base ────────────────────────────────────────────
++ 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 = [
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
]; }
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
{ label = "Tor Bitcoin P2P Address Reachable only through Tor"; file = "/var/lib/tor/onion/bitcoind/hostname"; suffix = ":8333"; }
]; }
]
# ── Bitcoin Apps (services on top of the node) ─────────────
@@ -125,6 +122,9 @@ let
role = activeRole;
services = monitoredServices;
feature_manager = true;
feature_states = {
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
};
sovran_version = sovranVersion;
});
@@ -132,13 +132,13 @@ let
"caddy.service" = if pkgs ? caddy then pkgs.caddy.version else "2.8.4";
"tor.service" = if pkgs ? tor then pkgs.tor.version else "0.4.8.12";
"gnome-remote-desktop.service" = if pkgs ? gnome-remote-desktop then pkgs.gnome-remote-desktop.version else "46.0";
"bitcoind.service" = if pkgs ? bitcoind-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";
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
# Keep the fallbacks aligned with the vendored packages used by the
# service modules (RTL 0.15.8 and Mempool 3.2.1). The nixpkgs attrs are
# 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.8");
"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.
@@ -160,8 +160,10 @@ let
LOG="/var/log/sovran-hub-update.log"
STATUS="/var/log/sovran-hub-update.status"
GENERATION="/var/log/sovran-hub-update.generation"
echo "RUNNING" > "$STATUS"
rm -f "$GENERATION"
: > "$LOG"
exec > >(tee -a "$LOG") 2>&1
@@ -185,16 +187,23 @@ let
if [ "$RC" -eq 0 ]; then
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 stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1)
--option fallback true
BOOT_RC=$?
echo "$BOOT_OUT"
if [ "$BOOT_RC" -ne 0 ]; then
echo "[ERROR] nixos-rebuild boot failed"
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
echo ""
fi
@@ -240,20 +249,23 @@ let
echo ""
echo ""
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 stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1)
--option fallback true
SWITCH_RC=$?
echo "$SWITCH_OUT"
if [ "$SWITCH_RC" -eq 0 ]; then
echo ""
echo ""
echo " Rebuild completed successfully"
echo ""
echo "SUCCESS" > "$STATUS"
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then
elif grep -q "switchInhibitors\|Pre-switch checks failed" "$LOG"; then
echo ""
echo " Build succeeded a reboot is required to apply this rebuild"
echo " (Critical system components changed; running nixos-rebuild boot instead)"
@@ -278,17 +290,31 @@ let
fi
'';
# ── Brave launcher wrapper: stable profile dir so Wayland app_id is
# deterministic and GNOME Shell can match the window to the .desktop
# entry (fixes generic gear icon appearing in the dock).
# ── Brave Origin launcher wrapper: a *persistent* per-user profile dir.
# It must NOT be wiped on exit: the Hub's logout marker cookie
# (hub_manual_logout) and the session cookie live in this profile.
# Launching from a fresh/ephemeral profile every time throws away the
# marker, so /auto-login would mint a new session and silently log the
# user straight back in after they signed out and reopened the window.
# A stable directory also keeps the Wayland app_id deterministic so
# GNOME Shell can match the window to the .desktop entry (dock icon).
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
export PATH="${lib.makeBinPath [ pkgs.brave pkgs.coreutils ]}:$PATH"
HUB_DATA="/tmp/sovran-hub-brave-$(id -u)"
export PATH="${lib.makeBinPath [ pkgs.brave-origin pkgs.coreutils ]}:$PATH"
# Per-user, persistent browser state. $XDG_STATE_HOME keeps it out of the
# way of backups and survives reboots and window close/reopen.
if [ -n "$XDG_STATE_HOME" ]; then
HUB_DATA="$XDG_STATE_HOME/sovran-hub-browser"
else
HUB_DATA="$HOME/.local/state/sovran-hub-browser"
fi
mkdir -p "$HUB_DATA"
trap '[ -n "$HUB_DATA" ] && rm -rf "$HUB_DATA"' EXIT INT TERM
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
brave --app=http://localhost:8937/auto-login \
# 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 \
--user-data-dir="$HUB_DATA" \
--password-store=basic \
+44 -15
View File
@@ -44,6 +44,35 @@ let
fi
fi
# Brave Origin migration (existing installs upgrading from `brave`)
# The old package's brave-browser.desktop no longer exists, so GNOME drops
# it from the dock and the Browsers folder. Swap the id in place, but only
# when the stale value is still present (never touch user customizations).
if [ -f "$USER_DB" ]; then
FAVS="$(${pkgs.dconf}/bin/dconf read /org/gnome/shell/favorite-apps 2>/dev/null || true)"
if [ -n "$FAVS" ]; then
NEW_FAVS="''${FAVS//brave-browser.desktop/brave-origin.desktop}"
if [ "$NEW_FAVS" != "$FAVS" ]; then
${pkgs.dconf}/bin/dconf write /org/gnome/shell/favorite-apps "$NEW_FAVS"
fi
fi
BROWSER_APPS="$(${pkgs.dconf}/bin/dconf read /org/gnome/desktop/app-folders/folders/Browsers/apps 2>/dev/null || true)"
if [ -n "$BROWSER_APPS" ]; then
NEW_BROWSER_APPS="''${BROWSER_APPS//brave-browser.desktop/brave-origin.desktop}"
if [ "$NEW_BROWSER_APPS" != "$BROWSER_APPS" ]; then
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/app-folders/folders/Browsers/apps "$NEW_BROWSER_APPS"
fi
fi
fi
# A previous fresh install wrote the user's mimeapps.list with the old id;
# XDG gives it precedence over the new system-wide default, so rewrite it.
MIME_LIST="$HOME/.config/mimeapps.list"
if [ -f "$MIME_LIST" ] && ${pkgs.gnugrep}/bin/grep -q 'brave-browser\.desktop' "$MIME_LIST"; then
${pkgs.gnused}/bin/sed -i 's/brave-browser\.desktop/brave-origin.desktop/g' "$MIME_LIST"
fi
# Already applied skip
if [ -f "$STAMP" ]; then
exit 0
@@ -61,11 +90,11 @@ let
mkdir -p "$HOME/.config"
cat > "$HOME/.config/mimeapps.list" << EOF
[Default Applications]
text/html=brave-browser.desktop
x-scheme-handler/http=brave-browser.desktop
x-scheme-handler/https=brave-browser.desktop
x-scheme-handler/about=brave-browser.desktop
x-scheme-handler/unknown=brave-browser.desktop
text/html=brave-origin.desktop
x-scheme-handler/http=brave-origin.desktop
x-scheme-handler/https=brave-origin.desktop
x-scheme-handler/about=brave-origin.desktop
x-scheme-handler/unknown=brave-origin.desktop
EOF
${pkgs.dconf}/bin/dconf load / << EOF
@@ -104,7 +133,7 @@ search-filter-time-type='last_modified'
[org/gnome/shell]
disabled-extensions=['just-perfection-desktop@just-perfection']
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'light-style@gnome-shell-extensions.gcampax.github.com']
favorite-apps=['brave-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'
[org/gnome/desktop/app-folders]
@@ -112,7 +141,7 @@ folder-children=['Browsers', 'Office', 'Terminal', 'Chat', 'Bitcoin', 'Media', '
[org/gnome/desktop/app-folders/folders/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]
name='Office'
@@ -269,7 +298,7 @@ in
];
favorite-apps = [
"brave-browser.desktop"
"brave-origin.desktop"
"org.gnome.Settings.desktop"
"org.gnome.Nautilus.desktop"
"sovran-hub.desktop"
@@ -292,7 +321,7 @@ in
"org/gnome/desktop/app-folders/folders/Browsers" = {
name = "Browsers";
apps = [
"brave-browser.desktop"
"brave-origin.desktop"
"firefox.desktop"
"org.gnome.Epiphany.desktop"
];
@@ -433,13 +462,13 @@ in
];
xdg.mime.defaultApplications = {
"text/html" = "brave-browser.desktop";
"x-scheme-handler/http" = "brave-browser.desktop";
"x-scheme-handler/https" = "brave-browser.desktop";
"x-scheme-handler/about" = "brave-browser.desktop";
"x-scheme-handler/unknown" = "brave-browser.desktop";
"text/html" = "brave-origin.desktop";
"x-scheme-handler/http" = "brave-origin.desktop";
"x-scheme-handler/https" = "brave-origin.desktop";
"x-scheme-handler/about" = "brave-origin.desktop";
"x-scheme-handler/unknown" = "brave-origin.desktop";
};
environment.sessionVariables.BROWSER = "brave-browser";
environment.sessionVariables.BROWSER = "brave-origin";
}
+261 -21
View File
@@ -33,9 +33,15 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
'';
};
####### ENSURE SERVICES START AFTER KEY EXISTS #######
systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
systemd.services.livekit.wants = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
####### ENSURE SERVICES START AFTER KEY & NETWORK EXIST #######
# Ordering against network-online.target matters: livekit-turn-setup detects
# the primary interface from the IPv4 default route. If it runs before the
# network is up (no default route yet) it exits 1 and, being a hard
# dependency of livekit.service, takes livekit down with it — the Hub then
# shows a "failed" red dot until livekit is restarted manually. See the
# livekit-turn-setup block for the matching network-online ordering.
systemd.services.livekit.after = [ "network-online.target" "livekit-key-setup.service" "livekit-turn-setup.service" ];
systemd.services.livekit.wants = [ "network-online.target" "livekit-key-setup.service" "livekit-turn-setup.service" ];
systemd.services.lk-jwt-service.after = [ "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.
systemd.services.livekit-turn-setup = {
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" ];
requiredBy = [ "livekit.service" ];
wantedBy = [ "multi-user.target" ];
@@ -125,19 +136,39 @@ EOF
unitConfig = {
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 = ''
MATRIX=$(cat /var/lib/domains/matrix)
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
mkdir -p /run/livekit
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir.
# The ACME CA hostname directory can vary, so glob for the domain dir.
# Caddy issues ACME certs asynchronously, so on a fresh boot the cert may
# not exist yet. Retry (bounded) so we never write an empty turn.crt/key;
# otherwise embedded TURN silently breaks until the next livekit restart.
CRT=""
KEY=""
for _ in $(seq 1 30); do
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
if [ -n "$CRT" ] && [ -n "$KEY" ] \
&& [ -s "$CRT" ] && [ -s "$KEY" ]; then
break
fi
CRT=""
KEY=""
echo "Waiting for Caddy to issue the $MATRIX ACME certificate..."
sleep 2
done
if [ -z "$CRT" ] || [ -z "$KEY" ]; then
echo "ERROR: Caddy ACME certificate for $MATRIX not available after retries; TURN will not be enabled for LiveKit." >&2
else
cp "$CRT" /var/lib/livekit/turn.crt
cp "$KEY" /var/lib/livekit/turn.key
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
fi
# Detect the primary network interface from the IPv4 default route.
# Restricting LiveKit to this single interface prevents it from
@@ -158,6 +189,56 @@ EOF
# rtc.interfaces.includes are only known at runtime, so they are
# substituted here. The cert/key paths point at the LoadCredential-staged
# copies under /run/credentials.
#
# Determine the public IPv4 to advertise in LiveKit ICE candidates.
# Remote peers must be able to reach this address, so it must be the
# server's public IP or the router's WAN IP when the server is behind
# NAT with port-forwarding. It does not need to be assigned to this box,
# and it may be dynamic.
#
# 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
port: 7880
rtc:
@@ -170,6 +251,20 @@ rtc:
interfaces:
includes:
- $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:
auto_create: false
turn:
@@ -179,6 +274,10 @@ turn:
udp_port: 3478
cert_file: /run/credentials/livekit.service/turn-cert
key_file: /run/credentials/livekit.service/turn-key
webhook:
api_key: $LK_KEY
urls:
- https://$ELEMENT_CALLING/livekit/jwt/sfu_webhook
EOF
chmod 644 /run/livekit/livekit.yaml
@@ -186,24 +285,17 @@ EOF
};
####### LIVEKIT SERVICE #######
# NOTE: the runtime config (rtc ports, TURN, webhook, node_ip) is generated
# by livekit-turn-setup and delivered via LoadCredential; the upstream
# module's `settings` block is therefore intentionally NOT used (it would
# be dead config that silently diverges from what LiveKit actually loads).
# The firewall ports are opened explicitly below; openFirewall is left off
# so the upstream module does not also open 7880/tcp publicly (Caddy fronts
# the SFU on this host).
services.livekit = {
enable = true;
openFirewall = true;
openFirewall = false;
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
@@ -247,12 +339,26 @@ EOF
script = ''
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
MATRIX=$(cat /var/lib/domains/matrix)
FULL_ACCESS_HOMESERVERS="$MATRIX"
# Federated peers may also be granted LiveKit room-creation (full access)
# on this SFU via sovran_systemsOS.elementCalling.fullAccessHomeservers.
# Without this, remote users can join existing calls but cannot be the
# first to start one on your SFU.
EXTRA_HS='${lib.concatStringsSep "," config.sovran_systemsOS.elementCalling.fullAccessHomeservers}'
if [ -n "$EXTRA_HS" ]; then
FULL_ACCESS_HOMESERVERS="$FULL_ACCESS_HOMESERVERS,$EXTRA_HS"
fi
mkdir -p /run/lk-jwt-service
cat > /run/lk-jwt-service/env <<EOF
LIVEKIT_URL=wss://$ELEMENT_CALLING
LIVEKIT_FULL_ACCESS_HOMESERVERS=$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
chmod 640 /run/lk-jwt-service/env
@@ -264,6 +370,9 @@ EOF
enable = true;
port = 8073;
keyFile = livekitKeyFile;
# Required by the upstream module's option type, but overridden at runtime
# by EnvironmentFile (/run/lk-jwt-service/env, generated above from the
# element-calling domain). Kept as a harmless placeholder.
livekitUrl = "wss://placeholder.local";
};
@@ -271,6 +380,126 @@ EOF
"/run/lk-jwt-service/env"
];
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
# configs (new domains, externalIP, full-access list), mirroring the domain
# change flow.
# 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) #######
systemd.services.element-calling-synapse-config = {
description = "Generate Synapse runtime config for Element Calling";
@@ -287,6 +516,7 @@ EOF
path = [ pkgs.coreutils ];
script = ''
MATRIX=$(cat /var/lib/domains/matrix)
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
mkdir -p /run/matrix-synapse
@@ -296,7 +526,17 @@ public_baseurl: "https://$MATRIX"
serve_server_wellknown: true
experimental_features:
msc3266_enabled: true
# MSC4143: enables the MatrixRTC transports registry endpoint
# (/_matrix/client/unstable/org.matrix.msc4143/rtc/transports, MSC4519).
# Element X requires this endpoint to discover the LiveKit focus; without it
# mobile clients fail with MISSING_MATRIX_RTC_TRANSPORT / cannot start calls.
msc4143_enabled: true
msc4222_enabled: true
# MSC4519: advertise this site's LiveKit focus via the transports registry.
matrix_rtc:
transports:
- type: livekit
livekit_service_url: "https://$ELEMENT_CALLING/livekit/jwt"
max_event_delay_duration: "24h"
rc_message:
per_second: 0.5
+1 -1
View File
@@ -17,6 +17,7 @@
./core/no-sleep.nix
./core/cpu-performance.nix
./core/local-domain-loopback.nix
./core/public-ip.nix
# ── Always on (no flag) ───────────────────────────────────
./php.nix
@@ -35,7 +36,6 @@
./nwc-wallets.nix
./element-calling.nix
./mempool.nix
./bitcoin-core.nix
./rdp.nix
./sshd.nix
];
+22 -7
View File
@@ -47,29 +47,44 @@ EOF
systemd.services.zeus-connect-setup = {
description = "Save Zeus lndconnect URL";
wantedBy = [ "multi-user.target" ];
after = [ "lnd.service" ];
after = [ "lnd.service" "onion-addresses.service" ];
wants = [ "lnd.service" "onion-addresses.service" ];
serviceConfig = {
Type = "oneshot";
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 = ''
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
mkdir -p /var/lib/secrets
# LND may still be creating the wallet / macaroon, and the dedicated
# lnd-rest onion hostname is published by onion-addresses.service.
URL=""
ATTEMPTS=0
while [ "$ATTEMPTS" -lt 60 ]; do
if command -v lndconnect >/dev/null 2>&1; then
URL=$(lndconnect --url 2>/dev/null || true)
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
URL=$(lndconnect --url 2>/dev/null | tr -d '\r' | tail -n 1 || true)
fi
# Zeus LND REST over Tor: lndconnect://<v3-onion>:8080?macaroon=...
if echo "$URL" | grep -q '^lndconnect://' \
&& echo "$URL" | grep -q '\.onion' \
&& echo "$URL" | grep -q 'macaroon='; then
break
fi
URL=""
ATTEMPTS=$((ATTEMPTS + 1))
sleep 2
done
if [ -n "$URL" ]; then
echo "$URL" > "$SECRET_FILE"
printf '%s\n' "$URL" > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
echo "Zeus connect URL saved."
else
echo "No lndconnect URL available yet."
echo "No valid lndconnect URL available yet."
fi
'';
};
-19
View File
@@ -1,19 +0,0 @@
{ lib, buildGoModule, fetchFromGitHub }:
buildGoModule rec {
pname = "lndinit";
version = "0.1.3-beta";
src = fetchFromGitHub {
owner = "lightninglabs";
repo = pname;
rev = "v${version}";
sha256 = "sha256-sO1DpbppCurxr9g9nUl9Vx82FJK1mTcUw3rY1Fm1wEU=";
};
vendorHash = "sha256-El44BS5Bu0K/klMxkajciU/R6uqiXBMOiLN536QztbE=";
subPackages = [ "." ];
meta = with lib; {
description = "Wallet initializer for lnd (from nix-bitcoin)";
homepage = "https://github.com/lightninglabs/lndinit";
license = licenses.mit;
};
}
+3 -3
View File
@@ -10,11 +10,11 @@
}:
let self = stdenvNoCC.mkDerivation {
pname = "rtl";
version = "0.15.8";
version = "0.15.10";
src = fetchurl {
url = "https://github.com/Ride-The-Lightning/RTL/archive/refs/tags/v${self.version}.tar.gz";
hash = "sha256-8XdGyORxB2dkZRB/Yl7zh+Quqo4L/Y0VmC6Brbr/hqU=";
hash = "sha256-r5riYV2FN0OKi0mwj9I1jBeeU1LOv2HVB6CEovPlUuY=";
};
passthru = {
@@ -26,7 +26,7 @@ let self = stdenvNoCC.mkDerivation {
# TODO-EXTERNAL: Remove `npmFlags` when no longer required
# See: https://github.com/Ride-The-Lightning/RTL/issues/1182
npmFlags = "--legacy-peer-deps";
hash = "sha256-oMqd6nLzS6iQ9w4z2yzpR2unA5qhOq5YdvfoS8IgYLY=";
hash = "sha256-NKiWcjqYcHBVIB+vbF3aKXLe2fJRmh/quu8obztP3TA=";
};
};
+259 -175
View File
@@ -20,6 +20,11 @@
set -euo pipefail
# Always operate from the repository root, regardless of the caller's cwd.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -62,13 +67,28 @@ echo
# ─────────────────────────────────────────────────────────────────────────────
# Helper: Get latest tag
# ─────────────────────────────────────────────────────────────────────────────
# GitHub release tags are fetched into a private namespace instead of
# refs/tags/. GitHub and Gitea contain some same-named historical tags that
# point to different objects; sharing refs/tags/ would make either fetch fail
# with "would clobber existing tag".
GITHUB_TAG_NAMESPACE="refs/release-tags/github"
get_latest_tag() {
local exclude="${1:-}"
if [[ -n "$exclude" ]]; then
git tag --list 'v*' --sort=-version:refname | grep -v -x -E "v?${exclude#v}|${exclude}" | head -1 || echo ""
else
git tag --list 'v*' --sort=-version:refname | head -1 || echo ""
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
}
# ─────────────────────────────────────────────────────────────────────────────
@@ -90,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}"
git fetch --all --tags 2>/dev/null || true
echo -e "${BLUE}Step 0: Running release preflight...${NC}"
# A release must start from an exact, committed, reproducible tree. This also
# prevents release metadata generated below from being mixed with local work.
if [[ -n "$(git status --porcelain)" ]]; then
echo -e "${RED}Error: the working tree is not clean. Commit, stash, or remove local changes first.${NC}" >&2
git status --short >&2
exit 1
fi
for remote in "$GITHUB_REMOTE" "$GITEA_REMOTE"; do
if ! git remote | grep -q -x "$remote"; then
echo -e "${RED}Error: required remote '$remote' is not configured.${NC}" >&2
exit 1
fi
done
for command_name in gh curl; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo -e "${RED}Error: required command '$command_name' is not installed.${NC}" >&2
exit 1
fi
done
if ! gh auth status >/dev/null 2>&1; then
echo -e "${RED}Error: GitHub CLI is not authenticated. Run 'gh auth login' first.${NC}" >&2
exit 1
fi
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo
read -rsp "Enter your Gitea API token (input hidden): " GITEA_TOKEN
echo
fi
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo -e "${RED}Error: a GITEA_TOKEN with write:repository scope is required.${NC}" >&2
exit 1
fi
echo -e " Fetching GitHub (${GITHUB_REMOTE}) and Gitea (${GITEA_REMOTE})..."
# Never fetch either host's tags into refs/tags/. Historical tags with the same
# name differ between the hosts, and Git correctly refuses to clobber them.
git fetch "$GITHUB_REMOTE" --prune --no-tags
git fetch "$GITEA_REMOTE" --prune --no-tags
git fetch "$GITHUB_REMOTE" --prune --no-tags \
"+refs/tags/*:${GITHUB_TAG_NAMESPACE}/*"
GITHUB_MAIN_REF="refs/remotes/${GITHUB_REMOTE}/main"
GITEA_STAGING_REF="refs/remotes/${GITEA_REMOTE}/staging-dev"
if ! git rev-parse --verify "$GITHUB_MAIN_REF" >/dev/null 2>&1; then
echo -e "${RED}Error: cannot resolve GitHub main at ${GITHUB_MAIN_REF}.${NC}" >&2
exit 1
fi
if ! git rev-parse --verify "$GITEA_STAGING_REF" >/dev/null 2>&1; then
echo -e "${RED}Error: cannot resolve Gitea staging-dev at ${GITEA_STAGING_REF}.${NC}" >&2
exit 1
fi
HEAD_COMMIT=$(git rev-parse HEAD)
GITHUB_MAIN_COMMIT=$(git rev-parse "$GITHUB_MAIN_REF")
GITEA_STAGING_COMMIT=$(git rev-parse "$GITEA_STAGING_REF")
if [[ "$GITHUB_MAIN_COMMIT" != "$GITEA_STAGING_COMMIT" ]]; then
echo -e "${RED}Error: GitHub main and Gitea staging-dev are not synchronized.${NC}" >&2
echo " GitHub main : $GITHUB_MAIN_COMMIT" >&2
echo " Gitea staging-dev : $GITEA_STAGING_COMMIT" >&2
exit 1
fi
if [[ "$HEAD_COMMIT" != "$GITHUB_MAIN_COMMIT" ]]; then
echo -e "${RED}Error: local HEAD is not the synchronized release candidate.${NC}" >&2
echo " Local HEAD : $HEAD_COMMIT" >&2
echo " Remote HEAD : $GITHUB_MAIN_COMMIT" >&2
echo "Update/check out the synchronized commit, then run the script again." >&2
exit 1
fi
echo -e " ${GREEN}${NC} Clean tree; GitHub main and Gitea staging-dev match local HEAD"
LATEST_TAG=$(get_latest_tag)
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
@@ -117,6 +211,23 @@ fi
VERSION="${VERSION#v}"
TAG="v${VERSION}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${RED}Error: version must use MAJOR.MINOR.PATCH format (for example, 1.1.1).${NC}" >&2
exit 1
fi
remote_has_tag() {
local remote="$1"
[[ -n "$(git ls-remote --tags "$remote" "refs/tags/${TAG}" "refs/tags/${TAG}^{}")" ]]
}
if git show-ref --verify --quiet "refs/tags/${TAG}" || \
git show-ref --verify --quiet "${GITHUB_TAG_NAMESPACE}/${TAG}" || \
remote_has_tag "$GITHUB_REMOTE" || remote_has_tag "$GITEA_REMOTE"; then
echo -e "${RED}Error: tag ${TAG} already exists locally or on a remote.${NC}" >&2
echo "Refusing to move or overwrite an existing release tag." >&2
exit 1
fi
# ─────────────────────────────────────────────────────────────────────────────
# Get release message
# ─────────────────────────────────────────────────────────────────────────────
@@ -193,20 +304,26 @@ generate_release_notes() {
printf '%s' "$notes"
}
# Build the notes from commits since the previous tag (exclude the target tag if already present)
# 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")
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG" >/dev/null; then
COMMIT_RANGE="${PREV_TAG}..HEAD"
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}...${NC}"
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
@@ -246,95 +363,45 @@ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
fi
# ─────────────────────────────────────────────────────────────────────────────
# Step 1: Push tested code to Gitea (stable & staging-dev)
# Step 1: Prepare and commit all release metadata
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 1: Pushing tested code to Gitea (stable & staging-dev)...${NC}"
if git remote | grep -q -x "${GITEA_REMOTE}"; then
git push "${GITEA_REMOTE}" HEAD:stable --force-with-lease || echo -e " ${YELLOW}⚠ Push to ${GITEA_REMOTE} (stable) failed.${NC}"
git push "${GITEA_REMOTE}" HEAD:staging-dev --force-with-lease || echo -e " ${YELLOW}⚠ Push to ${GITEA_REMOTE} (staging-dev) failed.${NC}"
else
echo -e " ${YELLOW}⚠ Remote '${GITEA_REMOTE}' not found in git — skipping Gitea push.${NC}"
fi
echo -e "${BLUE}Step 1: Preparing release metadata for ${TAG}...${NC}"
# ─────────────────────────────────────────────────────────────────────────────
# Step 2: Create annotated tag
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 2: Creating annotated tag ${TAG}...${NC}"
git tag -f -a "${TAG}" -m "${RELEASE_MESSAGE}
- Stable release of Sovran_SystemsOS
- See CHANGELOG.md for full details" || true
if git remote | grep -q -x "${GITEA_REMOTE}"; then
git push "${GITEA_REMOTE}" "${TAG}" || echo -e " ${YELLOW}⚠ Tag push to ${GITEA_REMOTE} failed.${NC}"
else
echo -e " ${YELLOW}⚠ Remote '${GITEA_REMOTE}' not found in git — skipping Gitea tag push.${NC}"
fi
if git remote | grep -q -x "${GITHUB_REMOTE}"; then
git push "${GITHUB_REMOTE}" "${TAG}" || echo -e " ${YELLOW}⚠ Tag push to ${GITHUB_REMOTE} failed.${NC}"
fi
# Update VERSION file for ISO builds
# VERSION drives ISO naming.
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}"
# ─────────────────────────────────────────────────────────────────────────────
# Step 2b: Auto-update README.md ISO download references
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 2b: Updating README.md ISO references to ${VERSION}...${NC}"
# Update every versioned ISO filename in README.md.
README_FILE="README.md"
if [ -f "$README_FILE" ]; then
# Extract the version currently used in the README's ISO filenames,
# e.g. "Sovran_SystemsOS-1.0.6.iso" -> "1.0.6"
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 [ -n "$OLD_ISO_VER" ] && [ "$OLD_ISO_VER" != "$VERSION" ]; then
# Portable in-place edit (works with GNU sed and BSD/macOS sed)
if [[ ! -f "$README_FILE" ]]; then
echo -e "${RED}Error: ${README_FILE} not found.${NC}" >&2
exit 1
fi
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" \
| head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
if [[ -z "$OLD_ISO_VER" ]]; then
echo -e "${RED}Error: no versioned ISO reference found in ${README_FILE}.${NC}" >&2
exit 1
fi
if [[ "$OLD_ISO_VER" != "$VERSION" ]]; then
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
"$README_FILE" > "$README_FILE.tmp" && mv "$README_FILE.tmp" "$README_FILE"
UPDATED=$(grep -c "Sovran_SystemsOS-${VERSION}" "$README_FILE")
echo -e " ${GREEN}${NC} README.md: ${OLD_ISO_VER} -> ${VERSION} (${UPDATED} references updated)"
git add "$README_FILE"
git commit -m "docs: update README ISO download links to ${TAG}" || true
echo -e " ${GREEN}${NC} Committed README update"
else
echo -e " ${YELLOW}${NC} README.md already references ${VERSION} or no versioned ISO links found — skipping"
fi
else
echo -e " ${YELLOW}${NC} README.md not found — skipping"
"$README_FILE" > "$README_FILE.tmp"
mv "$README_FILE.tmp" "$README_FILE"
fi
# ─────────────────────────────────────────────────────────────────────────────
# Step 3: Auto-update CHANGELOG.md
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 3: Updating ${CHANGELOG_FILE}...${NC}"
# Add the changelog entry before tagging so the tag and stable branch contain it.
TODAY=$(date +%Y-%m-%d)
# Create new changelog entry from the generated notes (no placeholders)
NEW_ENTRY="## [${VERSION}] - ${TODAY}
${RELEASE_NOTES}
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
"
# Prepend to changelog (after the header)
if [ -f "$CHANGELOG_FILE" ]; then
# Backup
cp "$CHANGELOG_FILE" "${CHANGELOG_FILE}.bak"
# Insert new section after the first --- line
awk -v new_entry="$NEW_ENTRY" '
if [[ ! -f "$CHANGELOG_FILE" ]]; then
echo -e "${RED}Error: ${CHANGELOG_FILE} not found.${NC}" >&2
exit 1
fi
awk -v new_entry="$NEW_ENTRY" '
BEGIN { printed=0 }
/^---$/ && !printed {
print
@@ -344,116 +411,134 @@ if [ -f "$CHANGELOG_FILE" ]; then
next
}
{ print }
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" && mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
rm -f "${CHANGELOG_FILE}.bak"
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}"
else
echo -e " ${YELLOW}${NC} CHANGELOG.md not found — skipping"
fi
# Commit the changelog update
git add "$CHANGELOG_FILE"
git add VERSION "$README_FILE" "$CHANGELOG_FILE"
if git diff --cached --quiet; then
echo " (No changes to commit in changelog)"
else
git commit -m "docs: update CHANGELOG.md for ${TAG}"
echo -e " ${GREEN}${NC} Committed changelog update"
# Ask if user wants to push
echo
read -rp "Push the changelog commit to GitHub (main) and Gitea (staging-dev) now? (y/N): " push_confirm
if [[ "$push_confirm" =~ ^[Yy]$ ]]; then
if git remote | grep -q -x "${GITHUB_REMOTE}"; then
echo -e "${BLUE}Pushing changelog commit to GitHub main...${NC}"
if git push "${GITHUB_REMOTE}" HEAD:main; then
echo -e " ${GREEN}${NC} Changelog pushed to GitHub main (${GITHUB_REMOTE})"
else
echo -e " ${YELLOW}⚠ Push to GitHub main failed — verify credentials or remote name.${NC}"
fi
fi
if git remote | grep -q -x "${GITEA_REMOTE}"; then
echo -e "${BLUE}Pushing changelog commit to Gitea staging-dev...${NC}"
if git push "${GITEA_REMOTE}" HEAD:staging-dev; then
echo -e " ${GREEN}${NC} Changelog pushed to Gitea staging-dev (${GITEA_REMOTE})"
else
echo -e " ${YELLOW}⚠ Push to Gitea staging-dev failed.${NC}"
fi
fi
else
echo " (Changelog commit left local — remember to push later)"
fi
echo -e "${RED}Error: release preparation produced no changes.${NC}" >&2
exit 1
fi
git commit -m "chore(release): prepare ${TAG}"
RELEASE_COMMIT=$(git rev-parse HEAD)
echo -e " ${GREEN}${NC} VERSION, README, and CHANGELOG committed"
echo -e " Release commit: ${CYAN}${RELEASE_COMMIT}${NC}"
# ─────────────────────────────────────────────────────────────────────────────
# Step 4: Create GitHub Release via gh CLI
# Step 2: Publish the final release commit to every release branch
# ─────────────────────────────────────────────────────────────────────────────
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
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
if gh release create "${TAG}" \
# GitHub main and Gitea staging-dev were verified equal during preflight, so
# these are normal fast-forward pushes. Stable is an intentional promotion and
# uses a lease to prevent overwriting a branch changed since the fetch.
git push "$GITHUB_REMOTE" HEAD:main
git push "$GITEA_REMOTE" HEAD:staging-dev
git push "$GITEA_REMOTE" HEAD:stable --force-with-lease
# Verify all three branch tips before creating an immutable release tag.
remote_branch_commit() {
local remote="$1"
local branch="$2"
git ls-remote "$remote" "refs/heads/${branch}" | awk 'NR == 1 { print $1 }'
}
PUBLISHED_GITHUB=$(remote_branch_commit "$GITHUB_REMOTE" main)
PUBLISHED_STAGING=$(remote_branch_commit "$GITEA_REMOTE" staging-dev)
PUBLISHED_STABLE=$(remote_branch_commit "$GITEA_REMOTE" stable)
if [[ "$PUBLISHED_GITHUB" != "$RELEASE_COMMIT" || \
"$PUBLISHED_STAGING" != "$RELEASE_COMMIT" || \
"$PUBLISHED_STABLE" != "$RELEASE_COMMIT" ]]; then
echo -e "${RED}Error: post-push verification failed; no release tag was created.${NC}" >&2
echo " Expected : $RELEASE_COMMIT" >&2
echo " GitHub main : ${PUBLISHED_GITHUB:-missing}" >&2
echo " Gitea staging-dev : ${PUBLISHED_STAGING:-missing}" >&2
echo " Gitea stable : ${PUBLISHED_STABLE:-missing}" >&2
exit 1
fi
echo -e " ${GREEN}${NC} GitHub main, Gitea staging-dev, and Gitea stable all match"
# ─────────────────────────────────────────────────────────────────────────────
# Step 3: Tag the final release commit and publish the tag
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 3: Creating annotated tag ${TAG} on ${RELEASE_COMMIT}...${NC}"
git tag -a "$TAG" -m "${RELEASE_MESSAGE}
- Stable release of Sovran_SystemsOS
- See CHANGELOG.md for full details" "$RELEASE_COMMIT"
git push "$GITHUB_REMOTE" "$TAG"
git push "$GITEA_REMOTE" "$TAG"
# For an annotated tag, ^{} resolves the commit referenced by the tag object.
remote_tag_commit() {
local remote="$1"
git ls-remote "$remote" "refs/tags/${TAG}^{}" | awk 'NR == 1 { print $1 }'
}
GITHUB_TAG_COMMIT=$(remote_tag_commit "$GITHUB_REMOTE")
GITEA_TAG_COMMIT=$(remote_tag_commit "$GITEA_REMOTE")
if [[ "$GITHUB_TAG_COMMIT" != "$RELEASE_COMMIT" || "$GITEA_TAG_COMMIT" != "$RELEASE_COMMIT" ]]; then
echo -e "${RED}Error: published tag verification failed.${NC}" >&2
exit 1
fi
echo -e " ${GREEN}${NC} ${TAG} points to the final release commit on GitHub and Gitea"
# ─────────────────────────────────────────────────────────────────────────────
# Step 4: Create GitHub release via gh CLI
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 4: Creating GitHub release...${NC}"
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
gh release create "$TAG" \
--repo naturallaw777/Sovran_SystemsOS \
--title "${TAG}${title_suffix}" \
--notes-file "${NOTES_FILE}"; then
echo -e " ${GREEN}${NC} GitHub release created successfully"
else
echo -e " ${YELLOW}${NC} GitHub release creation failed (check 'gh auth status' or create via web GUI)"
fi
else
echo -e " ${YELLOW}${NC} gh CLI not found — skipping GitHub release (create via GitHub web GUI)"
fi
--notes-file "$NOTES_FILE"
echo -e " ${GREEN}${NC} GitHub release created successfully"
# ─────────────────────────────────────────────────────────────────────────────
# Step 5: Create Gitea Release via API
# Step 5: Create Gitea release via API
# ─────────────────────────────────────────────────────────────────────────────
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
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
# Build JSON safely because release notes may contain quotes and newlines.
if command -v jq >/dev/null 2>&1; then
PAYLOAD=$(jq -n \
--arg tag "$TAG" \
--arg name "$TAG — Stable Release" \
--arg body "$RELEASE_BODY" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
else
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': sys.argv[1] + ' — Stable Release', 'body': open(sys.argv[2]).read(), 'draft': False, 'prerelease': False}))" "$TAG" "$NOTES_FILE")
fi
if [[ -n "${GITEA_TOKEN:-}" ]]; then
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
# Build JSON payload safely (release body may contain quotes/newlines)
if command -v jq &>/dev/null; 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=$(curl -s -X POST \
RESPONSE_FILE=$(mktemp "/tmp/gitea-release-${TAG}.XXXXXX.json")
HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "${PAYLOAD}" \
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases" 2>/dev/null || echo "")
-d "$PAYLOAD" \
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases")
if echo "$RESPONSE" | grep -q '"id"'; then
echo -e " ${GREEN}${NC} Gitea release created successfully"
elif echo "$RESPONSE" | grep -q "write:repository"; then
echo -e " ${YELLOW}${NC} Gitea token scope issue: your token requires the 'write:repository' scope (currently has write:package)."
echo " To fix: In Gitea, navigate to Settings → Applications → Manage Access Tokens and generate a token with 'write:repository'."
else
echo -e " ${YELLOW}${NC} Gitea release creation failed or already exists"
echo " Response: $RESPONSE"
fi
if [[ "$HTTP_STATUS" != "201" ]]; then
echo -e "${RED}Error: Gitea release creation failed (HTTP ${HTTP_STATUS}).${NC}" >&2
cat "$RESPONSE_FILE" >&2
rm -f "$RESPONSE_FILE"
exit 1
fi
rm -f "$RESPONSE_FILE"
echo -e " ${GREEN}${NC} Gitea release created successfully"
# ─────────────────────────────────────────────────────────────────────────────
# Final Summary
@@ -468,8 +553,7 @@ echo " • Build the installer ISO:"
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
echo " • Package, verify, and upload ISO to CDN:"
echo " ./scripts/upload-cdn.sh --upload"
echo " • Review and enhance the new section in CHANGELOG.md"
echo " • Push changes: git push ${GITHUB_REMOTE} HEAD:main && git push ${GITEA_REMOTE} HEAD:staging-dev"
echo " • Verify the ISO download and checksum from the public CDN"
echo " • Verify releases on both GitHub and Gitea"
echo
echo -e "${CYAN}Tag created: ${TAG}${NC}"
+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()
+214
View File
@@ -14,6 +14,7 @@ import json
import os
import sys
import tempfile
import time
import unittest
# Add the app package to the path so we can import without the full FastAPI tree.
@@ -30,6 +31,8 @@ from sovran_systemsos_web.security_helpers import ( # noqa: E402
_validate_ssh_pubkey,
_DDNS_ALLOWED_HOSTNAMES,
_bech32_decode,
load_session_store,
save_session_store,
)
from sovran_systemsos_web import support_ops # noqa: E402
@@ -330,6 +333,33 @@ class TestSshPubkeyValidation(unittest.TestCase):
_validate_ssh_pubkey("ssh-ed25519")
# ---------------------------------------------------------------------------
# LND macaroon command-line safety
# ---------------------------------------------------------------------------
class TestLndMacaroonCommandLineSafety(unittest.TestCase):
"""The LND admin macaroon must never be exposed in curl's argv."""
@classmethod
def setUpClass(cls):
path = os.path.join(_REPO_ROOT, "modules", "bitcoin", "lnd.nix")
with open(path, encoding="utf-8") as f:
cls.lnd_module = f.read()
def test_admin_macaroon_not_interpolated_into_header_argument(self):
self.assertNotIn(
'-H "Grpc-Metadata-macaroon: $(',
self.lnd_module,
)
def test_admin_macaroon_header_is_passed_via_file_descriptor(self):
self.assertIn("adminMacaroonHex=$(", self.lnd_module)
self.assertIn(
"""-H @<(printf 'Grpc-Metadata-macaroon: %s\\n' "$adminMacaroonHex")""",
self.lnd_module,
)
# ---------------------------------------------------------------------------
# Auth-exempt paths
# ---------------------------------------------------------------------------
@@ -362,6 +392,190 @@ class TestAuthExemptPaths(unittest.TestCase):
self.assertIn("/api/ping", self._get_exempt_paths())
class TestFrontendAuthRecovery(unittest.TestCase):
"""Expired browser sessions must not leave the Hub polling forever."""
@classmethod
def setUpClass(cls):
path = os.path.join(
_REPO_ROOT, "app", "sovran_systemsos_web", "static", "js", "helpers.js"
)
with open(path, encoding="utf-8") as f:
cls.helpers = f.read()
def test_api_fetch_redirects_unauthorized_response_to_login(self):
self.assertRegex(self.helpers, r"res\.status\s*===\s*401")
self.assertIn('window.location.replace("/login")', self.helpers)
def test_unauthorized_response_does_not_use_local_auto_login(self):
# Remote clients must still authenticate with the Hub password.
self.assertNotIn('window.location.replace("/auto-login")', self.helpers)
class TestManualLogoutPersistence(unittest.TestCase):
"""Explicit logout must take precedence over desktop auto-login."""
@classmethod
def setUpClass(cls):
path = os.path.join(_REPO_ROOT, "app", "sovran_systemsos_web", "server.py")
with open(path, encoding="utf-8") as f:
cls.server = f.read()
def _between(self, start, end):
return self.server.split(start, 1)[1].split(end, 1)[0]
def test_auto_login_honors_manual_logout_cookie(self):
route = self._between(
'@app.get("/auto-login")',
"class LoginRequest",
)
self.assertIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", route)
self.assertIn('RedirectResponse(url="/login"', route)
def test_logout_sets_persistent_manual_logout_cookie(self):
route = self._between(
'@app.post("/api/logout")',
"def _get_sovran_version",
)
self.assertIn("key=MANUAL_LOGOUT_COOKIE_NAME", route)
self.assertIn("max_age=MANUAL_LOGOUT_MAX_AGE", route)
self.assertIn("httponly=True", route)
def test_password_login_clears_manual_logout_cookie(self):
route = self._between(
'@app.post("/api/login")',
'@app.post("/api/logout")',
)
self.assertIn(
"response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)", route
)
class TestHubBrowserProfilePersistence(unittest.TestCase):
"""The desktop launcher must keep a persistent browser profile.
The hub_manual_logout marker (and the session cookie) are stored in this
profile. If the launcher used an ephemeral /tmp profile that it deleted on
exit, closing and reopening the Hub window would wipe the marker and
/auto-login would silently log the user back in without a password.
"""
@classmethod
def setUpClass(cls):
path = os.path.join(
_REPO_ROOT, "modules", "core", "sovran-hub.nix"
)
with open(path, encoding="utf-8") as f:
cls.wrapper = f.read()
def test_profile_is_not_under_tmp(self):
# The profile must live in a persistent per-user location, not /tmp.
self.assertNotIn("/tmp/sovran-hub-brave", self.wrapper)
def test_profile_is_not_deleted_on_exit(self):
# There must be no trap that removes the user-data-dir on exit.
self.assertNotRegex(self.wrapper, r"rm\s+-rf\s+.*HUB_DATA")
self.assertNotIn("trap '", self.wrapper)
def test_profile_is_persistent_per_user_location(self):
self.assertIn("sovran-hub-browser", self.wrapper)
# It should honour XDG_STATE_HOME (standard, persistent per-user dir).
self.assertIn("XDG_STATE_HOME", self.wrapper)
def test_launcher_still_uses_user_data_dir(self):
self.assertIn("--user-data-dir=", self.wrapper)
# ---------------------------------------------------------------------------
# Persistent session store
# ---------------------------------------------------------------------------
class TestSessionStore(unittest.TestCase):
"""Sessions must persist across Hub restarts so rebuild/update polling
keeps working after nixos-rebuild switch restarts the Hub service."""
def _store_path(self, tmpdir, name="hub-sessions.json"):
return os.path.join(tmpdir, name)
def test_roundtrip(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = self._store_path(tmpdir)
future = time.time() + 3600
sessions = {"token-a": future, "token-b": future + 10}
self.assertTrue(save_session_store(path, sessions))
self.assertEqual(load_session_store(path), sessions)
def test_missing_file_returns_empty(self):
with tempfile.TemporaryDirectory() as tmpdir:
self.assertEqual(load_session_store(self._store_path(tmpdir)), {})
def test_malformed_json_returns_empty(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = self._store_path(tmpdir)
with open(path, "w") as f:
f.write("{not json")
self.assertEqual(load_session_store(path), {})
def test_non_dict_json_returns_empty(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = self._store_path(tmpdir)
with open(path, "w") as f:
json.dump(["token"], f)
self.assertEqual(load_session_store(path), {})
def test_expired_sessions_discarded(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = self._store_path(tmpdir)
now = time.time()
save_session_store(path, {"alive": now + 3600, "dead": now - 1})
loaded = load_session_store(path)
self.assertIn("alive", loaded)
self.assertNotIn("dead", loaded)
def test_invalid_entries_skipped(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = self._store_path(tmpdir)
now = time.time()
with open(path, "w") as f:
json.dump({
"good": now + 3600,
"": now + 3600, # empty token
"bool-expiry": True, # bool is not a valid expiry
"str-expiry": "soon", # non-numeric expiry
"none-expiry": None,
}, f)
loaded = load_session_store(path)
self.assertEqual(list(loaded.keys()), ["good"])
def test_file_mode_is_0600(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = self._store_path(tmpdir)
save_session_store(path, {"token": time.time() + 60})
mode = os.stat(path).st_mode & 0o777
self.assertEqual(mode, 0o600)
def test_save_overwrites_existing_store(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = self._store_path(tmpdir)
future = time.time() + 3600
save_session_store(path, {"old": future})
save_session_store(path, {"new": future})
self.assertEqual(load_session_store(path), {"new": future})
def test_empty_store_roundtrip(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = self._store_path(tmpdir)
self.assertTrue(save_session_store(path, {}))
self.assertEqual(load_session_store(path), {})
def test_no_leftover_temp_files(self):
with tempfile.TemporaryDirectory() as tmpdir:
path = self._store_path(tmpdir)
save_session_store(path, {"token": time.time() + 60})
leftovers = [n for n in os.listdir(tmpdir) if n.startswith(".hub_sessions_tmp")]
self.assertEqual(leftovers, [])
# ---------------------------------------------------------------------------
# tech-support.nix validation
# ---------------------------------------------------------------------------
+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()