Compare commits
60
Commits
v1.1.0
...
be5d47cb9c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be5d47cb9c | ||
|
|
ddf87a1c1c | ||
|
|
f0e4c33a5f | ||
|
|
a6ef13688e | ||
|
|
ef1c045e0e | ||
|
|
e27bf0ef1f | ||
|
|
362fa0b36c | ||
|
|
043981a34b | ||
|
|
5529fccab2 | ||
|
|
5f84154dca | ||
|
|
60b55f715e | ||
|
|
01037cd55c | ||
|
|
7980833fc2 | ||
|
|
837fffc8f9 | ||
|
|
cb4585ecf8 | ||
|
|
1de1bf95ad | ||
|
|
e9d6edca56 | ||
|
|
a7bfc9aa0d | ||
|
|
ed2e4059c1 | ||
|
|
f057e43ca0 | ||
|
|
d600198049 | ||
|
|
14db4282b7 | ||
|
|
d31887cae6 | ||
|
|
669181c75b | ||
|
|
47e9bb99b0 | ||
|
|
ac6c498615 | ||
|
|
224ea99ce4 | ||
|
|
c54dbfe2a5 | ||
|
|
a1fa40cacf | ||
|
|
48dacbeef3 | ||
|
|
b6e6adbe31 | ||
|
|
64624002bb | ||
|
|
1ccce429a5 | ||
|
|
f832efb0d1 | ||
|
|
67ae53ad3f | ||
|
|
db5b9f6b60 | ||
|
|
2ea1427766 | ||
|
|
b35b327a07 | ||
|
|
a9ff168fd6 | ||
|
|
b436dbed95 | ||
|
|
1cf611d44c | ||
|
|
1e3dd2dd76 | ||
|
|
45322db41a | ||
|
|
2a1d73af25 | ||
|
|
e83c7fdb18 | ||
|
|
587c19c2a5 | ||
|
|
c862ed5806 | ||
|
|
1d79acb083 | ||
|
|
5edf594eac | ||
|
|
94dba571c1 | ||
|
|
de32699539 | ||
|
|
892397589f | ||
|
|
30b753ec40 | ||
|
|
e98a0b7554 | ||
|
|
3541f6baa1 | ||
|
|
e0ee8a50a4 | ||
|
|
8f89a4350a | ||
|
|
cb2b49174e | ||
|
|
6563acef0a | ||
|
|
a053ee77fb |
+203
-1
@@ -7,6 +7,208 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.1.3] - 2026-08-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Sparrow update
|
||||||
|
- Nixpkgs update with bisq and bisq2
|
||||||
|
- Albyhub: re-harvest hashes for fixed sovran tag
|
||||||
|
- Albyhub default.nix hash update
|
||||||
|
- Albyhub default.nix fix
|
||||||
|
- Albyhub: build from sovran fork tag (LND-only, no frontend)
|
||||||
|
- Vendor v1.24.0 LND-only, no-frontend build
|
||||||
|
- Ux: simplify domain and router setup guidance
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Evaluate local vendored package versions directly
|
||||||
|
- Use proxyVendor to preserve secp256k1-zkp cgo headers
|
||||||
|
[1.1.3]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.3
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.2] - 2026-08-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Nixpkgs update with Bisq update
|
||||||
|
- Unify public-IP detection into one privacy-first script
|
||||||
|
- Fix Element X discovery and harden federated calling
|
||||||
|
- Nixpkgs update
|
||||||
|
- Add tor-browser to the package list
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Use pkgs.lndinit, drop local packages/lndinit
|
||||||
|
- Update RTL to 0.15.10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Move system.activationScripts under the config attribute
|
||||||
|
- Reuse Hub external IP for LiveKit, drop egress service calls
|
||||||
|
- Derive Restart required from boot default vs running system
|
||||||
|
- Reconcile completed updates after polling stalls
|
||||||
|
- Migrate i2pd SAM settings for nixpkgs 26.11
|
||||||
|
- Order turn-setup after network-online to fix boot-time red dot
|
||||||
|
- Restore a Zeus-scannable LND REST connect QR
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Prevent LND admin macaroon exposure in curl argv
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Update desktop and Sovran Hub screenshots
|
||||||
|
[1.1.2]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.2
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.1] - 2026-08-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Update release-stable.sh
|
||||||
|
- Enhance release script with preflight checks and metadata
|
||||||
|
- Migrate dock/folder/mime entries from brave to brave-origin on upgrade
|
||||||
|
- Skip Brave Origin startup dialog in Hub launcher
|
||||||
|
- Switch default browser to Brave Origin (brave-origin)
|
||||||
|
- Add Bitcoin Core Tor IBD gossip control
|
||||||
|
- Replace Bitcoin Knots with Bitcoin Core
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Persistent browser profile so logout survives window reopen
|
||||||
|
- Recover from expired sessions and preserve logout
|
||||||
|
- Prevent Bitcoin Core switch from hanging the Hub UI
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Update README ISO download links to v1.1.0
|
||||||
|
[1.1.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.1
|
||||||
|
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Replaced Brave Browser with Brave Origin (the new `brave-origin` nixpkgs
|
||||||
|
package) as the desktop's default browser: installed package, dock
|
||||||
|
favorite, Browsers app-folder entry, `xdg.mime.defaultApplications`, and
|
||||||
|
the `$BROWSER` session variable now all point at Brave Origin, and the
|
||||||
|
Sovran Hub launcher runs it.
|
||||||
|
- On installs upgrading from the old Brave package, the desktop theme init
|
||||||
|
now migrates `brave-browser.desktop` → `brave-origin.desktop` in the dock
|
||||||
|
favorites, the Browsers app folder, and the user's `mimeapps.list`, so
|
||||||
|
the dock icon and default-browser handler survive the switch without a
|
||||||
|
manual re-pin.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Zeus Connect QR in the Hub was invalid: the LND-only `lndconnect`
|
||||||
|
wrapper used flags Zeus/`lndconnect` do not understand (`--cert`,
|
||||||
|
`--macaroon`), read a non-existent onion path (`free/lnd.onion`),
|
||||||
|
and collided with LND's P2P hidden service. The wrapper now matches
|
||||||
|
the nix-bitcoin contract — dedicated `lnd-rest` onion on port 8080,
|
||||||
|
`--nocert` over Tor, admin macaroon in the URI — so scanning the Hub
|
||||||
|
QR in Zeus works again.
|
||||||
|
- The Hub launcher now uses a persistent per-user browser profile
|
||||||
|
(`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp`
|
||||||
|
profile that was deleted on exit. The throwaway profile wiped the
|
||||||
|
`hub_manual_logout` marker cookie on every close, so `/auto-login`
|
||||||
|
silently logged the user straight back in after they signed out and
|
||||||
|
reopened the Hub window. The persistent profile makes explicit logout
|
||||||
|
stick (until the next password login) while still skipping Brave Origin's
|
||||||
|
one-time startup dialog.
|
||||||
|
- The Hub now redirects to the login page when an API request finds an
|
||||||
|
expired browser session, instead of leaving the dashboard tile grid in a
|
||||||
|
loading state while `/api/services` continues returning 401 every five
|
||||||
|
seconds.
|
||||||
|
- Explicitly logging out now suppresses the desktop launcher's local
|
||||||
|
auto-login after the Hub window is closed and reopened. A successful
|
||||||
|
password login clears that preference.
|
||||||
|
- Sessions are now persisted to `/var/lib/secrets/hub-sessions.json` so the
|
||||||
|
browser login survives the Hub service restart that `nixos-rebuild switch`
|
||||||
|
performs during activation. Previously the in-memory session store was
|
||||||
|
wiped by that restart, the `/api/rebuild/status` poll started returning
|
||||||
|
401, and the rebuild modal spun forever showing "Applying changes…" while
|
||||||
|
the switch result was never displayed.
|
||||||
|
- Rebuild and update modals now bail out and reload the page after sustained
|
||||||
|
polling failures instead of hanging indefinitely.
|
||||||
|
- Rebuild/update scripts stream `nixos-rebuild` output into the live log
|
||||||
|
(it was buffered until completion, making long rebuilds look frozen).
|
||||||
|
- The update modal now reconciles persisted update state after a page/RDP
|
||||||
|
reconnect, bounds every status request with a timeout, prevents overlapping
|
||||||
|
async polls, and reports an explicit "status unavailable" state with a
|
||||||
|
Retry Status action instead of spinning forever. Verbose Nix logs are
|
||||||
|
rendered incrementally and bounded so they cannot stall the browser UI.
|
||||||
|
- Successful staged updates now record the exact NixOS generation. The Hub
|
||||||
|
keeps showing "Restart required" until that generation is active, then
|
||||||
|
clears the marker after reboot (with log-based recovery for older updates).
|
||||||
|
- Pending-reboot state is now derived from NixOS itself — the boot default
|
||||||
|
(`/nix/var/nix/profiles/system`) versus the running `/run/current-system`
|
||||||
|
— instead of from Hub-written marker files. Updates performed from a
|
||||||
|
terminal or support session (which never touch the Hub's status files)
|
||||||
|
previously left a stale `REBOOT_REQUIRED` marker the Hub could never
|
||||||
|
clear, pinning the "Restart required" badge forever even after many
|
||||||
|
reboots. The marker self-heals to `IDLE` on the next status read whenever
|
||||||
|
boot default and running system agree, and recording the informational
|
||||||
|
`.generation` marker can no longer fail an otherwise successful update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-08-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Speed up Hub service status loading
|
||||||
|
- Update Documentation
|
||||||
|
- Njalla.nix: fail on ImportError; fix redundant except clause
|
||||||
|
- Correctly escape DDNS placeholder in Nix string
|
||||||
|
- Security hardening: fix all 8 blocking findings for PR #419
|
||||||
|
- Security hardening: fix DDNS injection, Nix injection, reboot auth, support key, sudo rules
|
||||||
|
- Harden btcpay and nbxplorer config handling
|
||||||
|
- Add provenance headers and fix flake checks for PR409
|
||||||
|
- Restore upstream nix-bitcoin fetchNodeModules for mempool and RTL packages
|
||||||
|
- Refactor Nix build configuration for mempool
|
||||||
|
- Update npm dependencies and patch handling in default.nix
|
||||||
|
- Update npmDepsHash for backend and frontend packages
|
||||||
|
- Mempool: use postPatch to copy lock file for npmDeps fetcher
|
||||||
|
- Mempool: use preBuild cd instead of postUnpack so npmDeps fetcher finds lock files
|
||||||
|
- Mempool: fix npmDeps fetcher — postUnpack instead of sourceRoot, postPatch instead of patches
|
||||||
|
- Mempool: switch backend and frontend to buildNpmPackage with placeholder npmDepsHash
|
||||||
|
- Update npmDepsHash with correct hash value
|
||||||
|
- RTL: switch to buildNpmPackage with placeholder npmDepsHash
|
||||||
|
- Add bitcoind.rpc.users.btcpayserver for NBXplorer
|
||||||
|
- Restore original flake.nix with nixosModules.Sovran_SystemsOS export
|
||||||
|
- Add vendored RTL package and fix rtl.nix to use it
|
||||||
|
- Vendor mempool packages and wire mempool module to vendored pkgs
|
||||||
|
- Refactor onion service configuration for LND
|
||||||
|
- Add missing bitcoind-rpc-public-whitelist.nix
|
||||||
|
- Remove joinmarket-ob-watcher from onion-services defaults
|
||||||
|
- Tailor: make bitcoin modules truly Sovran-only (lnd-only) and delete stubs.nix
|
||||||
|
- Vendor: replace nix-bitcoin flake input with minimal vendored modules (nixpkgs-only)
|
||||||
|
- Remove duplicate 1.0.6 release notes from CHANGELOG
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Clean mempool module comment typo
|
||||||
|
- Move vendor/nix-bitcoin to modules/bitcoin, remove overlays
|
||||||
|
- Nix flake update - drop nix-bitcoin
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Correct RTL and Mempool Hub versions
|
||||||
|
- Fix DDNS URL validation: replace ${IP} temporarily for validator, keep placeholder for storage
|
||||||
|
- Fix all 8 security hardening blockers for PR #423
|
||||||
|
- Fix Nix interpolation in DDNS runner
|
||||||
|
- Fix IP validation in DDNS and document journalctl sudo rule
|
||||||
|
- Report configured BTCPay Server version
|
||||||
|
- Fix Matrix SIGPIPE and RTL v0.15.8 config schema regressions
|
||||||
|
- Add NBXplorer cookie auth and WorkingDirectory for BTCPay service
|
||||||
|
- Fix BTCPay startup regression: add home dirs to service users
|
||||||
|
- Address btcpay hardening review feedback
|
||||||
|
- Register bitcoin-HMAC-btcpayserver as managed secret owned by bitcoind user
|
||||||
|
- Fix RTL: use fetchNodeModules instead of npm ci in buildPhase
|
||||||
|
- Fix PostgreSQL ensureUsers: use ensureDBOwnership instead of ensureClauses
|
||||||
|
- Fix lnd macaroons: replace invalid 'enable' with 'user'
|
||||||
|
- Fix postgresql ensurePermissions -> ensureClauses for nixpkgs unstable
|
||||||
|
- Fix btcpayserver.nix to use pkgs.stable overlay
|
||||||
|
- Fix btcpayserver syntax error and pin to nixpkgs-stable (2.4.2)
|
||||||
|
- Fix mempool package: remove fetchNodeModules dependency
|
||||||
|
- Fix typo in onion-addresses service check
|
||||||
|
- Fix formatting of extraGroups in btcpayserver.nix
|
||||||
|
- Add missing semicolon after extraGroups in btcpayserver.nix
|
||||||
|
- Remove services.clightning.enable assignment that fails on f13ff45
|
||||||
|
- Remove clightning and clightning-rest from stubs to avoid duplicate on f13ff45
|
||||||
|
- Add stubs for nixpkgs-unstable 2026-08 where services.clightning removed
|
||||||
|
- Keep ISO artifacts out of the repo and auto-update README on release
|
||||||
|
[1.1.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.0
|
||||||
|
|
||||||
|
|
||||||
## [1.0.6] - 2026-08-07
|
## [1.0.6] - 2026-08-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -160,7 +362,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Integrated Sparrow Wallet, Bisq, and Bisq 2
|
- Integrated Sparrow Wallet, Bisq, and Bisq 2
|
||||||
- Comprehensive Sovran Hub for service management
|
- Comprehensive Sovran Hub for service management
|
||||||
- NixOS-based operating system with privacy and sovereignty focus
|
- NixOS-based operating system with privacy and sovereignty focus
|
||||||
- Support for Bitcoin Knots + BIP110, Electrs, LND, Ride The Lightning, BTCPay Server, and more
|
- Support for Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and more
|
||||||
- Server + Desktop hybrid mode with Matrix, Nextcloud, VaultWarden, and other self-hosted services
|
- Server + Desktop hybrid mode with Matrix, Nextcloud, VaultWarden, and other self-hosted services
|
||||||
- Automated installer with graphical GNOME desktop
|
- Automated installer with graphical GNOME desktop
|
||||||
- Tor integration and onion services for all major components
|
- Tor integration and onion services for all major components
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ Lightning infrastructure, private cloud, and communications platform when you
|
|||||||
are ready.
|
are ready.
|
||||||
|
|
||||||
[Visit the Website](https://sovransystems.com) ·
|
[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.3.iso) ·
|
||||||
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
[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.3.iso.sha256) ·
|
||||||
[Build from Source](#build-from-source)
|
[Build from Source](#build-from-source)
|
||||||
|
|
||||||
<img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||||
|
|
||||||
*Bitcoin sovereignty from the first boot.*
|
*Bitcoin sovereignty from the first boot.*
|
||||||
|
|
||||||
@@ -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
|
- **Buy and sell Bitcoin peer-to-peer** with Bisq and Bisq 2. No central
|
||||||
company holds user funds, and no exchange account stands between buyers and
|
company holds user funds, and no exchange account stands between buyers and
|
||||||
sellers.
|
sellers.
|
||||||
- **Verify your own Bitcoin** with a full node: [Bitcoin
|
- **Verify your own Bitcoin** with a full node: [Bitcoin Core](https://bitcoin.org) and
|
||||||
Knots](https://bitcoinknots.org) and
|
|
||||||
[Electrs](https://github.com/romanz/electrs), so your wallets connect to
|
[Electrs](https://github.com/romanz/electrs), so your wallets connect to
|
||||||
*your* node instead of a stranger's.
|
*your* node instead of a stranger's.
|
||||||
- **Use Lightning** with [LND](https://github.com/lightningnetwork/lnd) and
|
- **Use Lightning** with [LND](https://github.com/lightningnetwork/lnd) and
|
||||||
@@ -150,13 +149,13 @@ presents and manages the features available on your system.
|
|||||||
|
|
||||||
### Your money — Bitcoin sovereignty
|
### Your money — Bitcoin sovereignty
|
||||||
|
|
||||||
- Bitcoin Knots, Electrs, and Tor integration
|
- Bitcoin Core, Electrs, and Tor integration
|
||||||
- LND and Ride The Lightning, with [Alby Hub](https://albyhub.com) for Nostr
|
- LND and Ride The Lightning, with [Alby Hub](https://albyhub.com) for Nostr
|
||||||
Wallet Connect (NWC) connections
|
Wallet Connect (NWC) connections
|
||||||
- BTCPay Server
|
- BTCPay Server
|
||||||
- Sparrow Wallet, Bisq, and Bisq 2, with automatic wallet-to-node connections
|
- Sparrow Wallet, Bisq, and Bisq 2, with automatic wallet-to-node connections
|
||||||
- Optional: a self-hosted [Mempool](https://github.com/mempool/mempool)
|
- Optional: a self-hosted [Mempool](https://github.com/mempool/mempool)
|
||||||
explorer, or Bitcoin Core in place of Knots
|
explorer
|
||||||
|
|
||||||
Run your own Bitcoin infrastructure. Verify your own money. Trust no one.
|
Run your own Bitcoin infrastructure. Verify your own money. Trust no one.
|
||||||
|
|
||||||
@@ -186,7 +185,7 @@ hardware you control.
|
|||||||
### Your desktop
|
### Your desktop
|
||||||
|
|
||||||
- [GNOME](https://www.gnome.org) desktop
|
- [GNOME](https://www.gnome.org) desktop
|
||||||
- [Brave](https://brave.com) and
|
- [Brave Origin](https://brave.com/origin/) and
|
||||||
[Firefox](https://www.mozilla.org/firefox) browsers
|
[Firefox](https://www.mozilla.org/firefox) browsers
|
||||||
- File management, email, calendar, and office applications
|
- File management, email, calendar, and office applications
|
||||||
- System monitoring and administration tools
|
- System monitoring and administration tools
|
||||||
@@ -202,7 +201,7 @@ Bitcoin and self-hosting infrastructure runs on the machine.
|
|||||||
| Mode | Best for | What you get |
|
| Mode | Best for | What you get |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
|
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
|
||||||
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Knots, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
|
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
|
||||||
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services |
|
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services |
|
||||||
|
|
||||||
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
|
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
|
||||||
@@ -282,6 +281,10 @@ From one place, the Hub helps you:
|
|||||||
- Reach your Bitcoin tools, private cloud, and communications
|
- Reach your Bitcoin tools, private cloud, and communications
|
||||||
- Perform supported system operations without everyday terminal commands
|
- Perform supported system operations without everyday terminal commands
|
||||||
|
|
||||||
|
<img src="assets/sovran-hub-screenshot.webp" alt="The Sovran Hub dashboard" width="800" />
|
||||||
|
|
||||||
|
*The Sovran Hub: manage your private infrastructure from one place.*
|
||||||
|
|
||||||
### Example home setup
|
### Example home setup
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -342,8 +345,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
|||||||
|
|
||||||
### 1. Download the ISO and checksum
|
### 1. Download the ISO and checksum
|
||||||
|
|
||||||
- [Download Sovran_SystemsOS-1.0.6.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso)
|
- [Download Sovran_SystemsOS-1.1.3.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.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.3.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso.sha256)
|
||||||
|
|
||||||
The download may take some time. Do not rename or modify the ISO before
|
The download may take some time. Do not rename or modify the ISO before
|
||||||
verifying it, and keep both files in the same folder.
|
verifying it, and keep both files in the same folder.
|
||||||
@@ -361,16 +364,16 @@ checksum exactly.
|
|||||||
Open a terminal in the download folder and run:
|
Open a terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sha256sum --check Sovran_SystemsOS-1.0.6.iso.sha256
|
sha256sum --check Sovran_SystemsOS-1.1.3.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
A successful comparison reports:
|
A successful comparison reports:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Sovran_SystemsOS-1.0.6.iso: OK
|
Sovran_SystemsOS-1.1.3.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.3.iso` and compare the output
|
||||||
against the checksum file manually.
|
against the checksum file manually.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -381,11 +384,11 @@ against the checksum file manually.
|
|||||||
Open Terminal in the download folder and run:
|
Open Terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
shasum -a 256 Sovran_SystemsOS-1.0.6.iso
|
shasum -a 256 Sovran_SystemsOS-1.1.3.iso
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value shown in Terminal with the value inside
|
Compare the value shown in Terminal with the value inside
|
||||||
`Sovran_SystemsOS-1.0.6.iso.sha256`.
|
`Sovran_SystemsOS-1.1.3.iso.sha256`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -395,7 +398,7 @@ Compare the value shown in Terminal with the value inside
|
|||||||
Open PowerShell in the download folder and run:
|
Open PowerShell in the download folder and run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Get-FileHash .\Sovran_SystemsOS-1.0.6.iso -Algorithm SHA256
|
Get-FileHash .\Sovran_SystemsOS-1.1.3.iso -Algorithm SHA256
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value under `Hash` with the published checksum.
|
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
|
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||||
connect the USB drive.
|
connect the USB drive.
|
||||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.0.6.iso`.
|
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.3.iso`.
|
||||||
3. Choose **Select target**, select the USB drive, and review your selection
|
3. Choose **Select target**, select the USB drive, and review your selection
|
||||||
carefully.
|
carefully.
|
||||||
4. Choose **Flash** and wait for the writing and verification process to
|
4. Choose **Flash** and wait for the writing and verification process to
|
||||||
@@ -507,13 +510,15 @@ setup, supported hardware, and Royal Membership.
|
|||||||
|
|
||||||
## For developers
|
## For developers
|
||||||
|
|
||||||
Sovran_SystemsOS combines [NixOS](https://nixos.org), an in-repository
|
Sovran_SystemsOS combines [NixOS](https://nixos.org), the standalone
|
||||||
Bitcoin and Lightning stack, desktop packages from
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) flake for
|
||||||
|
Bitcoin and Lightning, desktop packages from
|
||||||
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
||||||
Sovran Hub. The Bitcoin modules under `modules/bitcoin/` were adapted from
|
Sovran Hub. The Bitcoin and Lightning modules live in the Sovran_Bitcoin
|
||||||
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) and are now maintained
|
repository and are consumed as a flake input. OS-specific Bitcoin customizations
|
||||||
here. Builds no longer import or fetch nix-bitcoin. Legacy `nix-bitcoin.*`
|
(Second_Drive paths, operator user, Hub integration) are bridged by
|
||||||
option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
`modules/sovran-bitcoin-integration.nix`. Legacy `nix-bitcoin.*` option names
|
||||||
|
and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||||
|
|
||||||
### Development workflow
|
### Development workflow
|
||||||
|
|
||||||
@@ -533,8 +538,9 @@ option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
|||||||
|
|
||||||
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
||||||
for declarative, pinned system configuration
|
for declarative, pinned system configuration
|
||||||
- `modules/bitcoin/` for the in-repository Bitcoin and Lightning stack
|
- [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) for the
|
||||||
- `packages/` for Sovran-maintained package definitions and patches
|
Bitcoin and Lightning stack (consumed as a flake input)
|
||||||
|
- `modules/sovran-bitcoin-integration.nix` for OS-specific Bitcoin overrides
|
||||||
- [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the
|
- [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the
|
||||||
Sparrow, Bisq, and Bisq 2 packages
|
Sparrow, Bisq, and Bisq 2 packages
|
||||||
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
|
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
|
||||||
@@ -625,12 +631,11 @@ sudo nixos-rebuild switch --rollback
|
|||||||
| `flake.lock` | Pins dependencies for reproducible builds |
|
| `flake.lock` | Pins dependencies for reproducible builds |
|
||||||
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
||||||
| `modules/` | Core modules, self-hosted services, and optional features |
|
| `modules/` | Core modules, self-hosted services, and optional features |
|
||||||
| `modules/bitcoin/` | In-repository Bitcoin and Lightning service modules |
|
| `modules/sovran-bitcoin-integration.nix` | OS-specific Bitcoin overrides (Second_Drive, operator, Hub wiring) |
|
||||||
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
||||||
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
||||||
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
||||||
| `iso/` | Installer configuration, installer code, and installer assets |
|
| `iso/` | Installer configuration, installer code, and installer assets |
|
||||||
| `packages/` | Sovran-maintained package definitions and patches |
|
|
||||||
| `tests/` | Security and Nix integration checks |
|
| `tests/` | Security and Nix integration checks |
|
||||||
| `assets/` | Documentation images |
|
| `assets/` | Documentation images |
|
||||||
| `custom.template.nix` | Template for local features and service overrides |
|
| `custom.template.nix` | Template for local features and service overrides |
|
||||||
@@ -686,15 +691,12 @@ rebuilds the machine into the selected declarative state.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Core platform: roles, Hub, desktop integration, Caddy, domains, support, remote deployment | `modules/core/` |
|
| Core platform: roles, Hub, desktop integration, Caddy, domains, support, remote deployment | `modules/core/` |
|
||||||
| Shared credentials | `modules/credentials.nix` |
|
| Shared credentials | `modules/credentials.nix` |
|
||||||
| Bitcoin and Lightning stack | `modules/bitcoinecosystem.nix` |
|
| Bitcoin and Lightning stack | [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) flake input + `modules/sovran-bitcoin-integration.nix` |
|
||||||
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
||||||
| Optional Bitcoin Core in place of Knots | `modules/bitcoin-core.nix` |
|
|
||||||
| Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` |
|
|
||||||
| Matrix Synapse | `modules/synapse.nix` |
|
| Matrix Synapse | `modules/synapse.nix` |
|
||||||
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
||||||
| Optional Haven Nostr relay | `modules/haven.nix` |
|
| Optional Haven Nostr relay | `modules/haven.nix` |
|
||||||
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
|
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
|
||||||
| Optional Mempool explorer | `modules/mempool.nix` |
|
|
||||||
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
|
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
|
||||||
|
|
||||||
Feature availability and defaults may change as Sovran_SystemsOS develops.
|
Feature availability and defaults may change as Sovran_SystemsOS develops.
|
||||||
@@ -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:
|
Sovran_SystemsOS also depends on the work of the developers and communities behind:
|
||||||
|
|
||||||
- [Bitcoin Knots](https://github.com/bitcoinknots/bitcoin)
|
|
||||||
- [Bitcoin Core](https://github.com/bitcoin/bitcoin)
|
- [Bitcoin Core](https://github.com/bitcoin/bitcoin)
|
||||||
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
|
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
|
||||||
- [Bisq](https://github.com/bisq-network/bisq)
|
- [Bisq](https://github.com/bisq-network/bisq)
|
||||||
@@ -855,7 +856,7 @@ primary location for collaboration. Please read our
|
|||||||
## Privacy. Sovereignty. Bitcoin.
|
## Privacy. Sovereignty. Bitcoin.
|
||||||
|
|
||||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso) ·
|
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso) ·
|
||||||
[View the License](LICENSE)
|
[View the License](LICENSE)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+7
-5
@@ -35,12 +35,14 @@ external networks and are outside a “fully offline” model.
|
|||||||
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
||||||
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
||||||
|
|
||||||
### In-repository Bitcoin stack
|
### Bitcoin stack
|
||||||
|
|
||||||
Bitcoin and Lightning modules are maintained under `modules/bitcoin/`. They were
|
Bitcoin and Lightning modules are maintained in the standalone
|
||||||
adapted from nix-bitcoin, but Sovran builds do not import or fetch nix-bitcoin.
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||||
The `nix-bitcoin.*` option namespace and `/etc/nix-bitcoin-secrets` path remain
|
and consumed as a flake input. OS-specific customizations (Second_Drive paths,
|
||||||
only for upgrade compatibility.
|
operator user, Hub integration) are bridged by
|
||||||
|
`modules/sovran-bitcoin-integration.nix`. The `nix-bitcoin.*` option namespace
|
||||||
|
and `/etc/nix-bitcoin-secrets` path remain only for upgrade compatibility.
|
||||||
|
|
||||||
### Supply chain and integrity
|
### Supply chain and integrity
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
## nix-bitcoin
|
## nix-bitcoin
|
||||||
|
|
||||||
Portions of `modules/bitcoin/` and selected package definitions were adapted
|
The Bitcoin and Lightning modules were originally adapted from
|
||||||
from [fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
[fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
||||||
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||||
The current Sovran build does not import or fetch nix-bitcoin.
|
They now live in the standalone
|
||||||
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||||
|
and are consumed by Sovran_SystemsOS as a flake input. The current Sovran build
|
||||||
|
does not import or fetch nix-bitcoin.
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
<svg width="256" height="256" version="1.1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<image width="256" height="256" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAABmJLR0QA/wD/AP+gvaeTAAAgAElE
|
|
||||||
QVR4nO2dd3gU1frHv+9sTy9AKh2RIqh0kHJFpVoQO1IURa6CCFak6CrFdr1KU1HUa8NKEREQRQUR
|
|
||||||
ASlSlR5KEkpIQtr2Ob8/Aj9Isrszuzu7s7s5n+fJo+zOnHmTmfOdU94CcDgcDofD4XA4HA6Hw+Fw
|
|
||||||
OJyohtQ2gBM4zAytNTWtPkiozxjqChDqMiCVIKYyJtQBiakA6QGYiGAEAMaQAJDmfAsuIpSc/9wK
|
|
||||||
wALABrCzBJxlEM4SEwtE4AwJKICDHTOeO3mCzHCq9CtzFIILQITAvoLGdjqrqSiiLQmsFYDGYKwR
|
|
||||||
iBqBIRuANsQmOUE4AcZyQJQDRocZw98Cox2GohOHyAwxxPZw/IALQBjCzK31lpTidgKJnRmoDYAr
|
|
||||||
AbQCEKOyaXKpAKM9ANtBwC4RtNlkSN9Ko7c61DaMUxUuAGEAm52SYIOxEyN0B3DN+R+TymYpjYMI
|
|
||||||
O0XgdzBa79SzXxJG5xWobVRthwuACrCvoKnIz7iKNHQ9MdwEoCsAQW27VGAvMXwH4CeDIWMtHyGE
|
|
||||||
Hi4AIaL4rQbJBtF1M0Q2gAg3MCBZbZvCjEIAq0FshdVh+C55Qk6x2gbVBrgABBE2PzPGZsN1IAxj
|
|
||||||
wC0A9GrbFCHYGPAjMfraCMtSGldYorZB0QoXAIVh5tZ6S2rxLQLYEAb0Ayq33Th+YyXQChHiQpM+
|
|
||||||
cxmfJigLFwCFsM7Nag6RjWSE+wHUU9ueaISAIgb6WhCdcwyPndqttj3RABeAAGCzmxmsKL8dJIwC
|
|
||||||
WE/wv2eoYABbC9B7xrPJ35B5j11tgyIV/sD6AZudkmAjw/0M9CSAbLXtqeWcAvCO3SHMTnz8RKHa
|
|
||||||
xkQaXAB8wDInrTEjzXhieABArNr2cKpQBtBCIrxuHJu7X21jIgUuADKwzclsIQLPA7gDgEZtezhe
|
|
||||||
cYHwBYFe5EIgDRcAL1jeSG8EreZZgI1E6H3tOYEhErAIgjDJOObEQbWNCVe4ALjBMjejIUSaAsII
|
|
||||||
ADq17eEEhAOgD5nApseMyTuutjHhBheAS2CvpcVajZqnADwDvn8fbdgZ4R2TaJ3KHYsuwgUAAGMg
|
|
||||||
65ysYSD2MoAMte3hBJU8IvaCoSB/AQ9Z5gKAijmZ1xDwJoAOatvCCSVsM2PCYzHjcjeqbYma1FoB
|
|
||||||
YPObJFoc1heJYSxqZyQeB2Bg9KndSeNrqw9BrRQA69ysGxljb4M78XAqyWdgj8Y8mr9IbUNCTa0S
|
|
||||||
gLJ5ddM1Lv2rIDZMbVs44QcDlkMr/Dvm4RO5atsSKmqNAFTMzbpTYOwdHofPkaCQMRodMy73G7UN
|
|
||||||
CQVRLwDslTrx1hjDfwD2kNq2cCIIRp8YNfZHaMyZMrVNCSZRLQAVs7M7EYmfAWimti2cCISQwxiG
|
|
||||||
xjya97vapgSLqBQAZoZgTc2aDLDnwF14OYHhALHnjWPyXyYCU9sYpYk6AagM1TV+xIBBatvCiSq+
|
|
||||||
t2m0w5IeOVaktiFKElUCYHsru63oEhcDaKq2LZyo5KAAYbDh0RO71DZEKaLGAaZiduY9okvcAN75
|
|
||||||
OcGjmQhxk2Vu1gi1DVGKiB8BMDMEW2rmSwx4Wm1bOLUJesk4NndypK8LRLQAsNnNDFaq+BDAPWrb
|
|
||||||
Eu6cLQdmrBFRZgNeGSggOVKKjIUxBFpkcNAwevyERW1b/CViBaBkblaqjrElAHqobUu4s3Q3g/kH
|
|
||||||
EcXnH9N6cYTXbyb0aBKxtz+c2Ohk4s3x406eUdsQf4jIJ8A6u35TRq4VAJqrbUs4Y3EAz60S8fWO
|
|
||||||
mqNUIuCBzgIm9iZoo2YlSDUOkQsDjePz9qltiK9EnACUz81sJ4j4AYQ6atsSzhwrAh76WsQ/p71P
|
|
||||||
Ua+7jDB3sAATz3sUKKddAvWNG5P7l9qG+EJECUD5vPSOgiisApCiti3hzM48YOSXLhSUyzu+bSbw
|
|
||||||
v7s1SOHrAgHBgGIQDYgZm/uH2rbIJWIEwDI3oydAy8EQr7Yt4czvRxhGfSWiwscCWk1Tgc+GapDO
|
|
||||||
/7qBUk4Mg4zj8n5S2xA5RMTszzonvT8YreKd3zt/5TI89LXvnR8ADp0Fhi904Zw1one1woFYRlhu
|
|
||||||
m5t5i9qGyCHsBcA6J70/g7AUgEltW8KZAwXA8M9FlAdQJGv/GWDsYgaHi4tAgBhEhq+s8zL6qW2I
|
|
||||||
FGEtABVzMq9hEL4GL6vtlTI78PA3LpRYA2/rt8MMk1dwAVAAPRNpsWVO9r/UNsQbYSsAFbOzuhBh
|
|
||||||
JXgJLkmeXCbiYIH0cckxQPO60sd9tYNh0U4uAgpgAsTllnmZ3dU2xBNhKQD22VlXErHv+ZxfmqW7
|
|
||||||
GVb9I91Z4w3Ax/do8PlQDZqkSq/9vrCa4WQJFwEFiIWI5eVzM9upbYg7wk4ArHOzmrvAfgLf6pOk
|
|
||||||
2AK8uFo6tb1WAObfIaBNBpAaCyy4S0CcxKSqxMrw3A9cABQiUWBYaZ2XHXaJacJKAErmZqUyxpZz
|
|
||||||
Jx95vP6riMIK6eOevpbQrdHFt36TFOCVG6Vv/ep9DCtljC44sqjHRHFV6ex0GZOw0BE2AsA+bGTU
|
|
||||||
gS0DcJnatkQCJ84BX2yX7py9mxFGdal5mwe2ItzWVvr2v/ozg7PW189RjKZaCIvZh43CpuxcWAgA
|
|
||||||
YyBrmf19MHRT25ZIYdY6EQ6JjhmnB2YMEEAepvxTrifJqMAjhQzf7uajAMUgdLeV2T9m5vDoe2Fh
|
|
||||||
hG1u5ssAhqhtR6RQUA4s3SXdKR/rSchI8Px9cgwwoaf0I/DBZi4ASsKAO6ypWdPVtgMIAwGomJ15
|
|
||||||
D0/m4Rtf/sUk3/7JMcDQ9tK39552hOxE78fsOcmw7QQXAWVhz1pmZw1X2wpVM+aez+H3npo2RCIi
|
|
||||||
YxjSzvtWXpeGJCvCTycAD3UV8Nwq74qybA9Du+yICR2JDIi9ZZuVts3w2Kndqpmg1oWL32qQbHA5
|
|
||||||
/wTP4ac6pTag6ywXyry4EdeLI2x6zPN6AsdvDlid+k7JE3KK1bi4KlMAZoZgcDk/Ae/8YUG8Aejf
|
|
||||||
0nvPPl3G8LdEbgGOX1xm0Km3KKjKRSuLdmCgGtfmuKdfC+lX+4YjXACCATHcZE3NVGUdLOQDuvNJ
|
|
||||||
PX4HENE5aDYdQ8RHzXWoTzCeXwWyOYEr/+OC1en5+FuuIMwadPGdYXEAh88yxBsIqbFALA/ZCgQn
|
|
||||||
I6F7zNgTm0J50ZAuArJ5deOsovAZIrzzA5XRd3K88MKZ9+8ScN1lle8AgxZok0n485hnUfv7VNXv
|
|
||||||
Sm0MAxdcXDxMiQEapRBapxOuymToUJ/QMJkvGshES0z8jL1S52p6pqA0VBcN6RTAKmrnIUo8/d4c
|
|
||||||
JCDBGNkP9x9Hq/77qkzvv8/RIoBdogH14qjK36CwAth2guGTLSKeWMbQa56I69524ZWfRew9Fdmj
|
|
||||||
pRDR1BqjfyOUFwyZAFTMybgNINX3PZWiZxPC1yMEyT30cGZ7tb39xhLhVzYnaox6GiZ779iHzgJv
|
|
||||||
b2AY8J6IQR+K+GantA9DLeeBijmZd4XqYiERgPI3GmYIoKjb77+8LvD1CA3qJ6ltiX/knqvaebNk
|
|
||||||
iFlBNQFIjZU/Cvorl+HJZSL+Nc+Fhdt45iFPEPBW2Zv10kJxrZAIgEbrnMuA5FBcK9RkJACfDBFQ
|
|
||||||
Ly7ypgMF5YDrkrdxSoz072CxV+20SX6EteSeAyatENH3XYb1fGfBHSkajXZWKC4UdAGwzs26kYEN
|
|
||||||
DvZ11KRRCmHuYIJGdcdq33AxVHHsEUi6M9pcVUVCr/H/+ofPMgxbKGLSChEWPxKZRjl3hSKxaFAf
|
|
||||||
WTa/SSJj7J1gXiNc6NSAMOaayFIAgwYQLunPLlF6BKAVqopEoPN5xoCF2xhu+cCFAzLSmtUmRIa3
|
|
||||||
it5oFNQJZlCfWKvd+hqArGBeI5wYcw1FVF79RFPVDn+mXHoEoNdUPcfmxW/AF/afAW77n4gNOXxK
|
|
||||||
cAmZBo09qFGDQROAijmZ1wB4MFjthyMGLTC2e+SsBbSqtsyULyMHYPX8AWdliIZcSqwMIxaKPAvR
|
|
||||||
JRDh4Yq52Z2D1X5QBICZIRCjNxBBlYeUYnBbATER4ubUttq+/z+nvd8uIqButRzNueeUtckhAuOW
|
|
||||||
MPxykIvAeQRi4izGgtOXguIJaE3NHAGwjsFoO9yJ0QG9mhFW/i3/AW6VTljxoPrrBzvzvU/oU2Iq
|
|
||||||
RzmX4s112F8cLoYxixi+HqFB63Tl249AOlvnZtwL5H+qdMOKP3XslTrxAGYo3W4kcWkCTjmEwzCp
|
|
||||||
xArsOen9mBb1alq6ebwGK0cJMPcV0LUhVVlUDIQKB/DIIhElvFTZeehl9lqa4jUyFBcAm0k/GUCG
|
|
||||||
0u1GEm0i8K219hCr4hPgjsvr1fxMIKBlGuG+joTPhwlY/6gGj/ciJClQyO1oEcOUlVwAzpNlNWqe
|
|
||||||
UbpRRQXAMjejISM8pmSbkchldX17DYbDI75URuLPDjIyAmUmAON6CPhtrAbje1LAEYLL9jD8zp2F
|
|
||||||
LvBkxeysbCUbVHYNgNFUAGGT8lgtYvWVP3ILdfoiFw4XwwurK9OCeWNibwEJMu/E6TKGtYe8tycQ
|
|
||||||
0LWhfEvjDcD4ngJubs0wbomI3RLTC2+YfxCx6iFNxDlaBQETkTgZwMNKNaiYAFhn12/K4IqaYJ9A
|
|
||||||
qRNLKLfLe3P58n7TaQjbc0XsOen9rMvqMtzfUV6H/XgLJHP/t8mQTiHujiaphCUjNXh2eWUgkD8c
|
|
||||||
KABW72foLyNpSfRDD1jfSnvN+Mipw0q0ppimMojPIwri/JXCl6Gvr491zybSx3y2VYQoo7+V2oCP
|
|
||||||
t0gfeHNrGYZ5QCcAr94k4JYr/O/Ab2/gIYTn0TFRM1mpxhQRAOvcrOYgdo8SbUULugB85KUY0FL6
|
|
||||||
th0sAFbI2Iqc/weTXGnXCMBAiZyBUggEvH6zgM4N/Dt/Zx5kVUCuFTCMsL6ZebkSTSkiAIyxF6Fy
|
|
||||||
ivFwQ6+RP9z1dWDcJgNoJqN64n/Xeg+5zT0HLNgo/Wa9oTkhPSHw4bdWAF6/RVPDl0Auq/fxxcDz
|
|
||||||
aJgWU5VoKGABsMxJawzgdgVsiSp82Q/3p2vdeaX0WYfPMizwkmHuhdWiLEeeBzsrN/fOTpRXsMQd
|
|
||||||
aw5wAfh/GO62vpUmYzLonYAFgEHzOIAgDng57rj7apIs8Q1U1hA8Uliz46w9xGS9UTs1qEweqiTD
|
|
||||||
2vt33p6T0r4KtQgNnNpHA20kIAEofqtBMgH3BWoEx3cSjIS720nfPqsTGLdEhN1V9fPujQmDZCzK
|
|
||||||
PdNb+b23RikkawpTHasTyClS3JyIhREbde6/2RKJ3LwT0N01uFyPAIgLpA2O/zzSjRBvkD5uVz4w
|
|
||||||
48eqr06NAPznZgEDvCzu9WtBaB+kcmBSCUg94W40U4uJ1enFUYE04LcAsNnNDAAbE8jFOYGREgOM
|
|
||||||
6iKvI320pTJb76VoBWDWIHJbFShOD5j7Bs/zxt88iiUWZe2IdIhhHDO39tvf0u87bEX57ajlPv/h
|
|
||||||
wOiuAhqnyBOBF1Yz/FRtIU2nIcy9VcDIagt9T/UWgprcxF8X4RKbsnZEAZmWlKLb/D3Zf4knesjv
|
|
||||||
czmKYdAC0/qTrKKdThEYs0jEusNVRUAjAM/dIODlgQJ0GkKvpoTh7YPrdeetEKk3qqck4wBE/ife
|
|
||||||
8UsArHOzmgPo4e9FOcrSvTFhqES58AvYnMDor0W3W2p3X034eAjh9ZuDXwX4lJ+1b5JN3B3YDdda
|
|
||||||
Z2X7VXDHvxEAY6MQHmHsnPNMvkHAZTJX1i2OShH4fHtNEejakFBH8ajzmuT7KQCJCoQZRyHEBHGk
|
|
||||||
Pyf6LADM3FrPAB70E2YYtcD8OzWyIwCdIvDs9yJe/lmERGCh4jhcDFuP+3fRFC4AnrifzW/vcyyO
|
|
||||||
zwJgSS2+BYCb1BActWmSAswbLEDrw119ZwPDk9+xoKT28sS2XEKpH4t5eg3QzMdcC7WINIsj70Zf
|
|
||||||
T/J9CsDYvT6fwwkZPZoQpvbx7bYu2inixgWhy8v/zQ7/3PmuygqsEEm0IwBD/DhHPmx2SgIR+vp6
|
|
||||||
EU5oGdGBMMzHVfyDBcDgD10+JTP1h7wSYOku/67RqQHPCOINxmgAm1fXJ8c8n/6iVpgGgWf8iQhe
|
|
||||||
6Cfgtra+dZhSG/DIYhFTVzFUBKlU18yfRL+rCfVvoawtUUiMxaW7yZcTfHtCiN3p0/GckHGqlFXZ
|
|
||||||
WxcIePVGwq1tfMxPyIBPtojoO9+FzceUHQ0s2cWwfK9/bXZuALRO5/N/KQSCT31UtgCcr1F2g88W
|
|
||||||
cYKOUwTGLmEY9aVYpVSXRgBeu0nAwFa+d5zjxcCQT0X851cWcP0/oDLl+HOr/G/ovo58+C8HBvRn
|
|
||||||
85vIKPReiey/qlHnuAVAgDleOcHgP7+K+PMYwx9HGR5dIlbJ71fp7+9fOi6nCMxdL+KmBYEl9Txc
|
|
||||||
CAz/3OXXyj9QGZLcl+cDlIvBarPI3g2QL6siG+CXOZygsisfeG/jxWH16n0MTy8Xq8TNawXgzVsE
|
|
||||||
PCQzcKg6/5xmGPSBCy//XDOsWIqdecCdH7lwttyvS0OvAWYO0ChWcKRWIFB/2YfKOYh9BQ0I1/tv
|
|
||||||
EScYuETgme/FGkkyFu9keGypWCUdGBEw6XoBz/Xxz83XKVb6DNy4wIWdefLOWb2P4e5PXCjws/MD
|
|
||||||
lTb7kzugViOiLzPL69uyDrKcyuwCIKDEAxzlWbKbYa+H9ODL9zI89HVNB5+RnQiv3lgZ9OMP+88A
|
|
||||||
g//nwuZjno9xuBjeXMfw72/EgHYThneorDjE8RFCnYq66bLyLskSACLWLzCLOErjEIE31npfVPvl
|
|
||||||
IMOIha4akXd3XElYeC8hxY88/ykxwFu3CejkIbvv4bMMgz5keHOdvLTknri1DeGFIOYjiHYEJsia
|
|
||||||
BsgTAMifU3BCw/I9TFZp7rwSuPX179iAsOR+34bXPZsQVo4S0Ody92/lRTtF3PS+dNESKe7vGJqI
|
|
||||||
xKiGQdZLW1IASuZmpTKGqwO3iKMkH8ko5nFhB8BT2rCGyYTF92nQvbH3nmbSAdP6C/joHgFp8TWP
|
|
||||||
LawAHv5GxBPLmOxyaO7QCMBzfQQ831fgi36B00nOdqCkAOhF1k3OcZzQcbgQ+CtXWgDGdBfQTiKn
|
|
||||||
X4IR+OgewWMZsaapwOL7BAxr7z7pyJoDDP3eFbHyn8De+unxwOdDNRjZifd8hdDYnJauUgfJKdFw
|
|
||||||
jQLGcBRk2W5ph5r0BMLD3eR1Jo0APN9XQKNUhmmrL/oRDG5LmNFfgMlNkGmxBZi6SsR3ewL3Fuzb
|
|
||||||
onJhMpE7mSsKY7gGwCpvx0gKAOMCEHb8clD6mPE9CEYfK/CM6EBokiJgzGIRIzsJGN/TvYCsOcAw
|
|
||||||
8XsRZ8p8a786KTGViUdvbs3f+sGBJPuu10eEmVvrrVTkZxkHTjAos0NykS0rEbhdRuUgd/RoQvhj
|
|
||||||
nMZt0k67C3jpJxH/28ICSiJCBAy6gvDcDYJfFYc5MmHowsyt9WTe43FlxqsAWOoUtycGnoMljNhz
|
|
||||||
UrqU96AryKekINVx1/kPFwKPLg58hb9JSmWkYo8m/K0fAkwVdc9eCeBPTwd4FQCBiZ0YT/0XVuw/
|
|
||||||
I90Bb7lC2TXbRTtFPLcqsBX+GB3wUFcBY7oTdHxJOWRoXEJn+CsAjKitz6VrOUElp9D79xkJQPO6
|
|
||||||
ylyrzA5MXiHi293+PwREwOA2Aib2JviWqoKjBIzQ1tv33peJmPeTOaGnsMJ7Z2ydpsyI7Xgx8OCX
|
|
||||||
Luw7438brdIIL/QldGzg3iabExAZ3O4ycBSjjbcvPQ7G2FfQAGiluDmcgCixev++mQJv/y3HGQZ9
|
|
||||||
6H/nN+mA8T0FfPuA4LHzrz/CMOA9F/rMd2H9ET7MDCJtvAUGeRwB2PIym0EDvkYbZkj51/tbcusC
|
|
||||||
S3YxPP2d/2m7+rUgmPt6Lit2pJBh2mqGnw9e/EWGLRRxaxvC8324L0AQiLWl1G8MHD/k7kuPAiBq
|
|
||||||
qC3xBYCwQ8pFVgjAgf6TrQzPr/IviCc1FpjeX0B/D4k7yuzA7HUiPtxcM8MQY5UhzBtyRLxxC6Fr
|
|
||||||
Q77wrCQucrUB4FYAPA4NiFjLoFnE8RupyjgVdv9E+50NDFNX+tf5B7Qk/DRa47Hz//APw3Vvi3h3
|
|
||||||
o/f0YidLGIZ+JuLNdaxGjgOO/wiMeZzKe1sEbBwEWzgBUieWAC8jM39y+/93LcPs33zvcYlG4OWB
|
|
||||||
gtvy4gBwpqwyD6AvcQIuEXhznYiNRwmzbyXUi+OjgcAhj33Z844s83wSRz0aJnvvTP+c9u0V/sFm
|
|
||||||
/zr/1VmE5Q9qPHb+FX8z9HnX5XeQ0MajDDe97znhCccHCI08feXFJYN5PImjHs3reH8jHiuSPwr4
|
|
||||||
dKuIaT/61vk1AjC+J+GbEQLqJ7k/5tnvRTyySERRhU9N1+BUKcPdn1YmO+UEhG8jAGaGFkBW0Mzh
|
|
||||||
+M0VGSRZHmuZDMedVf8wPLfKN5/+eAPw3p0CxvcUoPHy6njqWgEd68tv1xslVobhnwUeblzLaXB+
|
|
||||||
W78Gbm+jNTWtPuSFCnNCjElXWSPPG9/srFofoDp7TzE8/q1vC34NkwmL7tOgdzPpOXlKDPD5MA1G
|
|
||||||
dFBm/u4QgXFLGNYe4iLgJzprfqbbp8a9jjPykPGNEw5c39y7M31+CfDxFvdD+8IKYNRXviXr7NqQ
|
|
||||||
sOwBwScXY61QGfQzc4D/CUgvxeGqTDK67QQXAb8g1tDdx+6nAKTh5b/DmBtbuc/OcynzfmduvQbH
|
|
||||||
LBJl5RK8QJ/LCR8N8d9BZ0g7wgd3EeIUKCljcQCjvhZxsjTwtmobDHAr324FQABSg2sOJxAyEyoT
|
|
||||||
dHqj2OK+FNewDuR1/n4pN7cmzBssveYgRY8mhC+Ga/zKQlyds+XAE8vEgPIR1EbIQ592PwJg4KUY
|
|
||||||
whxPOfwuZeluhmXVUnYNaEl45UbppJv3tiO8OUiZ4TsAXJEOfDVcg9TYwNv6/QjDp1u5p5BPCO77
|
|
||||||
tFsB8KQWnPChV1PClZnSnXPichF/n6oqAre3JTzf13Pa7XvbEab3Vz4zb7M6wMf3aDxmKfaFl9Yw
|
|
||||||
nPBhKlPbIZAPIwAuAGHPhVJfUlQ4gAe+FJFfUvXzER0I0/pRjU5+Q3PCC/2Cl5O/dTow/w4hoIxF
|
|
||||||
QOXv9aZEYRTORTz1afe3gRgvAxYBdG5QWUFHirwS4J5PxRo1+oa2F6oM86/OIsy+NfDOKUW3RoQJ
|
|
||||||
vQK/yJLdDMeLFTCoFuCjAPAw4Ejh+T6CrEw7OYUMIz53ocRadTpwc2vC/DsIbTIq6wOEKjnHw90I
|
|
||||||
XQKM+nOJ4GsBMvGU29ODHwAU2LThhIIkEzB3sEbWYt2ek8B9X7AaItC7GeHbkRokhDAWXyDgxX6B
|
|
||||||
rzMs3sUCqkFYi3C78uJpHKbAMg0nVHRuADzXR15P2naC4Z5PWQ0/fTVKcTWvC9wUYE2AM2XAjjyu
|
|
||||||
ANIw+QLA+Agg4hjWnmS73u45ydyuCajBv7sGrjwbjypgSJRDILd92v02IHEBiETMfQXcfbW8DvXP
|
|
||||||
aYY7P3Kp7lXXMo2QLVnC0jvbZdRJrO0w36YA7tWCE94QATP6CxjgIUa/OocLgbs+9s01OBhcKyPA
|
|
||||||
yBuHCrgASEM+rQFwIhSNAMwaRBjYSl6nOlrEcMdHLuQUqteJrs4KTAByz4G7BvuJBwFgAdSA4aiN
|
|
||||||
TkOYPUjAbW3lday8EuDuT9QbCWQGOAWwOit/ON5gNnefeloE5AIQQZRYWQ13X40AvHaTgDtkFgk9
|
|
||||||
WQoM/UydhUFfqxi7w+JDeHNthAD5AkDEBSCSmL0euOsThs3HqoqAQMCrNwoYLnN34Eghw/CFIs5Z
|
|
||||||
QzueVuLtLTfCsbbCPIzqPf3Z3KoFJ/w4WsTw8Z8iSqyVKbVXVUudRQS80FfAsPbyRGDvKYbxS0Pr
|
|
||||||
XFMYYO5AAIjR8UUA75D8EQD4CCBieGMtg91V+f92FzB2sYilu2uKwIv95E8HfjnIMOu30HWoE8WB
|
|
||||||
XcuohWJhy1GMDwLAoIAmc4LN8WJg+d6qnccpVibMWLSzpgi8PFDAjTJ3B+b8JtaYUgSLvacCOz/R
|
|
||||||
xDu/FIxgcfe5p12As8E0hqMM720U4XQTC+MSgae+E/HNzpoLg28MEmQF4YisMpeAvzUC5cIY8Ofx
|
|
||||||
wISmMY9dlYQY3CaL95QQhAtAmGNxAEt3e/5eZMDT34n45WDVzqUTgHdu95zT/1IOFwL/2xxcBdh7
|
|
||||||
igW8/XhZXT4CkMJTn/aQEETgAhDmrPy7ZlRfdfQaoEW9mp0jyQS8c7tG1vbbnPUM5UFcEfpie+DT
|
|
||||||
jGY8gZ0kzBcBICb6UWGOE0qqL/S548EuAjIS3H/XOh0Y31N676zECny1IzhrAQXlqDFN8YfOPIm9
|
|
||||||
NOTDFED0cDAnPCi3V9bO84ZRCzzY2fvQ+IHOkJXr/7MgJd2Y9qMYsANPejxwOZ8CSCL6tAbAGBeA
|
|
||||||
AAnm+vm6wxe3/jwxqA0hSaKUuE5DmNZfOuf3wQJg/xkfDJTBsj0M38oYxUhxbTPpGgkcQGDuF/Y9
|
|
||||||
5QQ8FlRragE2Z/Ceyg050sfcIzMsuHMDeUPonw8oJ2lbjjM8s1yZUcVgGTkROQDTUI67z90KgPHs
|
|
||||||
qeMAeHhFAHirzRcom4967zzZiUDbDPkdY2h76bWAbQrF3K87zDDi88CH/kBlLoGODbgAyMBhqpuX
|
|
||||||
5+4Lt+vAZIbTMhcnwDzXFed4p9QWnEnAOSuTLP/dr6Vvab17X1YZPnwhpNakAwyXPBkJRiBFYjoh
|
|
||||||
hcPF8PaGSg9Dl0JLCnJjHDg4RnfC7aTR80YQYzkANQqKOVGOSwROlxGCsRKw7zQk/fS7ui0D6ZlY
|
|
||||||
PTBvcPCiaX49BMz8SVR0HaFZHeD2tsq1F+Uc8fSFZwEgygnqSlYUc7qs8o0XDPad9t6uQECH+kG5
|
|
||||||
tE+UWBlW7QM++lPEnpPKtz/lBg10AdYsrEX4IQBeTuJ4Z1d+8JQzp8j7yKJBMiHRGPqhcYmVYf8Z
|
|
||||||
YNdJYO1BERuPBi9Jx4CWhH81DU7bUQljOZ6+8igAjLE9BD7H8gdfk1TanQzHitx/VycOiLmkWMep
|
|
||||||
Uu9tV9/XL7MDf8m0p9TqfdIisso1iBIrnf8vkF8C7D8TuDuvXBokAy8P5A8P8RsAABVFSURBVM+l
|
|
||||||
LwgC7fH0nUcBEEjYxXiiNb9Yf8S3v9uBAqDnPPcb+5/eK6B744sP/Jky7203Sq767x25lXkClEWd
|
|
||||||
58KoBebeGtoCJtGAyFw7PX3nceXHUJB7EEAYZI6PLI4WMezKV669rGr58k6VeX/7pcVX/Xduifvj
|
|
||||||
Ig2dALx1u4C2mWpbEmEQSo1jT+V4+tqjAJAZIsA8Dh047vlmh3JtEQGZCVU7vNQIoE5s1ePzoqCE
|
|
||||||
tk4A5gwW0DvA9OG1EobdRJ6HbBJ7P7RLaXuimXNWho+2KDfcNmqr7sdbHJCMzKte3FMqYjDcSTIB
|
|
||||||
nw7VoF8L3vn9gpjH4T/gfRcAxLCT8b+7bN7eULkwphTVt7nkeBdWPyeSl3Fa1CO8czuhEU/44Tck
|
|
||||||
kpesERICIArCJmK8/LIcduYBCzYq29sM1e6OXYZvQZDcD0KKVgAe7EJ4vJcAPd/rDwgXsY3evvcq
|
|
||||||
ACZd2jarPb8CQIyiVkUZpTZg/FKX2/RcgWCt5i9v1EoPx2zVzok1BMcjMVh0a0SYfD2hdTofeipA
|
|
||||||
eczZ/L+8HeB1DYBGb3UA2KKoSVGGQwTGLBJxuFD5tsvsqOI3HyOjYmORpWpnb5Ds4cAw48pMwsdD
|
|
||||||
BCwcKvDOrxQMG8nsPahPRlIo+h1gPZWyKZq4kIZ73eHgvGEZqxxdXIjr1wqVgTne1hnyq237ycn9
|
|
||||||
pxY6AbjhcsKQdlTF14GjGL9LHSApAMTwO18IrMk5K/DQVy5sCnLmhOPFqJLYo34SYc9Jz4JzrLjq
|
|
||||||
v69IJ+gEBD27r1wMWqB7Y8J1lxH6tSCk8Mll0CAlBMDmpD/0OiaCVxKuwj2fiAHns5dDTiFDm0ti
|
|
||||||
+xsmw2twTXUnpHgD0LEBYUOOOusA6fGVcfvts4FODSodeZSoBciRxGUwGDdJHSR5KxIfP1FomZO5
|
|
||||||
DUAHRcyKEro1JlyVFfzrJFaLw2+TQVjxt+fOfLSIobACVd6swzpcTA/mFIEKBbP8mnSVW48mHaFO
|
|
||||||
LENaHCE1FmiUAjRJJcS7rUrPCTqETTT6sKQbmFwtXgkuAFWYcj0BKgRLtc3w/j1jwK8HGQZfUhq8
|
|
||||||
fwtC/5A40vC5YtjAaJWcw2QN65kIWY1xgk+H+iQ5hP5Rwfx9nMhEhLhSznGyBMCUkbcJvFpQWGDQ
|
|
||||||
Ap0lSnv9coDhnIIeiZwIg6Eg5mz+NjmHypoC0J1wWebgJwB3BWQYRxEGtiKsPeT5LW91At/sEPFA
|
|
||||||
Z3nrtkUV0lGDBMb35yMFolWVwXzSyF+PJfY9GHEBCAP6Xg5MXek9NuDjLcCIjpW+A1I8+Z2INRLT
|
|
||||||
hh5NCJ8M4QIQCTDGVsg9VvbWnlG0fQuADyzDgEQj4ebW3jvj0SJWo0S4O/48xiQ7PwAMkVlngKM6
|
|
||||||
VhOs38s9WLYA0LjCEkb40T+bOEpzX0fpijiz1omo8JJ/3yUCL6yWHimmxROub+6jgRxVINAKGlco
|
|
||||||
Ow2MT849JLIvfTeJEwxapxOuv8y7AuSVVIqAJz7awrBbRsbesd0JOg0fAUQCIsGnPuqTABgtjmUA
|
|
||||||
LD5ZxAkaj/ciaCTu4PubGHa6qQlzpJDhv79Kv/2zE4G7+fA/UqgwWZyyh/+AryOAZwpKCSRrf5ET
|
|
||||||
fFqmkeRKv1MExix2VQkgqgxiYiiT4RE46XoBOu4EHil8R0+d8imPp8+3VoS40NdzOMFjQk9Cw2Tv
|
|
||||||
b+jjxcC4pSIcLgbGgInfi14Dii7QrwVhQEv+9o8UBMLnPp/j6wkmfeYyAEGo9cLxB5MOeGmg9ILg
|
|
||||||
rwcZnl7OMHMNw2IZuwOpscC0fvzVH0Gc1OsyZG//XcDnO0yjtzqI8Imv53GCR7dGhPs7Sb+pl+xi
|
|
||||||
eG+j9LxfpyHMGyygbpwS1nFCAtEH5xP4+IR/Ei9q5iOS8kzVAiZfp1za7MnXE7pIuBtzwgpGRB/6
|
|
||||||
c6JfAmAcd/wQQOv8OZcTHDTnc+e3Sgus447qIuC+jrzzRxQMPxvHnDjoz6kBTPLYu/6fywkGsXrg
|
|
||||||
/bsEZCT4d/6DXQRMvp53/kiDCVjg77l+C4BRn/E1gBP+ns8JDhkJwNL7BbT0YSRABIzvKZzPccCJ
|
|
||||||
MHJNBcmL/T3ZbwE4v+Awz9/zOcEjLZ7wxTAB7bKlO7ROAP5zE2F8T975IxHGMIvMe/zO8RTQPo/V
|
|
||||||
qX8HQFkgbXCCQ6IR+OxeAX0u99yxsxOBRfdpcFtbvt0XkRBKbS79e4E0EdCdT56QU8xA7wfSBid4
|
|
||||||
mHTA/NsFTO9PNWoG9m9JWDFKw6vtRjDEMD95Qk6x9JFe2gjUCMsb6Y2gFQ7Al9wCnJBzvBiY8K2I
|
|
||||||
/WcYJvYWMKQdH/JHOA6IaGZ6LC+gxPSKPAWWOZmfARiiRFuc4OFwMVQ4CIlGtS3hBA772PRo/ohA
|
|
||||||
W1Fk8keiYAa8lyDiqI9Owzt/lOAiEmYo0ZBi40DHn7M3CGlXdVWqPQ6H4x7x9F+/6TqMU6Rcn2Lz
|
|
||||||
9lMlSVPSr75mjUYqQJ3D4fiNyyWi8NDBF5VqT9GVoPffX5dzXGzZUMk2ORzORRpo9h4eObJXU6Xa
|
|
||||||
U3Tl3pKz/q4/zqZvtDNeD4rDURq9YEVayobblWxT0fH62GmTNnWM37BeyTY5HE4lneI2/PrwtGe3
|
|
||||||
K9mm4hP2VqacW1N1BXxHgMNRkBRNgesy01HF63IoLgBDzOaCDrF/cO9ADkdBOsZveGu42Xxa6XaD
|
|
||||||
smRvNRQ80thwkMcIcDgK0Nh4sNRqODs+GG0HRQDMZrPYJmbrBJJXnozD4XiAIOLKmC1jzGZzUDpT
|
|
||||||
UB3Cn35q4e4/y65pHcxrcDjRTKf433a88urQq4LVflC9dlon7umboilwBfMaHE60kqorcDbR7u0f
|
|
||||||
zGsEVQBGTJmR2y1h3fRgXoPDiVa6xq1/ZfTMmfnBvEZIYkLHPbnk+K7ydtmhuBaHEw1cGbc1583X
|
|
||||||
BjcO9nVC4rjfIm7HwFhNKU8jrhAxRgHxMRd/BD/volLtAJAsTMKRT4K2RGwRu3NgKK4Vstv22qQ3
|
|
||||||
Xl5RNPiZUF0vWkmME/DF9CwY9Bdv3eiXTuLAcd/SwtVJ0mDhi5nQai+2M9ychxOn5flwEQHXto/B
|
|
||||||
dR1j0bKRHolxGjhdDGeKXNi814Jl68qQk+9znQoOgP7JS994euZjj4fiWiEL3Xtq5oSJ7eI37Q/V
|
|
||||||
9aKV4QMSq3R+fxl5U1KVzu8LSfEazH4iDVNG1kHXNiYkxWtABOi0hMy6WgzqFY93J6VjaP/EgO2s
|
|
||||||
bXSI++PvUHV+IIQCAABXxuztkabP8zuDaW2nW1sTBvWKD7id3h1i0LdLrF/nxhgFvP5YPbRu4j3g
|
|
||||||
S6shjLwpEcMHcBGQS5ou39E86dC1obxmSAVguNl8ukvs2ge0xEMFfKVrGxOmjqwT8Fy7V7sYPDM8
|
|
||||||
1e92hg9IQONMnfSB5xk2IBFNsuQfX1vRkhNdY38bPWry5FOhvG7Is3eMnznx024Jv6wO9XUjFa2W
|
|
||||||
8OAtSZg2um5AQ3+DjvDI7cl47oE60Pk59E9L0WLwtVVHIIwB7y4pxh3P5uLB6fnYvs9a5XuNAIy+
|
|
||||||
Ndlvu2sLPRJ/Xv7YS0/5Vd8vEFRJ30PGE/3bxPyVq8a1I4n2LYx4Z2I6hvRN8HuFngjo1NqE+c+m
|
|
||||||
4/be8QGNIK5tHwOtpmoD3/1Whi9+LMHZcy4cznPg+XcLUFpe1Wu1fQsjkhM0/l84ymlt2nFSNOTe
|
|
||||||
osa1VREAs9kstjZs6pihz7Wpcf1wx6gnzH82Ha+Nq4cmPgy3qxMfK2DB5Ay8PKYuGqQHPgzv1S6m
|
|
||||||
xmcr/6ga81VmEfHrtooqnwkC0OMqU8DXj0bS9Hn2dqadnYLl6y+Fagn8Rs+cmd8tZs1gk1DB/QOq
|
|
||||||
odcRLquvr/H5vqN2n7b7YgyC2/n67kM2n7fodFpCs2o2uUTgUG7NdvYcrqnrLRvxLFHVMQoW1j1x
|
|
||||||
zZ0jZ0w5rpYNqmbwHPvS1BXXJq56lYhrgDcYA5avL8P4/55Ccan/LwrGgMW/lOKJWadRWuFbO/Xr
|
|
||||||
aVE932t+gRNOZ817d+JMzUXeRhl8IfBSiBiuTfzhzbEvTvlWTTtUr+bz1MwJE6c8ndLz99J/8ZTi
|
|
||||||
bjhw3I53lxRj6z9W6YO9sPeIDfOXFGPXQf9mXfXdTCEKit3v5hQU1fy8fprqj1pY0S3+1/Wh3O/3
|
|
||||||
RFjcFW3Mke5Xi7EHt5d3DLrvcyTAGPD7TguWry/D5j0WMD8HSC6RYe32CixfX4atfwcmIAmxNQeL
|
|
||||||
Nrt7w+yOmp/HGAVoteR2xFDbuDJ22zGtKaeX2nYAKk8BLmA2m8UuFbvbXmb6p0htW8KB0goRU985
|
|
||||||
g027/e/8AFBQ7MIL7xUE3PkBwGRwIwBuOjoA2D24eZgMPGCgqWn/uQ5xO9qotehXnbAQAAC48y1z
|
|
||||||
WbuYzZ3TuadgWGJ044Pg7k0PAHYPb3l3IlKbqKc76eiU8GenoWZzidq2XCCs7si/p089cE3izwMS
|
|
||||||
tOfCQh05fuJh1CLU4gFAkrZIvC5xzQ0PmSeFVTxMWAkAAIx9cfKaPokr7+Lhw+GFu+G+J49Cvc79
|
|
||||||
5xXW2qnrMUI565mwcuhD0yatVduW6oSdAADAmOnPfNMn+YcHTYKFi0CYYLXV7LyeOrqnzy222nc7
|
|
||||||
TUIF65P63f0TZjz7udq2uCMsBQAAxk174oO+ScsfMZBFbVM4AMrcaLHehxGAzcHgqGU7AHqy4bqk
|
|
||||||
5Y8/9uIzH6ltiyfCVgAA4LEZT75zffKKqTx6UH3yztT0+EtJdO/fX8fN58dO1q7kIDpyoG/y8slP
|
|
||||||
zHjqTbVt8UZYCwAAPDnj8el9k7+dqiceNqAmx0/VFOHMulq3wUXZ9Wq6lxytRdmB9IIV/ZKXTX58
|
|
||||||
xuMz1bZFirAXAKBSBAakfDeWxw2oR5lFRH61ko8GHbl18XUX/7//WO3Y3TUKFtY38bsnI6HzAxEi
|
|
||||||
AADw2PQn5vVJ/HZ4nFDCRUAlNuysuR7Tr2tclX9rtYR/ta+abYgxYN1f0b+WEyuUsT6Jyx56fOaT
|
|
||||||
r6tti1wiRgCAymQi/ZOW38X9BNThl60VNT4bfG08eneIAVFl+PHTQ1NQL7nqGsDuwzacLozudZwk
|
|
||||||
bZHYJ3n57RNmPr1AbVt8ISxiAXzhkRnPfv3uVHb615J/rc63Z9WMmeUEjb1HbFi3vQI9r76YF0Aj
|
|
||||||
AFNG1sHTwxh0WqqxJsAYsGBpcYgtDS1punxH97g1A8dOn/yj2rb4SkSNAC7w0LRJa3vp17RqZtwX
|
|
||||||
3U9WGDLv6yK3b3O9rmbnByrDmHcdit4F3MbGg6W9U36+YuzMyOv8QIQKAACMfm3qoV6JGxpeFbcl
|
|
||||||
R21bahNnil14fNZpHMmTXtX//vcyzPqyMARWqUOb2G0neiT+1iDc3Ht9IaITtS3+9Vdbvz6t5mQL
|
|
||||||
5/octzeqr7Y9oaBxhg52J0N+gfP/f9bvqEBJuW/LIk2z9LDYqrazblsFKqzSa6ylFSJW/F6Oc+Ui
|
|
||||||
EuM0qJN08TGyOxg277Vi9ldFWPRzaUDRjOEKEcM1Cb+uj43dd+UEszmiVzejJjzjtSmzZv5S1Gei
|
|
||||||
RYyJmt8pUtBpCcnxApwuoKjUFZWd/gJGwcKuTfzhzXBI5qEEUdVZ5k2Z1u/30t5L8+3ZPAEdR3HS
|
|
||||||
9Hn2nvG/3PXI9ElL1bZFKaJKAABg/qRJGXtsnf/cVXFVltq2cKKHVqYdp9qbdnZUM4FnMIjYRUBP
|
|
||||||
jJ45Mz8lfnuDXgmrf+AxBJxA0ZIT1yatXl43YVtmtHV+IApHAJcya/Irw/4o67nglD2T+wtwfCZd
|
|
||||||
n2fvHLv2gfEzJ36qti3BIqoFAAAWmM0pByqa/by5tMeVatvCiRyuivvzcKukfd1CXasv1ES9AFzg
|
|
||||||
1WffeG1d6Q1PlLvia83vzPGdBG2JeE3CmlefnjH+WbVtCQW1qjO8Pcl8xX7HFd/+Vdaxidq2cMKP
|
|
||||||
NrHbTrSK29H332bzXrVtCRW1SgAu8PqkN8x/lPWcfNZRJ+JiITjKk6w56+qasPY/T82cMFFtW0JN
|
|
||||||
rRQAAPjYbK63v6LptxtKu3dh0bcZwpEBEUP72I1/N9PtvG70zJn5atujBrVWAC4we+rrI/8qvWrW
|
|
||||||
EVuzOOmjOdFCE+OBkrYxW8c+NuOZT9S2RU0iOhZACVauW739ln5NXmmlP5pQ5ErtaBHd1MDiRA3J
|
|
||||||
2gJXz8Q1C2Jj/+kxcdqUHWrboza1fgRwKQvN5jr7rdkfbyrt3s8qmvjfJorQC1Z0jN28sWHisUHR
|
|
||||||
vrXnC/whd8O8qS91zLE0/N9fFZ1bORlfJ4xktOTE1bGbdjcyHr3vkWnPblXbnnCDC4AX3ps8vct+
|
|
||||||
e7MPtpd1bOmKvORJtRotOXFF7PbDzXT7Hhwzc/IvatsTrnABkMHcSTNuOORo8e6usqsacSEIb7Tk
|
|
||||||
RNuY7Yea6PeN4h1fGi4APjBv8vTu+Y6Gr20vb9+5Qozlf7swwiRYWJvYLTub6XL+PWrGlI1q2xMp
|
|
||||||
8IfYDz6aPjnraFn9OTvLO9x81lm31u+kqEmStkhsE7vtt7ra3PsfnT71iNr2RBpcAALgq0fMcUcS
|
|
||||||
U186ZGt+70Hr5cmM8T9nKCBiaGb8p6iJ4cCnyYazE0ebzTXzlXNkwZ9YhZj73IzrTtszpu4tb9ud
|
|
||||||
jwqCQ5K2SGxt2rU5Q3tsEp/fKwMXAIWZbzbHnHMkTc21NRr6T0WrbDvj2ckCQS9Y0cK093i28din
|
|
||||||
dbRFL95vNlvVtima4AIQRBaYzSnFjqQJufbsIX+XX9HExkxqmxQR6MiO5qZ/TmUZjn6bgjxzbfXT
|
|
||||||
DwVcAELER9MnZ+VXpD170pE9MMfSrME5VyJ3Ob6ERM05sYlx/9E0Xf73SZQ/k3f60MAFQAV+MZu1
|
|
||||||
O+zGu4vFOkPy7VmdD1ubp9Q2j0OBXKivP1aRrT++M8Vw6pOrNWXvXms28ySOIYYLQBjw3owZaaVW
|
|
||||||
w20ljqQbCxxp7Y5Ym9aLNj8DvWBFtv54SYYuf2+S7uxP8fbS/41+beohte2q7UTVQxYtfGg2G0ts
|
|
||||||
sfeUs/j+hc6kNoXOutn5jqzYSAlQMgoWlqHLLU/VnTmerCneHas/932CYPmSL+CFHxHxQHEAs9ks
|
|
||||||
1LXrelUgpm+5K75duRjfoMSZWLfYlRxf6KyjC/UUQktOpGgLHEnawtIETcmZWKH0WIy2bGuSULEq
|
|
||||||
T2P/zWw28xLuEQAXgCjgK7NZf86hbVdBpvZ2UahvF/UZdhjS7aIh1S4akmzMFOdgWp2LaTROptcB
|
|
||||||
gJUZtIxpCACIXMxINicA6MhuF8gl6sjpMJClzKCxFenIVqiH7aResOfrBfF4DLNsTdQ5t91pNtvV
|
|
||||||
/L05HA6Hw+FwOBwOh8PhcDgcDocjwf8BnHZ+mE3sgIkAAAAASUVORK5CYII=
|
|
||||||
"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 18 KiB |
@@ -1,75 +0,0 @@
|
|||||||
"""
|
|
||||||
Structured audit logging for NWC wallet operations.
|
|
||||||
|
|
||||||
Writes append-only JSON lines to /var/log/sovran-nwc-audit.log.
|
|
||||||
Log file is owned by albyhub:albyhub with mode 0600.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
AUDIT_LOG_PATH = "/var/log/sovran-nwc-audit.log"
|
|
||||||
_AUDIT_LOCK = threading.Lock()
|
|
||||||
_initialized = False
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_log_file() -> None:
|
|
||||||
"""Ensure audit log file exists with correct permissions."""
|
|
||||||
global _initialized
|
|
||||||
if _initialized:
|
|
||||||
return
|
|
||||||
with _AUDIT_LOCK:
|
|
||||||
if _initialized:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
# Create directory if needed
|
|
||||||
os.makedirs(os.path.dirname(AUDIT_LOG_PATH), exist_ok=True)
|
|
||||||
# Create file if it doesn't exist
|
|
||||||
if not os.path.exists(AUDIT_LOG_PATH):
|
|
||||||
with open(AUDIT_LOG_PATH, "w") as f:
|
|
||||||
pass
|
|
||||||
# Set restrictive permissions
|
|
||||||
os.chmod(AUDIT_LOG_PATH, 0o600)
|
|
||||||
# Try to set ownership to albyhub user (best effort)
|
|
||||||
try:
|
|
||||||
import pwd
|
|
||||||
import grp
|
|
||||||
albyhub_uid = pwd.getpwnam("albyhub").pw_uid
|
|
||||||
albyhub_gid = grp.getgrnam("albyhub").gr_gid
|
|
||||||
os.chown(AUDIT_LOG_PATH, albyhub_uid, albyhub_gid)
|
|
||||||
except Exception:
|
|
||||||
pass # Best effort; may not have permissions
|
|
||||||
_initialized = True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to initialize audit log: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
def audit_log(event: str, **fields: Any) -> None:
|
|
||||||
"""Write a structured audit log entry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event type identifier (e.g., "wallet_created", "invoice_issued")
|
|
||||||
**fields: Additional key-value fields to include in the log entry
|
|
||||||
"""
|
|
||||||
_ensure_log_file()
|
|
||||||
|
|
||||||
entry = {
|
|
||||||
"ts": time.time(),
|
|
||||||
"event": event,
|
|
||||||
**fields,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with _AUDIT_LOCK:
|
|
||||||
with open(AUDIT_LOG_PATH, "a") as f:
|
|
||||||
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Failed to write audit log: %s", exc)
|
|
||||||
@@ -1,881 +0,0 @@
|
|||||||
"""
|
|
||||||
Alby Hub manager — shared backend for Lightning Wallet Connections API and recovery CLI.
|
|
||||||
|
|
||||||
Interfaces with the local Alby Hub instance at
|
|
||||||
http://127.0.0.1:18080 by default (override with NWC_ALBY_HUB_API_BASE).
|
|
||||||
All sensitive values (passwords, bearer tokens, pairing URIs, macaroon
|
|
||||||
contents, Nostr private keys) are redacted from any exception messages
|
|
||||||
or log output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from . import nwc_audit as _audit_mod
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Constants ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
DEFAULT_API_BASE = os.environ.get(
|
|
||||||
"NWC_ALBY_HUB_API_BASE",
|
|
||||||
"http://127.0.0.1:18080",
|
|
||||||
)
|
|
||||||
DEFAULT_UNLOCK_PASSWORD_FILE = "/var/lib/albyhub/unlock-password"
|
|
||||||
DEFAULT_MACAROON_FILE = os.environ.get(
|
|
||||||
"NWC_LND_MACAROON_FILE", "/run/lnd/albyhub.macaroon"
|
|
||||||
)
|
|
||||||
DEFAULT_LND_ADDRESS = os.environ.get("NWC_LND_ADDRESS", "127.0.0.1:10009")
|
|
||||||
DEFAULT_LND_CERT_FILE = os.environ.get("NWC_LND_CERT_FILE", "/var/lib/lnd/tls.cert")
|
|
||||||
DEFAULT_LND_SOCKET = "/run/lnd/lnd.socket"
|
|
||||||
|
|
||||||
LNURL_DESCRIPTION_DEFAULT = "Pay via Lightning"
|
|
||||||
NWC_MIN_SENDABLE_MSAT = 1000
|
|
||||||
NWC_MAX_SENDABLE_MSAT = 1_000_000_000
|
|
||||||
|
|
||||||
# Metadata key used to mark managed isolated wallets
|
|
||||||
_MANAGED_APP_STORE_ID = "uncle-jim"
|
|
||||||
_MANAGED_META_KEY = "app_store_app_id"
|
|
||||||
|
|
||||||
RECEIVE_ONLY_SCOPES = [
|
|
||||||
"get_info",
|
|
||||||
"get_balance",
|
|
||||||
"make_invoice",
|
|
||||||
"lookup_invoice",
|
|
||||||
"list_transactions",
|
|
||||||
"notifications",
|
|
||||||
]
|
|
||||||
|
|
||||||
LIMITED_SEND_SCOPES = RECEIVE_ONLY_SCOPES + ["pay_invoice"]
|
|
||||||
|
|
||||||
# ── Exceptions ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class AlbyHubError(Exception):
|
|
||||||
"""Base error from the Alby Hub manager.
|
|
||||||
|
|
||||||
The message string is safe to surface to the user — it never
|
|
||||||
contains raw secret material.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, code: str, message: str) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.code = code
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"[{self.code}] {self.args[0]}"
|
|
||||||
|
|
||||||
|
|
||||||
class AlbyHubHttpError(AlbyHubError):
|
|
||||||
def __init__(self, status_code: int, message: str) -> None:
|
|
||||||
super().__init__(f"http_{status_code}", message)
|
|
||||||
self.status_code = status_code
|
|
||||||
|
|
||||||
|
|
||||||
# ── Manager class ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class AlbyHubManager:
|
|
||||||
"""Thread-safe manager for Alby Hub API operations."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
api_base: str = DEFAULT_API_BASE,
|
|
||||||
unlock_password_file: str = DEFAULT_UNLOCK_PASSWORD_FILE,
|
|
||||||
macaroon_file: str = DEFAULT_MACAROON_FILE,
|
|
||||||
lnd_address: str = DEFAULT_LND_ADDRESS,
|
|
||||||
lnd_cert_file: str = DEFAULT_LND_CERT_FILE,
|
|
||||||
) -> None:
|
|
||||||
self.api_base = api_base.rstrip("/")
|
|
||||||
self.unlock_password_file = unlock_password_file
|
|
||||||
self.macaroon_file = macaroon_file
|
|
||||||
self.lnd_address = lnd_address
|
|
||||||
self.lnd_cert_file = lnd_cert_file
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._token: str | None = None
|
|
||||||
|
|
||||||
# ── Low-level HTTP ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
body: dict | None = None,
|
|
||||||
token: str | None = None,
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> dict:
|
|
||||||
"""Make a raw HTTP request to the local Alby Hub API.
|
|
||||||
|
|
||||||
Returns the parsed JSON response body.
|
|
||||||
Raises AlbyHubHttpError on non-2xx responses.
|
|
||||||
Secrets in response bodies are never included in raised exceptions.
|
|
||||||
"""
|
|
||||||
url = f"{self.api_base}{path}"
|
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
||||||
headers: dict[str, str] = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
if token:
|
|
||||||
headers["Authorization"] = "Bearer " + token
|
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
raw = resp.read()
|
|
||||||
if not raw:
|
|
||||||
return {}
|
|
||||||
return json.loads(raw)
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
code = exc.code
|
|
||||||
# Read and discard the body — we do NOT include it in the exception
|
|
||||||
try:
|
|
||||||
exc.read()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise AlbyHubHttpError(code, f"Hub API {method} {path} returned HTTP {code}") from None
|
|
||||||
except (urllib.error.URLError, TimeoutError, OSError):
|
|
||||||
raise AlbyHubError(
|
|
||||||
"hub_unreachable",
|
|
||||||
f"Hub API {method} {path} is unreachable",
|
|
||||||
) from None
|
|
||||||
|
|
||||||
def _authenticated_request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
body: dict | None = None,
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> dict:
|
|
||||||
"""Make an authenticated request; retry once with a fresh token on 401/403."""
|
|
||||||
token = self.ensure_ready()
|
|
||||||
try:
|
|
||||||
return self._request(method, path, body=body, token=token, timeout=timeout)
|
|
||||||
except AlbyHubHttpError as exc:
|
|
||||||
if exc.status_code in (401, 403):
|
|
||||||
with self._lock:
|
|
||||||
self._token = None
|
|
||||||
token = self.ensure_ready()
|
|
||||||
return self._request(method, path, body=body, token=token, timeout=timeout)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _paginate(self, path_template: str, page_size: int = 100) -> list[dict]:
|
|
||||||
"""Paginate a list API completely, collecting all items.
|
|
||||||
|
|
||||||
``path_template`` must contain ``{limit}`` and ``{offset}`` placeholders.
|
|
||||||
"""
|
|
||||||
token = self.ensure_ready()
|
|
||||||
offset = 0
|
|
||||||
results: list[dict] = []
|
|
||||||
while True:
|
|
||||||
path = path_template.format(limit=page_size, offset=offset)
|
|
||||||
page = self._request("GET", path, token=token)
|
|
||||||
# Alby Hub returns apps at the top level or under "apps"/"transactions"
|
|
||||||
total_count: int | None = None
|
|
||||||
if isinstance(page, list):
|
|
||||||
items = page
|
|
||||||
elif isinstance(page, dict):
|
|
||||||
items = page.get("apps") or page.get("transactions") or []
|
|
||||||
if page.get("totalCount") is not None:
|
|
||||||
total_count = int(page.get("totalCount"))
|
|
||||||
else:
|
|
||||||
items = []
|
|
||||||
if not isinstance(items, list):
|
|
||||||
break
|
|
||||||
results.extend(items)
|
|
||||||
if total_count is not None:
|
|
||||||
if len(results) >= total_count:
|
|
||||||
break
|
|
||||||
elif len(items) < page_size:
|
|
||||||
break
|
|
||||||
offset += page_size
|
|
||||||
return results
|
|
||||||
|
|
||||||
# ── Audit log helper ───────────────────────────────────────────
|
|
||||||
|
|
||||||
def _audit(self, event: str, **fields: Any) -> None:
|
|
||||||
"""Emit structured audit log entry."""
|
|
||||||
_audit_mod.audit_log(event, **fields)
|
|
||||||
|
|
||||||
# ── Startup / Auth ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _read_unlock_password(self) -> str:
|
|
||||||
try:
|
|
||||||
with open(self.unlock_password_file, "r") as fh:
|
|
||||||
return fh.read().strip()
|
|
||||||
except OSError as exc:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"unlock_password_unavailable",
|
|
||||||
"Cannot read Alby Hub unlock password",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
def _wait_for_file(self, path: str, timeout: int = 120) -> None:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if os.path.exists(path):
|
|
||||||
return
|
|
||||||
time.sleep(2)
|
|
||||||
raise AlbyHubError(
|
|
||||||
"dependency_unavailable",
|
|
||||||
f"Timed out waiting for required file: {path}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _wait_for_hub_api(self, timeout: int = 120) -> None:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
self._request("GET", "/api/info", timeout=5)
|
|
||||||
return
|
|
||||||
except AlbyHubError:
|
|
||||||
pass
|
|
||||||
time.sleep(3)
|
|
||||||
raise AlbyHubError("hub_unavailable", "Timed out waiting for Alby Hub API")
|
|
||||||
|
|
||||||
def _hub_setup(self, password: str) -> None:
|
|
||||||
"""Perform /api/setup idempotently."""
|
|
||||||
try:
|
|
||||||
info = self._request("GET", "/api/info", timeout=10)
|
|
||||||
if info.get("setupCompleted"):
|
|
||||||
return
|
|
||||||
except AlbyHubError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
setup_body = {
|
|
||||||
"backendType": "LND",
|
|
||||||
"unlockPassword": password,
|
|
||||||
"lndAddress": self.lnd_address,
|
|
||||||
"lndCertFile": self.lnd_cert_file,
|
|
||||||
"lndMacaroonFile": self.macaroon_file,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
self._request("POST", "/api/setup", body=setup_body, timeout=30)
|
|
||||||
except AlbyHubHttpError as exc:
|
|
||||||
if exc.status_code == 409:
|
|
||||||
return # already setup
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _obtain_token(self, password: str) -> str:
|
|
||||||
info = self._request("GET", "/api/info", timeout=10)
|
|
||||||
if info.get("running"):
|
|
||||||
resp = self._request(
|
|
||||||
"POST",
|
|
||||||
"/api/unlock",
|
|
||||||
body={
|
|
||||||
"unlockPassword": password,
|
|
||||||
"permission": "full",
|
|
||||||
},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
resp = self._request(
|
|
||||||
"POST",
|
|
||||||
"/api/start",
|
|
||||||
body={"unlockPassword": password},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
token = (
|
|
||||||
resp.get("token")
|
|
||||||
or resp.get("accessToken")
|
|
||||||
or resp.get("access_token")
|
|
||||||
)
|
|
||||||
if not token or not isinstance(token, str):
|
|
||||||
raise AlbyHubError("auth_failed", "Alby Hub auth response missing token")
|
|
||||||
return token
|
|
||||||
|
|
||||||
def _wait_for_node_ready(self, token: str, timeout: int = 120) -> None:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
status = self._request(
|
|
||||||
"GET", "/api/node/status", token=token, timeout=10
|
|
||||||
)
|
|
||||||
if status.get("isReady") or status.get("running") or status.get("online"):
|
|
||||||
return
|
|
||||||
except AlbyHubError:
|
|
||||||
pass
|
|
||||||
time.sleep(3)
|
|
||||||
raise AlbyHubError("node_not_ready", "Timed out waiting for Alby Hub node to be ready")
|
|
||||||
|
|
||||||
def ensure_ready(self) -> str:
|
|
||||||
"""Ensure Alby Hub is set up, unlocked, and authenticated.
|
|
||||||
|
|
||||||
Returns a valid bearer token. Caches it and uses a lock to
|
|
||||||
prevent concurrent setup races.
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
if self._token:
|
|
||||||
return self._token
|
|
||||||
|
|
||||||
password = self._read_unlock_password()
|
|
||||||
self._wait_for_file(self.macaroon_file, timeout=120)
|
|
||||||
self._wait_for_hub_api(timeout=120)
|
|
||||||
self._hub_setup(password)
|
|
||||||
token = self._obtain_token(password)
|
|
||||||
self._wait_for_node_ready(token, timeout=120)
|
|
||||||
self._token = token
|
|
||||||
return token
|
|
||||||
|
|
||||||
# ── App isolation helpers ──────────────────────────────────────
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_metadata(raw: Any) -> dict:
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
return raw
|
|
||||||
if isinstance(raw, str):
|
|
||||||
try:
|
|
||||||
result = json.loads(raw)
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return result
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _is_managed_app(self, app: dict) -> bool:
|
|
||||||
meta = self._parse_metadata(app.get("metadata"))
|
|
||||||
alias = str(meta.get("lnurl_alias", "")).strip().lower()
|
|
||||||
return (
|
|
||||||
meta.get(_MANAGED_META_KEY) == _MANAGED_APP_STORE_ID
|
|
||||||
and bool(alias)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _app_to_wallet_meta(self, app: dict, domain: str | None) -> dict:
|
|
||||||
meta = self._parse_metadata(app.get("metadata"))
|
|
||||||
alias = meta.get("lnurl_alias", "")
|
|
||||||
address = f"{alias}@{domain}" if alias and domain else None
|
|
||||||
|
|
||||||
scopes = app.get("scopes") or []
|
|
||||||
access_preset = (
|
|
||||||
"send_receive_limited" if "pay_invoice" in scopes else "receive_only"
|
|
||||||
)
|
|
||||||
|
|
||||||
balance_msat = int(app.get("balanceMsat", 0) or 0)
|
|
||||||
balance_sats = balance_msat // 1000
|
|
||||||
dust_msat = balance_msat % 1000
|
|
||||||
|
|
||||||
spending_limit_sats: int | None = None
|
|
||||||
max_amount = app.get("maxAmountSat") or 0
|
|
||||||
if max_amount:
|
|
||||||
spending_limit_sats = int(max_amount)
|
|
||||||
|
|
||||||
# Count pending transactions from the budget or transactions list
|
|
||||||
pending_txs = int(app.get("pendingTransactionsCount", 0) or 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(app.get("id", "")),
|
|
||||||
"pubkey": app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or "",
|
|
||||||
"name": app.get("name", ""),
|
|
||||||
"alias": alias,
|
|
||||||
"lightning_address": address,
|
|
||||||
"access_preset": access_preset,
|
|
||||||
"spending_limit_sats": spending_limit_sats,
|
|
||||||
"balance_sats": balance_sats,
|
|
||||||
"dust_msat": dust_msat,
|
|
||||||
"pending_transactions": pending_txs,
|
|
||||||
"created_at": app.get("createdAt") or app.get("created_at"),
|
|
||||||
"min_sendable_msat": int(
|
|
||||||
meta.get("lnurl_min_sendable_msat", NWC_MIN_SENDABLE_MSAT)
|
|
||||||
),
|
|
||||||
"max_sendable_msat": int(
|
|
||||||
meta.get("lnurl_max_sendable_msat", NWC_MAX_SENDABLE_MSAT)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _all_managed_apps(self) -> list[dict]:
|
|
||||||
apps = self._paginate("/api/apps?limit={limit}&offset={offset}&order_by=created_at")
|
|
||||||
return [a for a in apps if a.get("isolated") and self._is_managed_app(a)]
|
|
||||||
|
|
||||||
def _find_managed_app(self, identifier: str) -> dict | None:
|
|
||||||
needle = identifier.strip().lower()
|
|
||||||
for app in self._all_managed_apps():
|
|
||||||
if str(app.get("id", "")).lower() == needle:
|
|
||||||
return app
|
|
||||||
pubkey = (
|
|
||||||
app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
|
||||||
).lower()
|
|
||||||
if pubkey == needle:
|
|
||||||
return app
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ── Public API ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def list_wallets(self, domain: str | None = None) -> list[dict]:
|
|
||||||
"""Return all managed isolated app wallets (no secrets)."""
|
|
||||||
wallets = []
|
|
||||||
for app in self._all_managed_apps():
|
|
||||||
app_copy = dict(app)
|
|
||||||
app_copy["pendingTransactionsCount"] = len(self._get_app_pending_txs(int(app["id"])))
|
|
||||||
wallets.append(self._app_to_wallet_meta(app_copy, domain))
|
|
||||||
return wallets
|
|
||||||
|
|
||||||
def create_wallet(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
alias: str,
|
|
||||||
access_preset: str,
|
|
||||||
spending_limit_sats: int | None,
|
|
||||||
domain: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Create a new isolated Alby Hub app (wallet connection).
|
|
||||||
|
|
||||||
Returns a dict containing:
|
|
||||||
``wallet`` — safe metadata (no secrets)
|
|
||||||
``pairing_uri`` — real Alby Hub pairingUri (returned ONCE)
|
|
||||||
``result`` — creation status report
|
|
||||||
"""
|
|
||||||
# Validate uniqueness
|
|
||||||
managed = self._all_managed_apps()
|
|
||||||
for a in managed:
|
|
||||||
meta = self._parse_metadata(a.get("metadata"))
|
|
||||||
if meta.get("lnurl_alias", "").lower() == alias.lower():
|
|
||||||
raise AlbyHubError(
|
|
||||||
"alias_exists", "That Lightning Address alias is already in use."
|
|
||||||
)
|
|
||||||
if (a.get("name") or "").lower() == name.lower():
|
|
||||||
raise AlbyHubError(
|
|
||||||
"wallet_name_exists",
|
|
||||||
"That Wallet Connection name already exists.",
|
|
||||||
)
|
|
||||||
|
|
||||||
scopes = (
|
|
||||||
LIMITED_SEND_SCOPES
|
|
||||||
if access_preset == "send_receive_limited"
|
|
||||||
else RECEIVE_ONLY_SCOPES
|
|
||||||
)
|
|
||||||
max_amount = (
|
|
||||||
spending_limit_sats
|
|
||||||
if access_preset == "send_receive_limited" and spending_limit_sats
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
create_body: dict = {
|
|
||||||
"name": name,
|
|
||||||
"scopes": scopes,
|
|
||||||
"isolated": True,
|
|
||||||
"budgetRenewal": "never",
|
|
||||||
"maxAmountSat": max_amount,
|
|
||||||
"metadata": {
|
|
||||||
_MANAGED_META_KEY: _MANAGED_APP_STORE_ID,
|
|
||||||
"lnurl_alias": alias,
|
|
||||||
"lnurl_description": LNURL_DESCRIPTION_DEFAULT,
|
|
||||||
"lnurl_min_sendable_msat": NWC_MIN_SENDABLE_MSAT,
|
|
||||||
"lnurl_max_sendable_msat": NWC_MAX_SENDABLE_MSAT,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = self._authenticated_request("POST", "/api/apps", body=create_body)
|
|
||||||
pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or ""
|
|
||||||
app_id = resp.get("id")
|
|
||||||
|
|
||||||
# Fetch full app details for accurate metadata
|
|
||||||
app_detail: dict | None = None
|
|
||||||
if app_id is not None:
|
|
||||||
try:
|
|
||||||
app_detail = self._authenticated_request(
|
|
||||||
"GET", f"/api/v2/apps/{app_id}"
|
|
||||||
)
|
|
||||||
except AlbyHubError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if app_detail is None:
|
|
||||||
# Fallback: search recent apps for the one we just created
|
|
||||||
updated = self._all_managed_apps()
|
|
||||||
for a in updated:
|
|
||||||
if str(a.get("id", "")) == str(app_id):
|
|
||||||
app_detail = a
|
|
||||||
break
|
|
||||||
|
|
||||||
wallet_meta = self._app_to_wallet_meta(app_detail or resp, domain)
|
|
||||||
|
|
||||||
# Initial internal transfer for limited wallets
|
|
||||||
funding_result: dict = {"attempted": False, "success": False}
|
|
||||||
if (
|
|
||||||
access_preset == "send_receive_limited"
|
|
||||||
and spending_limit_sats
|
|
||||||
and app_id is not None
|
|
||||||
):
|
|
||||||
funding_result["attempted"] = True
|
|
||||||
try:
|
|
||||||
self._authenticated_request(
|
|
||||||
"POST",
|
|
||||||
"/api/transfers",
|
|
||||||
body={
|
|
||||||
"toAppId": int(app_id),
|
|
||||||
"amountSat": spending_limit_sats,
|
|
||||||
"description": f"Initial funding for {name}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
funding_result["success"] = True
|
|
||||||
except AlbyHubError as exc:
|
|
||||||
funding_result["error"] = exc.code
|
|
||||||
funding_result["message"] = (
|
|
||||||
"The wallet was created successfully and the NWC connection secret is shown "
|
|
||||||
"above, but initial funding failed. Save the NWC secret now. "
|
|
||||||
"Do not recreate this wallet."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log: wallet created
|
|
||||||
self._audit(
|
|
||||||
"wallet_created",
|
|
||||||
wallet_id=str(app_id) if app_id else "unknown",
|
|
||||||
name=name,
|
|
||||||
alias=alias,
|
|
||||||
access_preset=access_preset,
|
|
||||||
spending_limit_sats=spending_limit_sats,
|
|
||||||
lightning_address=wallet_meta.get("lightning_address"),
|
|
||||||
funding_attempted=funding_result["attempted"],
|
|
||||||
funding_success=funding_result["success"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"wallet": wallet_meta,
|
|
||||||
"pairing_uri": pairing_uri, # returned once on create only
|
|
||||||
"result": {
|
|
||||||
"wallet_created": True,
|
|
||||||
"secret_created": bool(pairing_uri),
|
|
||||||
"lightning_address_registered": bool(alias and domain),
|
|
||||||
"funding": funding_result,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_app_balance_msat(self, app: dict) -> int:
|
|
||||||
return int(app.get("balanceMsat", 0) or 0)
|
|
||||||
|
|
||||||
def _get_app_pending_txs(self, app_id: int) -> list[dict]:
|
|
||||||
txs = self._paginate(
|
|
||||||
f"/api/transactions?appId={app_id}&limit={{limit}}&offset={{offset}}"
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
t for t in txs if str(t.get("state", "")).lower() == "pending"
|
|
||||||
]
|
|
||||||
|
|
||||||
def drain_wallet(self, identifier: str) -> dict:
|
|
||||||
"""Drain all whole-satoshi funds from an isolated app to the primary wallet.
|
|
||||||
|
|
||||||
Returns ``{"ok": True, "drained_sats": N, "dust_msat": M}``.
|
|
||||||
Raises AlbyHubError on rejection or failure.
|
|
||||||
"""
|
|
||||||
app = self._find_managed_app(identifier)
|
|
||||||
if app is None:
|
|
||||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
|
||||||
|
|
||||||
app_id = int(app["id"])
|
|
||||||
balance_msat = self._get_app_balance_msat(app)
|
|
||||||
|
|
||||||
if balance_msat < 0:
|
|
||||||
raise AlbyHubError("negative_balance", "Wallet has a negative balance.")
|
|
||||||
|
|
||||||
pending = self._get_app_pending_txs(app_id)
|
|
||||||
if pending:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"pending_transactions",
|
|
||||||
"Wallet has pending transactions and cannot be drained.",
|
|
||||||
)
|
|
||||||
|
|
||||||
transferable_msat = (balance_msat // 1000) * 1000
|
|
||||||
expected_dust_msat = balance_msat - transferable_msat
|
|
||||||
|
|
||||||
if transferable_msat == 0:
|
|
||||||
return {"ok": True, "drained_sats": 0, "dust_msat": expected_dust_msat}
|
|
||||||
|
|
||||||
# Save original permissions
|
|
||||||
original_scopes = list(app.get("scopes") or [])
|
|
||||||
original_max = app.get("maxAmountSat") or 0
|
|
||||||
original_renewal = app.get("budgetRenewal") or "never"
|
|
||||||
|
|
||||||
# Temporarily grant pay_invoice scope with sufficient budget
|
|
||||||
app_pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
|
||||||
if not app_pubkey:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"app_pubkey_missing",
|
|
||||||
"Cannot drain app: app public key not available.",
|
|
||||||
)
|
|
||||||
|
|
||||||
patch_body = {
|
|
||||||
"scopes": sorted(set(original_scopes) | {"pay_invoice"}),
|
|
||||||
"maxAmountSat": 0,
|
|
||||||
"budgetRenewal": "never",
|
|
||||||
}
|
|
||||||
self._authenticated_request("PATCH", f"/api/apps/{app_pubkey}", body=patch_body)
|
|
||||||
|
|
||||||
drain_error: AlbyHubError | None = None
|
|
||||||
drained_sats = 0
|
|
||||||
try:
|
|
||||||
self._authenticated_request(
|
|
||||||
"POST",
|
|
||||||
"/api/transfers",
|
|
||||||
body={
|
|
||||||
"fromAppId": app_id,
|
|
||||||
"amountMsat": transferable_msat,
|
|
||||||
"description": f"Drain isolated subwallet {app.get('name', '')}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
drained_sats = transferable_msat // 1000
|
|
||||||
except AlbyHubError as exc:
|
|
||||||
drain_error = exc
|
|
||||||
finally:
|
|
||||||
# Restore original permissions whether drain succeeded or not
|
|
||||||
restore_body = {
|
|
||||||
"scopes": original_scopes,
|
|
||||||
"maxAmountSat": original_max,
|
|
||||||
"budgetRenewal": original_renewal,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
self._authenticated_request(
|
|
||||||
"PATCH", f"/api/apps/{app_pubkey}", body=restore_body
|
|
||||||
)
|
|
||||||
except AlbyHubError:
|
|
||||||
pass # best-effort restore; don't mask the original error
|
|
||||||
|
|
||||||
if drain_error is not None:
|
|
||||||
raise drain_error
|
|
||||||
|
|
||||||
# Verify remaining balance equals expected dust
|
|
||||||
refreshed = self._authenticated_request("GET", f"/api/v2/apps/{app_id}")
|
|
||||||
remaining_msat = self._get_app_balance_msat(refreshed)
|
|
||||||
if remaining_msat != expected_dust_msat:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"drain_incomplete",
|
|
||||||
"Drain verification failed: final balance does not match expected dust.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log: wallet drained
|
|
||||||
self._audit(
|
|
||||||
"wallet_drained",
|
|
||||||
wallet_id=str(app_id),
|
|
||||||
name=app.get("name", ""),
|
|
||||||
alias=app.get("alias", ""),
|
|
||||||
drained_sats=drained_sats,
|
|
||||||
dust_msat=expected_dust_msat,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"drained_sats": drained_sats,
|
|
||||||
"dust_msat": expected_dust_msat,
|
|
||||||
"remaining_msat": remaining_msat,
|
|
||||||
}
|
|
||||||
|
|
||||||
def delete_wallet(self, identifier: str) -> dict:
|
|
||||||
"""Safely drain and delete an isolated app.
|
|
||||||
|
|
||||||
Returns ``{"ok": True, "drained_sats": N}``.
|
|
||||||
"""
|
|
||||||
app = self._find_managed_app(identifier)
|
|
||||||
if app is None:
|
|
||||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
|
||||||
|
|
||||||
app_id = int(app["id"])
|
|
||||||
|
|
||||||
pending = self._get_app_pending_txs(app_id)
|
|
||||||
if pending:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"pending_transactions",
|
|
||||||
"Wallet has pending transactions and cannot be deleted.",
|
|
||||||
)
|
|
||||||
|
|
||||||
drain_result = self.drain_wallet(identifier)
|
|
||||||
|
|
||||||
# Verify no transferable balance remains
|
|
||||||
refreshed = self._authenticated_request("GET", f"/api/v2/apps/{app_id}")
|
|
||||||
remaining_msat = self._get_app_balance_msat(refreshed)
|
|
||||||
if remaining_msat < 0:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"negative_balance",
|
|
||||||
"Wallet has a negative final balance and cannot be deleted.",
|
|
||||||
)
|
|
||||||
if remaining_msat >= 1000:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"drain_incomplete",
|
|
||||||
f"Drain verification failed: funds still remain.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete by app pubkey
|
|
||||||
pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
|
||||||
if not pubkey:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"app_pubkey_missing",
|
|
||||||
"Cannot delete app: nostr pubkey not available.",
|
|
||||||
)
|
|
||||||
self._authenticated_request(
|
|
||||||
"DELETE",
|
|
||||||
f"/api/apps/{urllib.parse.quote(pubkey, safe='')}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log: wallet deleted
|
|
||||||
self._audit(
|
|
||||||
"wallet_deleted",
|
|
||||||
wallet_id=str(app_id),
|
|
||||||
name=app.get("name", ""),
|
|
||||||
alias=app.get("alias", ""),
|
|
||||||
drained_sats=drain_result.get("drained_sats", 0),
|
|
||||||
dust_msat=remaining_msat,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"drained_sats": drain_result.get("drained_sats", 0),
|
|
||||||
"dust_msat": remaining_msat,
|
|
||||||
}
|
|
||||||
|
|
||||||
def issue_invoice(
|
|
||||||
self, app_id: int, amount_msat: int, description: str = ""
|
|
||||||
) -> str:
|
|
||||||
"""Create an LND invoice attributed to a specific isolated app.
|
|
||||||
|
|
||||||
Returns a valid BOLT11 invoice string.
|
|
||||||
Raises AlbyHubError if the Hub returns an invalid or misattributed invoice.
|
|
||||||
"""
|
|
||||||
resp = self._authenticated_request(
|
|
||||||
"POST",
|
|
||||||
"/api/invoices",
|
|
||||||
body={
|
|
||||||
"amountMsat": amount_msat,
|
|
||||||
"description": description or LNURL_DESCRIPTION_DEFAULT,
|
|
||||||
"appId": app_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
invoice: str = resp.get("invoice") or ""
|
|
||||||
returned_app_id = resp.get("appId")
|
|
||||||
|
|
||||||
if not invoice:
|
|
||||||
raise AlbyHubError("invoice_creation_failed", "Hub returned empty invoice.")
|
|
||||||
|
|
||||||
# Require a valid BOLT11 prefix (mainnet, testnet, signet, regtest)
|
|
||||||
if not re.match(r"^ln", invoice, re.IGNORECASE):
|
|
||||||
raise AlbyHubError(
|
|
||||||
"invalid_invoice", "Hub returned a non-BOLT11 invoice string."
|
|
||||||
)
|
|
||||||
|
|
||||||
if returned_app_id is None or int(returned_app_id) != app_id:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"invoice_attribution_failed",
|
|
||||||
"Invoice attribution mismatch: returned appId does not match.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log: invoice issued via API
|
|
||||||
self._audit(
|
|
||||||
"invoice_issued",
|
|
||||||
app_id=app_id,
|
|
||||||
amount_msat=amount_msat,
|
|
||||||
amount_sat=amount_msat // 1000,
|
|
||||||
invoice_prefix=invoice[:50] + "..." if len(invoice) > 50 else invoice,
|
|
||||||
)
|
|
||||||
|
|
||||||
return invoice
|
|
||||||
|
|
||||||
def find_app_by_alias(self, alias: str) -> dict | None:
|
|
||||||
"""Find a managed isolated app by its ``lnurl_alias`` metadata field."""
|
|
||||||
alias_lower = alias.strip().lower()
|
|
||||||
for a in self._all_managed_apps():
|
|
||||||
meta = self._parse_metadata(a.get("metadata"))
|
|
||||||
if meta.get("lnurl_alias", "").lower() == alias_lower:
|
|
||||||
return a
|
|
||||||
return None
|
|
||||||
|
|
||||||
def rotate_wallet_secret(self, identifier: str) -> dict:
|
|
||||||
"""Rotate the NWC pairing secret for a wallet connection.
|
|
||||||
|
|
||||||
Revokes the old Nostr key and generates a new pairing URI.
|
|
||||||
Returns the new pairing URI (shown ONCE).
|
|
||||||
"""
|
|
||||||
app = self._find_managed_app(identifier)
|
|
||||||
if app is None:
|
|
||||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
|
||||||
|
|
||||||
app_id = int(app["id"])
|
|
||||||
app_pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
|
||||||
if not app_pubkey:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"app_pubkey_missing",
|
|
||||||
"Cannot rotate secret: app public key not available.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call Alby Hub's rotate secret endpoint (if available)
|
|
||||||
# Alby Hub may not have this endpoint yet; fall back to re-creating the app
|
|
||||||
# For now, we'll delete and re-create with same metadata
|
|
||||||
# This is a safe operation since we drain first
|
|
||||||
name = app.get("name", "")
|
|
||||||
alias = app.get("alias", "")
|
|
||||||
scopes = app.get("scopes") or []
|
|
||||||
max_amount = app.get("maxAmountSat") or 0
|
|
||||||
metadata = app.get("metadata") or {}
|
|
||||||
|
|
||||||
# Drain first
|
|
||||||
self.drain_wallet(identifier)
|
|
||||||
|
|
||||||
# Delete old app
|
|
||||||
self._authenticated_request(
|
|
||||||
"DELETE",
|
|
||||||
f"/api/apps/{urllib.parse.quote(app_pubkey, safe='')}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new app with same parameters
|
|
||||||
create_body: dict = {
|
|
||||||
"name": name,
|
|
||||||
"scopes": scopes,
|
|
||||||
"isolated": True,
|
|
||||||
"budgetRenewal": "never",
|
|
||||||
"maxAmountSat": max_amount,
|
|
||||||
"metadata": metadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = self._authenticated_request("POST", "/api/apps", body=create_body)
|
|
||||||
new_pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or ""
|
|
||||||
new_app_id = resp.get("id")
|
|
||||||
|
|
||||||
# Audit log: secret rotated
|
|
||||||
self._audit(
|
|
||||||
"wallet_secret_rotated",
|
|
||||||
old_wallet_id=str(app_id),
|
|
||||||
new_wallet_id=str(new_app_id) if new_app_id else "unknown",
|
|
||||||
name=name,
|
|
||||||
alias=alias,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"wallet_id": str(new_app_id) if new_app_id else "",
|
|
||||||
"pairing_uri": new_pairing_uri,
|
|
||||||
"message": "New NWC connection secret generated. Save it now — it will not be shown again.",
|
|
||||||
}
|
|
||||||
|
|
||||||
def health(self) -> dict:
|
|
||||||
"""Return a basic health summary."""
|
|
||||||
try:
|
|
||||||
token = self.ensure_ready()
|
|
||||||
status = self._request(
|
|
||||||
"GET", "/api/node/status", token=token, timeout=10
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"hub_ready": bool(
|
|
||||||
status.get("isReady") or status.get("running")
|
|
||||||
),
|
|
||||||
}
|
|
||||||
except AlbyHubError as exc:
|
|
||||||
return {"ok": False, "error": exc.code, "message": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Module-level singleton ──────────────────────────────────────────
|
|
||||||
|
|
||||||
_manager: AlbyHubManager | None = None
|
|
||||||
_manager_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def get_manager() -> AlbyHubManager:
|
|
||||||
"""Return the module-level singleton AlbyHubManager."""
|
|
||||||
global _manager
|
|
||||||
if _manager is None:
|
|
||||||
with _manager_lock:
|
|
||||||
if _manager is None:
|
|
||||||
_manager = AlbyHubManager()
|
|
||||||
return _manager
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
"""
|
|
||||||
Dedicated LNURL service for Lightning Wallet Connections.
|
|
||||||
|
|
||||||
Runs as ``nwc-lnurl.service`` on 127.0.0.1:8181 (loopback only).
|
|
||||||
Caddy proxies the public Lightning Address domain's LNURL routes to this port.
|
|
||||||
|
|
||||||
Routes:
|
|
||||||
GET /.well-known/lnurlp/{alias}
|
|
||||||
GET /lnurlp/{alias}/callback?amount=<msat>
|
|
||||||
|
|
||||||
All error responses are safe for public consumption — raw Alby Hub bodies
|
|
||||||
and internal credentials are never returned to callers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import urllib.parse
|
|
||||||
from collections import defaultdict
|
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from . import nwc_hub_manager as _mgr_mod
|
|
||||||
from . import nwc_audit as _audit_mod
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .nwc_hub_manager import AlbyHubManager
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Configuration ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
LNURL_BIND_HOST = "127.0.0.1"
|
|
||||||
LNURL_PORT = int(os.environ.get("NWC_LNURL_PORT", "8181"))
|
|
||||||
DOMAIN_FILE = "/var/lib/domains/lightning"
|
|
||||||
|
|
||||||
NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
|
||||||
|
|
||||||
# Rate limiting configuration
|
|
||||||
RATE_LIMIT_WINDOW_SEC = 60
|
|
||||||
RATE_LIMIT_MAX_REQUESTS = 30
|
|
||||||
_rate_limit_buckets: dict[str, list[float]] = defaultdict(list)
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _read_domain() -> str | None:
|
|
||||||
try:
|
|
||||||
with open(DOMAIN_FILE, "r") as fh:
|
|
||||||
raw = fh.read(256).strip().lower()
|
|
||||||
# Strict FQDN validation: must be a valid hostname with at least one dot
|
|
||||||
# Reject localhost, IP addresses, and single-label names
|
|
||||||
if not re.match(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$", raw):
|
|
||||||
return None
|
|
||||||
# Explicitly reject local/reserved names
|
|
||||||
if raw in {"localhost", "localhost.localdomain", "local"}:
|
|
||||||
return None
|
|
||||||
return raw
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _check_rate_limit(client_ip: str) -> bool:
|
|
||||||
"""Check and update rate limit bucket for client IP. Returns True if allowed."""
|
|
||||||
now = time.monotonic()
|
|
||||||
bucket = _rate_limit_buckets[client_ip]
|
|
||||||
# Prune old entries
|
|
||||||
cutoff = now - RATE_LIMIT_WINDOW_SEC
|
|
||||||
while bucket and bucket[0] < cutoff:
|
|
||||||
bucket.pop(0)
|
|
||||||
if len(bucket) >= RATE_LIMIT_MAX_REQUESTS:
|
|
||||||
return False
|
|
||||||
bucket.append(now)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _lnurl_discovery(alias: str, manager: "AlbyHubManager", client_ip: str = "") -> tuple[dict, int]:
|
|
||||||
alias = alias.strip().lower()
|
|
||||||
if not NWC_ALIAS_RE.match(alias):
|
|
||||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
|
||||||
|
|
||||||
domain = _read_domain()
|
|
||||||
if not domain:
|
|
||||||
return {"status": "ERROR", "reason": "Lightning domain is not configured"}, 503
|
|
||||||
|
|
||||||
try:
|
|
||||||
app = manager.find_app_by_alias(alias)
|
|
||||||
except _mgr_mod.AlbyHubError:
|
|
||||||
return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503
|
|
||||||
|
|
||||||
if app is None:
|
|
||||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
|
||||||
|
|
||||||
meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata"))
|
|
||||||
min_sendable = int(
|
|
||||||
meta.get("lnurl_min_sendable_msat", _mgr_mod.NWC_MIN_SENDABLE_MSAT)
|
|
||||||
)
|
|
||||||
max_sendable = int(
|
|
||||||
meta.get("lnurl_max_sendable_msat", _mgr_mod.NWC_MAX_SENDABLE_MSAT)
|
|
||||||
)
|
|
||||||
|
|
||||||
callback_alias = urllib.parse.quote(alias, safe="")
|
|
||||||
callback = f"https://{domain}/lnurlp/{callback_alias}/callback"
|
|
||||||
description = meta.get("lnurl_description") or f"Pay {alias}"
|
|
||||||
metadata = json.dumps([["text/plain", description]], separators=(",", ":"))
|
|
||||||
|
|
||||||
# Audit log: LNURL discovery
|
|
||||||
_audit_mod.audit_log(
|
|
||||||
"lnurl_discovery",
|
|
||||||
alias=alias,
|
|
||||||
domain=domain,
|
|
||||||
client_ip=client_ip,
|
|
||||||
min_sendable_msat=min_sendable,
|
|
||||||
max_sendable_msat=max_sendable,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tag": "payRequest",
|
|
||||||
"callback": callback,
|
|
||||||
"minSendable": min_sendable,
|
|
||||||
"maxSendable": max_sendable,
|
|
||||||
"metadata": metadata,
|
|
||||||
"commentAllowed": 0,
|
|
||||||
}, 200
|
|
||||||
|
|
||||||
|
|
||||||
def _lnurl_callback(
|
|
||||||
alias: str, amount_str: str | None, manager: "AlbyHubManager", client_ip: str = ""
|
|
||||||
) -> tuple[dict, int]:
|
|
||||||
payload, status_code = _lnurl_discovery(alias, manager, client_ip)
|
|
||||||
if status_code != 200:
|
|
||||||
return payload, status_code
|
|
||||||
|
|
||||||
if amount_str is None:
|
|
||||||
return {"status": "ERROR", "reason": "Missing amount parameter"}, 400
|
|
||||||
if not re.match(r"^\d+$", amount_str):
|
|
||||||
return {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Amount must be an integer millisatoshi value",
|
|
||||||
}, 400
|
|
||||||
|
|
||||||
amount_msat = int(amount_str)
|
|
||||||
min_sendable = int(payload["minSendable"])
|
|
||||||
max_sendable = int(payload["maxSendable"])
|
|
||||||
|
|
||||||
if amount_msat < min_sendable:
|
|
||||||
return {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Amount is below the minimum sendable value",
|
|
||||||
}, 400
|
|
||||||
if amount_msat > max_sendable:
|
|
||||||
return {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Amount is above the maximum sendable value",
|
|
||||||
}, 400
|
|
||||||
if amount_msat % 1000 != 0:
|
|
||||||
return {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Amount must be a whole-satoshi value",
|
|
||||||
}, 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
app = manager.find_app_by_alias(alias)
|
|
||||||
except _mgr_mod.AlbyHubError:
|
|
||||||
return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503
|
|
||||||
|
|
||||||
if app is None:
|
|
||||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
|
||||||
|
|
||||||
meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata"))
|
|
||||||
description = meta.get("lnurl_description") or f"Pay {alias}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
app_id = int(app["id"])
|
|
||||||
invoice = manager.issue_invoice(app_id, amount_msat, description)
|
|
||||||
except _mgr_mod.AlbyHubError:
|
|
||||||
return {"status": "ERROR", "reason": "Invoice creation failed"}, 502
|
|
||||||
|
|
||||||
# Audit log: Invoice generated via LNURL
|
|
||||||
_audit_mod.audit_log(
|
|
||||||
"lnurl_invoice_created",
|
|
||||||
alias=alias,
|
|
||||||
amount_msat=amount_msat,
|
|
||||||
amount_sat=amount_msat // 1000,
|
|
||||||
client_ip=client_ip,
|
|
||||||
invoice_prefix=invoice[:50] + "..." if len(invoice) > 50 else invoice,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"pr": invoice, "routes": []}, 200
|
|
||||||
|
|
||||||
|
|
||||||
# ── HTTP server ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _make_handler(manager: "AlbyHubManager") -> type:
|
|
||||||
"""Return a handler class bound to the given manager."""
|
|
||||||
|
|
||||||
class LnurlHandler(BaseHTTPRequestHandler):
|
|
||||||
_manager = manager
|
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args: object) -> None:
|
|
||||||
logger.debug(f"LNURL {self.address_string()} {fmt % args}")
|
|
||||||
|
|
||||||
def _send_json(self, status: int, body: dict) -> None:
|
|
||||||
raw = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(raw)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(raw)
|
|
||||||
|
|
||||||
def _get_client_ip(self) -> str:
|
|
||||||
# Check X-Forwarded-For header (set by Caddy)
|
|
||||||
forwarded = self.headers.get("X-Forwarded-For")
|
|
||||||
if forwarded:
|
|
||||||
# Take the first IP in the chain
|
|
||||||
return forwarded.split(",")[0].strip()
|
|
||||||
# Fallback to direct connection IP
|
|
||||||
return self.client_address[0]
|
|
||||||
|
|
||||||
def _check_rate_limit(self) -> bool:
|
|
||||||
client_ip = self._get_client_ip()
|
|
||||||
if not _check_rate_limit(client_ip):
|
|
||||||
self._send_json(429, {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Rate limit exceeded. Please slow down."
|
|
||||||
})
|
|
||||||
_audit_mod.audit_log(
|
|
||||||
"rate_limit_exceeded",
|
|
||||||
client_ip=client_ip,
|
|
||||||
path=self.path,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802
|
|
||||||
if not self._check_rate_limit():
|
|
||||||
return
|
|
||||||
|
|
||||||
parsed = urllib.parse.urlparse(self.path)
|
|
||||||
path = parsed.path
|
|
||||||
qs = urllib.parse.parse_qs(parsed.query)
|
|
||||||
client_ip = self._get_client_ip()
|
|
||||||
|
|
||||||
# /.well-known/lnurlp/{alias}
|
|
||||||
m = re.fullmatch(
|
|
||||||
r"/.well-known/lnurlp/([^/]+)", path
|
|
||||||
)
|
|
||||||
if m:
|
|
||||||
alias = urllib.parse.unquote(m.group(1))
|
|
||||||
payload, code = _lnurl_discovery(alias, self._manager, client_ip)
|
|
||||||
self._send_json(code, payload)
|
|
||||||
return
|
|
||||||
|
|
||||||
# /lnurlp/{alias}/callback
|
|
||||||
m = re.fullmatch(r"/lnurlp/([^/]+)/callback", path)
|
|
||||||
if m:
|
|
||||||
alias = urllib.parse.unquote(m.group(1))
|
|
||||||
amount_values = qs.get("amount")
|
|
||||||
if not amount_values:
|
|
||||||
amount_str = None
|
|
||||||
elif len(amount_values) != 1:
|
|
||||||
self._send_json(
|
|
||||||
400,
|
|
||||||
{
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "A single amount parameter is required",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
amount_str = amount_values[0]
|
|
||||||
payload, code = _lnurl_callback(alias, amount_str, self._manager, client_ip)
|
|
||||||
self._send_json(code, payload)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._send_json(404, {"status": "ERROR", "reason": "Not found"})
|
|
||||||
|
|
||||||
return LnurlHandler
|
|
||||||
|
|
||||||
|
|
||||||
def run(
|
|
||||||
host: str = LNURL_BIND_HOST,
|
|
||||||
port: int = LNURL_PORT,
|
|
||||||
manager: "AlbyHubManager | None" = None,
|
|
||||||
) -> None:
|
|
||||||
"""Start the blocking LNURL HTTP server."""
|
|
||||||
if manager is None:
|
|
||||||
manager = _mgr_mod.get_manager()
|
|
||||||
handler_class = _make_handler(manager)
|
|
||||||
server = HTTPServer((host, port), handler_class)
|
|
||||||
logger.info("nwc-lnurl service listening on %s:%d", host, port)
|
|
||||||
server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from . import nwc_hub_manager as _mgr_mod
|
|
||||||
from .server import _nwc_domain, _nwc_validate_alias, _nwc_test_address
|
|
||||||
|
|
||||||
|
|
||||||
def _print(data) -> None:
|
|
||||||
print(json.dumps(data, indent=2, sort_keys=True))
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(prog="nwc-wallet")
|
|
||||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
||||||
|
|
||||||
create = sub.add_parser("create")
|
|
||||||
create.add_argument("name")
|
|
||||||
create.add_argument("alias")
|
|
||||||
preset_group = create.add_mutually_exclusive_group()
|
|
||||||
preset_group.add_argument("--receive-only", action="store_true")
|
|
||||||
preset_group.add_argument("--limit-sats", type=int)
|
|
||||||
|
|
||||||
sub.add_parser("list")
|
|
||||||
|
|
||||||
drain = sub.add_parser("drain")
|
|
||||||
drain.add_argument("wallet")
|
|
||||||
|
|
||||||
delete = sub.add_parser("delete")
|
|
||||||
delete.add_argument("wallet")
|
|
||||||
|
|
||||||
addr = sub.add_parser("address")
|
|
||||||
addr_sub = addr.add_subparsers(dest="address_cmd", required=True)
|
|
||||||
addr_show = addr_sub.add_parser("show")
|
|
||||||
addr_show.add_argument("alias")
|
|
||||||
|
|
||||||
rotate = sub.add_parser("rotate")
|
|
||||||
rotate.add_argument("wallet")
|
|
||||||
|
|
||||||
sub.add_parser("health")
|
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
manager = _mgr_mod.get_manager()
|
|
||||||
domain = _nwc_domain()
|
|
||||||
|
|
||||||
if args.cmd == "list":
|
|
||||||
try:
|
|
||||||
wallets = manager.list_wallets(domain)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
_print({"wallets": wallets})
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.cmd == "health":
|
|
||||||
result = manager.health()
|
|
||||||
_print(result)
|
|
||||||
return 0 if result.get("ok") else 1
|
|
||||||
|
|
||||||
if args.cmd == "address" and args.address_cmd == "show":
|
|
||||||
alias = args.alias.strip().lower()
|
|
||||||
test = _nwc_test_address(alias)
|
|
||||||
_print(test)
|
|
||||||
return 0 if test.get("ok") else 1
|
|
||||||
|
|
||||||
if args.cmd == "drain":
|
|
||||||
try:
|
|
||||||
result = manager.drain_wallet(args.wallet)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
_print(result)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.cmd == "delete":
|
|
||||||
try:
|
|
||||||
result = manager.delete_wallet(args.wallet)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
_print(result)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.cmd == "rotate":
|
|
||||||
try:
|
|
||||||
result = manager.rotate_wallet_secret(args.wallet)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
_print({
|
|
||||||
"wallet_id": result.get("wallet_id", ""),
|
|
||||||
"pairing_uri": result.get("pairing_uri", ""),
|
|
||||||
"message": result.get("message", "New NWC connection secret generated. Save it now — it will not be shown again."),
|
|
||||||
})
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.cmd == "create":
|
|
||||||
alias = args.alias.strip().lower()
|
|
||||||
if not _nwc_validate_alias(alias):
|
|
||||||
print("Error: alias_invalid - Alias must be lowercase letters, digits, '_' or '-'.", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
access_preset = "send_receive_limited" if args.limit_sats is not None else "receive_only"
|
|
||||||
try:
|
|
||||||
result = manager.create_wallet(
|
|
||||||
args.name.strip(),
|
|
||||||
alias,
|
|
||||||
access_preset,
|
|
||||||
args.limit_sats if access_preset == "send_receive_limited" else None,
|
|
||||||
domain,
|
|
||||||
)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
# Print the pairing URI once — this is the only time it is shown
|
|
||||||
_print(
|
|
||||||
{
|
|
||||||
"wallet": result["wallet"],
|
|
||||||
"pairing_uri": result.get("pairing_uri", ""),
|
|
||||||
"message": "Keep the NWC connection secret private. It cannot be displayed again.",
|
|
||||||
"result": result.get("result", {}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -491,6 +491,8 @@ if [[ -d /home ]]; then
|
|||||||
--exclude='.config/chromium/*/Code Cache/' \
|
--exclude='.config/chromium/*/Code Cache/' \
|
||||||
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
|
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
|
||||||
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
|
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
|
||||||
|
--exclude='.config/BraveSoftware/Brave-Origin/*/Cache/' \
|
||||||
|
--exclude='.config/BraveSoftware/Brave-Origin/*/Code Cache/' \
|
||||||
--exclude='.local/share/baloo/' \
|
--exclude='.local/share/baloo/' \
|
||||||
--exclude='.thumbnails/' \
|
--exclude='.thumbnails/' \
|
||||||
--exclude='.xsession-errors' \
|
--exclude='.xsession-errors' \
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
# ── Nix string escaping ────────────────────────────────────────────────────────
|
# ── Nix string escaping ────────────────────────────────────────────────────────
|
||||||
@@ -239,3 +243,71 @@ def _validate_ssh_pubkey(key: str) -> str:
|
|||||||
if len(decoded) < 20:
|
if len(decoded) < 20:
|
||||||
raise ValueError("SSH public key payload is too short")
|
raise ValueError("SSH public key payload is too short")
|
||||||
return key
|
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
|
||||||
|
|||||||
+286
-314
@@ -21,6 +21,7 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -38,7 +39,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
|
|
||||||
from .config import load_config, load_versions
|
from .config import load_config, load_versions
|
||||||
from . import systemctl as sysctl
|
from . import systemctl as sysctl
|
||||||
from . import nwc_hub_manager as _nwc_mgr
|
from sovran_nwc import nwc_hub_manager as _nwc_mgr
|
||||||
from . import support_ops as _support_ops
|
from . import support_ops as _support_ops
|
||||||
from .security_helpers import (
|
from .security_helpers import (
|
||||||
_nix_escape,
|
_nix_escape,
|
||||||
@@ -52,7 +53,10 @@ from .security_helpers import (
|
|||||||
_SSH_PUBKEY_ALGORITHMS,
|
_SSH_PUBKEY_ALGORITHMS,
|
||||||
_bech32_decode,
|
_bech32_decode,
|
||||||
_bech32_convertbits_decode,
|
_bech32_convertbits_decode,
|
||||||
|
load_session_store,
|
||||||
|
save_session_store,
|
||||||
)
|
)
|
||||||
|
from .update_state import effective_update_status
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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_LOG = "/var/log/sovran-hub-update.log"
|
||||||
UPDATE_STATUS = "/var/log/sovran-hub-update.status"
|
UPDATE_STATUS = "/var/log/sovran-hub-update.status"
|
||||||
|
UPDATE_GENERATION = "/var/log/sovran-hub-update.generation"
|
||||||
UPDATE_UNIT = "sovran-hub-update.service"
|
UPDATE_UNIT = "sovran-hub-update.service"
|
||||||
|
|
||||||
REBUILD_LOG = "/var/log/sovran-hub-rebuild.log"
|
REBUILD_LOG = "/var/log/sovran-hub-rebuild.log"
|
||||||
@@ -130,6 +135,7 @@ _SERVICE_DOMAIN_KEYS = frozenset([
|
|||||||
])
|
])
|
||||||
|
|
||||||
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
|
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"
|
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
|
||||||
|
|
||||||
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
|
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"
|
MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass"
|
||||||
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
|
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
|
||||||
SESSION_COOKIE_NAME = "hub_session"
|
SESSION_COOKIE_NAME = "hub_session"
|
||||||
|
MANUAL_LOGOUT_COOKIE_NAME = "hub_manual_logout"
|
||||||
SESSION_MAX_AGE = 86400 # 24 hours
|
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: 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
|
# Failed login tracking: ip → list of failure timestamps
|
||||||
_login_failures: dict[str, list[float]] = {}
|
_login_failures: dict[str, list[float]] = {}
|
||||||
@@ -218,8 +246,8 @@ CATEGORY_ORDER = [
|
|||||||
FEATURE_REGISTRY = [
|
FEATURE_REGISTRY = [
|
||||||
{
|
{
|
||||||
"id": "rdp",
|
"id": "rdp",
|
||||||
"name": "Remote Desktop (RDP)",
|
"name": "Remote Desktop",
|
||||||
"description": "Access your desktop remotely via RDP client",
|
"description": "Access your desktop from a device on your home network — no router setup needed",
|
||||||
"category": "infrastructure",
|
"category": "infrastructure",
|
||||||
"needs_domain": False,
|
"needs_domain": False,
|
||||||
"domain_name": None,
|
"domain_name": None,
|
||||||
@@ -298,15 +326,24 @@ FEATURE_REGISTRY = [
|
|||||||
"port_requirements": [],
|
"port_requirements": [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "bitcoin-core",
|
"id": "bitcoin-tor-gossip",
|
||||||
"name": "Bitcoin Core",
|
"name": "Advertise Tor IBD Node",
|
||||||
"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.",
|
"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",
|
"category": "bitcoin",
|
||||||
|
"modal_only": True,
|
||||||
"needs_domain": False,
|
"needs_domain": False,
|
||||||
"domain_name": None,
|
"domain_name": None,
|
||||||
"needs_ddns": False,
|
"needs_ddns": False,
|
||||||
"extra_fields": [],
|
"extra_fields": [],
|
||||||
"conflicts_with": [],
|
"conflicts_with": [],
|
||||||
|
"requires": ["bitcoin-service"],
|
||||||
"port_requirements": [],
|
"port_requirements": [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -325,8 +362,8 @@ FEATURE_REGISTRY = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "btcpay-web",
|
"id": "btcpay-web",
|
||||||
"name": "BTCPay Server Web Access",
|
"name": "Put BTCPay Server Online",
|
||||||
"description": "Expose BTCPay Server to the internet via your domain. When disabled, BTCPay Server still runs locally but is not accessible from the web.",
|
"description": "Let people donate Bitcoin, pay you, or visit your online store. BTCPay Server stays private when this is turned off.",
|
||||||
"category": "bitcoin",
|
"category": "bitcoin",
|
||||||
"needs_domain": True,
|
"needs_domain": True,
|
||||||
"domain_name": "btcpayserver",
|
"domain_name": "btcpayserver",
|
||||||
@@ -342,7 +379,7 @@ FEATURE_REGISTRY = [
|
|||||||
|
|
||||||
# Feature ids that have been removed/deprecated. The Hub must never write these
|
# 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).
|
# 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
|
# Map feature IDs to their systemd units in config.json
|
||||||
FEATURE_SERVICE_MAP = {
|
FEATURE_SERVICE_MAP = {
|
||||||
@@ -350,7 +387,7 @@ FEATURE_SERVICE_MAP = {
|
|||||||
"haven": "haven-relay.service",
|
"haven": "haven-relay.service",
|
||||||
"element-calling": "livekit.service",
|
"element-calling": "livekit.service",
|
||||||
"mempool": "mempool.service",
|
"mempool": "mempool.service",
|
||||||
"bitcoin-core": None,
|
"bitcoin-tor-gossip": None,
|
||||||
"btcpay-web": "btcpayserver.service",
|
"btcpay-web": "btcpayserver.service",
|
||||||
"nwc-wallets": "albyhub.service",
|
"nwc-wallets": "albyhub.service",
|
||||||
"sshd": "sshd.service",
|
"sshd": "sshd.service",
|
||||||
@@ -405,9 +442,7 @@ SERVICE_DOMAIN_MAP: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# For features that share a unit, disambiguate by icon field
|
# For features that share a unit, disambiguate by icon field
|
||||||
FEATURE_ICON_MAP = {
|
FEATURE_ICON_MAP: dict[str, str] = {}
|
||||||
"bitcoin-core": "bitcoin-core",
|
|
||||||
}
|
|
||||||
|
|
||||||
ROLE_LABELS = {
|
ROLE_LABELS = {
|
||||||
"server_plus_desktop": "Server + Desktop",
|
"server_plus_desktop": "Server + Desktop",
|
||||||
@@ -426,7 +461,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = {
|
|||||||
ROLE_FEATURES: dict[str, set[str] | None] = {
|
ROLE_FEATURES: dict[str, set[str] | None] = {
|
||||||
"server_plus_desktop": None,
|
"server_plus_desktop": None,
|
||||||
"desktop": {"rdp", "sshd"},
|
"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] = {
|
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."
|
"Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch."
|
||||||
),
|
),
|
||||||
"zeus-connect-setup.service": (
|
"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. "
|
"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": (
|
"mempool.service": (
|
||||||
"Your own blockchain explorer and mempool visualizer. Monitor transactions, "
|
"Your own blockchain explorer and mempool visualizer. Monitor transactions, "
|
||||||
@@ -510,9 +545,9 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
"Addresses on your Sovran_SystemsOS node."
|
"Addresses on your Sovran_SystemsOS node."
|
||||||
),
|
),
|
||||||
"gnome-remote-desktop.service": (
|
"gnome-remote-desktop.service": (
|
||||||
"Access your server's full desktop environment from anywhere using any RDP client. "
|
"Access your desktop from a device on your home network — no router setup needed. "
|
||||||
"Manage your system visually without being physically present. "
|
"Sovran_SystemsOS creates the address, username, and password for you: "
|
||||||
"Sovran_SystemsOS sets up secure remote access with generated credentials — connect and go."
|
"just open an RDP app and connect."
|
||||||
),
|
),
|
||||||
"sshd.service": (
|
"sshd.service": (
|
||||||
"Secure Shell (SSH) remote access. When enabled, authorized users can connect "
|
"Secure Shell (SSH) remote access. When enabled, authorized users can connect "
|
||||||
@@ -606,38 +641,74 @@ def _get_or_create_session_secret() -> bytes:
|
|||||||
return token_hex
|
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:
|
def _create_session() -> str:
|
||||||
"""Create a new opaque session token and register it in the store."""
|
"""Create a new opaque session token and register it in the store."""
|
||||||
|
_load_sessions_once()
|
||||||
_purge_expired_sessions()
|
_purge_expired_sessions()
|
||||||
token = secrets.token_hex(32)
|
token = secrets.token_hex(32)
|
||||||
_sessions[token] = time.time() + SESSION_MAX_AGE
|
_sessions[token] = time.time() + SESSION_MAX_AGE
|
||||||
|
_persist_sessions(force=True)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
def _destroy_session(token: str) -> None:
|
def _destroy_session(token: str) -> None:
|
||||||
"""Remove a session token from the store."""
|
"""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:
|
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()
|
now = time.time()
|
||||||
expired = [tok for tok, exp in _sessions.items() if exp <= now]
|
expired = [tok for tok, exp in _sessions.items() if exp <= now]
|
||||||
for tok in expired:
|
for tok in expired:
|
||||||
del _sessions[tok]
|
del _sessions[tok]
|
||||||
|
if expired:
|
||||||
|
_persist_sessions(force=True)
|
||||||
|
|
||||||
|
|
||||||
def _is_authenticated(request: Request) -> bool:
|
def _is_authenticated(request: Request) -> bool:
|
||||||
"""Return True if the request carries a valid, unexpired session cookie."""
|
"""Return True if the request carries a valid, unexpired session cookie."""
|
||||||
|
_load_sessions_once()
|
||||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
if not token:
|
if not token:
|
||||||
return False
|
return False
|
||||||
expiry = _sessions.get(token)
|
expiry = _sessions.get(token)
|
||||||
if expiry is None or time.time() >= expiry:
|
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
|
return False
|
||||||
# Slide the expiry window on activity
|
# Slide the expiry window on activity
|
||||||
_sessions[token] = time.time() + SESSION_MAX_AGE
|
_sessions[token] = time.time() + SESSION_MAX_AGE
|
||||||
|
_persist_sessions() # throttled — don't rewrite the store on every poll
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -883,21 +954,43 @@ def _save_internal_ip(ip: str):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _get_external_ip() -> str:
|
def _save_external_ip(ip: str):
|
||||||
MAX_IP_LENGTH = 46
|
"""Write the external IP to a file so other services (e.g. LiveKit) can
|
||||||
for url in [
|
reference it without running their own detection."""
|
||||||
"https://api.ipify.org",
|
if ip and ip != "unavailable":
|
||||||
"https://ifconfig.me/ip",
|
|
||||||
"https://icanhazip.com",
|
|
||||||
]:
|
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, method="GET")
|
os.makedirs(os.path.dirname(EXTERNAL_IP_FILE), exist_ok=True)
|
||||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
with open(EXTERNAL_IP_FILE, "w") as f:
|
||||||
ip = resp.read().decode().strip()
|
f.write(ip)
|
||||||
if ip and len(ip) < MAX_IP_LENGTH:
|
except OSError:
|
||||||
return ip
|
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:
|
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"
|
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:
|
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.
|
"""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:
|
try:
|
||||||
result = subprocess.run(
|
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,
|
capture_output=True, timeout=10,
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout:
|
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:
|
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
|
"""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)."""
|
user wants to embed the QR in a website or print it at any size)."""
|
||||||
|
for ecc in ("H", "Q", "L"):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
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,
|
capture_output=True, timeout=10,
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout:
|
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) ────────────────────
|
# ── 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):
|
def _write_update_status(status: str):
|
||||||
"""Write to the status file."""
|
"""Write to the status file."""
|
||||||
try:
|
try:
|
||||||
@@ -1578,6 +1669,36 @@ def _write_update_status(status: str):
|
|||||||
pass
|
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]:
|
def _read_log(offset: int = 0) -> tuple[str, int]:
|
||||||
"""Read the update log file from the given byte offset.
|
"""Read the update log file from the given byte offset.
|
||||||
Returns (new_text, new_offset)."""
|
Returns (new_text, new_offset)."""
|
||||||
@@ -1613,6 +1734,9 @@ def _resolve_credential(cred: dict) -> dict | None:
|
|||||||
qr_data = _generate_qr_base64(result["value"])
|
qr_data = _generate_qr_base64(result["value"])
|
||||||
if qr_data:
|
if qr_data:
|
||||||
result["qrcode"] = qr_data
|
result["qrcode"] = qr_data
|
||||||
|
else:
|
||||||
|
# Don't hide the URI if we could not render a scannable QR.
|
||||||
|
qronly = False
|
||||||
if qronly:
|
if qronly:
|
||||||
result["qronly"] = True
|
result["qronly"] = True
|
||||||
return result
|
return result
|
||||||
@@ -1647,6 +1771,9 @@ def _resolve_credential(cred: dict) -> dict | None:
|
|||||||
qr_data = _generate_qr_base64(value)
|
qr_data = _generate_qr_base64(value)
|
||||||
if qr_data:
|
if qr_data:
|
||||||
result["qrcode"] = qr_data
|
result["qrcode"] = qr_data
|
||||||
|
else:
|
||||||
|
# Don't hide the URI if we could not render a scannable QR.
|
||||||
|
qronly = False
|
||||||
|
|
||||||
if qronly:
|
if qronly:
|
||||||
result["qronly"] = True
|
result["qronly"] = True
|
||||||
@@ -2017,14 +2144,24 @@ def _migrate_strip_deprecated_features() -> None:
|
|||||||
# ── Feature status helpers ─────────────────────────────────────────
|
# ── Feature status helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
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.
|
"""Check whether a feature is enabled in the evaluated Hub configuration.
|
||||||
Returns True/False if found, None if the feature has no mapped service."""
|
|
||||||
|
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":
|
if feature_id == "btcpay-web":
|
||||||
return False # Default off in Node role; only on via explicit hub toggle
|
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)
|
unit = FEATURE_SERVICE_MAP.get(feature_id)
|
||||||
if unit is None:
|
if unit is None:
|
||||||
return None # bitcoin-core — can't determine from config
|
return None
|
||||||
cfg = load_config()
|
|
||||||
for svc in cfg.get("services", []):
|
for svc in cfg.get("services", []):
|
||||||
if svc.get("unit") == unit:
|
if svc.get("unit") == unit:
|
||||||
return svc.get("enabled", False)
|
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"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
if client_ip not in ("127.0.0.1", "::1"):
|
if client_ip not in ("127.0.0.1", "::1"):
|
||||||
raise HTTPException(status_code=403, detail="Forbidden")
|
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()
|
token = _create_session()
|
||||||
response = RedirectResponse(url="/", status_code=303)
|
response = RedirectResponse(url="/", status_code=303)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
@@ -2480,17 +2621,27 @@ async def api_login(req: LoginRequest, request: Request):
|
|||||||
samesite="lax",
|
samesite="lax",
|
||||||
secure=False, # LAN-only appliance; no TLS on the Hub port
|
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
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/logout")
|
@app.post("/api/logout")
|
||||||
async def api_logout(request: Request):
|
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)
|
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
if token:
|
if token:
|
||||||
_destroy_session(token)
|
_destroy_session(token)
|
||||||
response = JSONResponse({"ok": True})
|
response = JSONResponse({"ok": True})
|
||||||
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
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
|
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: tuple[float, dict | None] = (0.0, None)
|
||||||
_BTC_SYNC_CACHE_TTL = 5 # seconds
|
_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
|
_BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime
|
||||||
|
|
||||||
# Cache for ``bitcoind --version`` output (available even before RPC is ready)
|
# Cache for ``bitcoind --version`` output (available even before RPC is ready)
|
||||||
_btcd_version_cache: tuple[float, str | None] = (0.0, None)
|
_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) ─────────
|
# ── Generic service version detection (NixOS store path) ─────────
|
||||||
@@ -2906,213 +3047,12 @@ def _get_service_version(unit: str) -> str | None:
|
|||||||
return version
|
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:
|
def _get_bitcoind_version() -> str | None:
|
||||||
"""Run ``bitcoind --version`` and return the raw version string, or None on error.
|
"""Run ``bitcoind --version`` and return the raw version string, or None on error.
|
||||||
|
|
||||||
Parses the first output line to extract the token after "version ".
|
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.).
|
Works regardless of whether the RPC server is ready (IBD, warmup, etc.).
|
||||||
Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL).
|
Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL).
|
||||||
"""
|
"""
|
||||||
@@ -3143,17 +3083,9 @@ def _get_bitcoind_version() -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _format_bitcoin_version(raw_version: str, icon: str = "") -> str:
|
def _format_bitcoin_version(raw_version: str) -> str:
|
||||||
"""Format a raw version string from ``bitcoind --version`` for tile display.
|
"""Format a raw version string from ``bitcoind --version`` for display."""
|
||||||
|
return raw_version
|
||||||
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 _get_bitcoin_sync_info() -> dict | None:
|
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")
|
@app.get("/api/services")
|
||||||
async def api_services():
|
async def api_services():
|
||||||
started_at = time.monotonic()
|
started_at = time.monotonic()
|
||||||
@@ -3314,39 +3233,27 @@ async def api_services():
|
|||||||
dns_states_future = asyncio.gather(
|
dns_states_future = asyncio.gather(
|
||||||
*(resolve_domain(domain) for domain in sorted(domain_names))
|
*(resolve_domain(domain) for domain in sorted(domain_names))
|
||||||
)
|
)
|
||||||
# Bitcoin sync and BIP-110 are supplementary tile metadata. Fetch each
|
# Bitcoin sync is supplementary tile metadata. Fetch it once alongside
|
||||||
# once, concurrently with the other diagnostics, instead of doing duplicate
|
# the other diagnostics instead of duplicating RPC calls per tile.
|
||||||
# RPC calls inside both bitcoind tile coroutines.
|
|
||||||
has_enabled_bitcoin = any(
|
has_enabled_bitcoin = any(
|
||||||
entry.get("unit") == "bitcoind.service" and enabled
|
entry.get("unit") == "bitcoind.service" and enabled
|
||||||
for entry, enabled in effective_entries
|
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 = (
|
bitcoin_sync_future = (
|
||||||
loop.run_in_executor(None, _get_bitcoin_sync_info)
|
loop.run_in_executor(None, _get_bitcoin_sync_info)
|
||||||
if has_enabled_bitcoin
|
if has_enabled_bitcoin
|
||||||
else asyncio.sleep(0, result=None)
|
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,
|
active_states,
|
||||||
port_states,
|
port_states,
|
||||||
dns_states,
|
dns_states,
|
||||||
bitcoin_sync_info,
|
bitcoin_sync_info,
|
||||||
bip110_status,
|
|
||||||
) = await asyncio.gather(
|
) = await asyncio.gather(
|
||||||
active_states_future,
|
active_states_future,
|
||||||
port_states_future,
|
port_states_future,
|
||||||
dns_states_future,
|
dns_states_future,
|
||||||
bitcoin_sync_future,
|
bitcoin_sync_future,
|
||||||
bip110_future,
|
|
||||||
)
|
)
|
||||||
listening_ports, firewall_ports = port_states
|
listening_ports, firewall_ports = port_states
|
||||||
resolved_domains = dict(dns_states)
|
resolved_domains = dict(dns_states)
|
||||||
@@ -3502,11 +3409,9 @@ async def api_services():
|
|||||||
if unit == "bitcoind.service" and enabled:
|
if unit == "bitcoind.service" and enabled:
|
||||||
raw_ver = await loop.run_in_executor(None, _get_bitcoind_version)
|
raw_ver = await loop.run_in_executor(None, _get_bitcoind_version)
|
||||||
if raw_ver is not None:
|
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["bitcoin_version"] = btc_ver # backwards compat
|
||||||
service_data["version"] = btc_ver
|
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) ──────────
|
# ── Generic version for all services (Nix store path) ──────────
|
||||||
if enabled and unit and "version" not in service_data:
|
if enabled and unit and "version" not in service_data:
|
||||||
ver = await loop.run_in_executor(None, _get_service_version, unit)
|
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", []),
|
"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 = {
|
service_detail: dict = {
|
||||||
"name": entry.get("name", ""),
|
"name": entry.get("name", ""),
|
||||||
"unit": unit,
|
"unit": unit,
|
||||||
@@ -3773,6 +3709,7 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
|||||||
"external_ip": external_ip,
|
"external_ip": external_ip,
|
||||||
"internal_ip": internal_ip,
|
"internal_ip": internal_ip,
|
||||||
"feature": feature_entry,
|
"feature": feature_entry,
|
||||||
|
"related_features": related_features,
|
||||||
}
|
}
|
||||||
if sync_ibd is not None:
|
if sync_ibd is not None:
|
||||||
service_detail["sync_ibd"] = sync_ibd
|
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()
|
loop = asyncio.get_event_loop()
|
||||||
raw_ver = await loop.run_in_executor(None, _get_bitcoind_version)
|
raw_ver = await loop.run_in_executor(None, _get_bitcoind_version)
|
||||||
if raw_ver is not None:
|
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["bitcoin_version"] = btc_ver # backwards compat
|
||||||
service_detail["version"] = btc_ver
|
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) ──────────
|
# ── Generic version for all services (Nix store path) ──────────
|
||||||
if enabled and unit and "version" not in service_detail:
|
if enabled and unit and "version" not in service_detail:
|
||||||
ver = await loop.run_in_executor(None, _get_service_version, unit)
|
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
|
# Keep the internal-ip file in sync for credential lookups
|
||||||
_save_internal_ip(internal)
|
_save_internal_ip(internal)
|
||||||
_cached_external_ip = external
|
_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}
|
return {"internal_ip": internal, "external_ip": external}
|
||||||
|
|
||||||
|
|
||||||
@@ -3944,9 +3882,15 @@ async def api_ports_health():
|
|||||||
@app.get("/api/updates/check")
|
@app.get("/api/updates/check")
|
||||||
async def api_updates_check():
|
async def api_updates_check():
|
||||||
loop = asyncio.get_event_loop()
|
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)
|
available = await loop.run_in_executor(None, check_for_updates)
|
||||||
# None means inconclusive (check failed) — report as available so the UI doesn't block
|
# 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")
|
@app.get("/api/ping")
|
||||||
@@ -4397,8 +4341,10 @@ async def api_features():
|
|||||||
|
|
||||||
role = load_config().get("role", "server_plus_desktop")
|
role = load_config().get("role", "server_plus_desktop")
|
||||||
allowed_features = ROLE_FEATURES.get(role)
|
allowed_features = ROLE_FEATURES.get(role)
|
||||||
registry = FEATURE_REGISTRY if allowed_features is None else [
|
registry = [
|
||||||
f for f in FEATURE_REGISTRY if f["id"] in allowed_features
|
f for f in FEATURE_REGISTRY
|
||||||
|
if not f.get("modal_only")
|
||||||
|
and (allowed_features is None or f["id"] in allowed_features)
|
||||||
]
|
]
|
||||||
|
|
||||||
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)
|
features, nostr_npub, cur_tz, cur_locale = await loop.run_in_executor(None, _read_hub_overrides)
|
||||||
|
|
||||||
if req.enabled:
|
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
|
# Element-calling requires matrix domain
|
||||||
if req.feature == "element-calling":
|
if req.feature == "element-calling":
|
||||||
if not os.path.exists(os.path.join(DOMAINS_DIR, "matrix")):
|
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."""
|
"""Ensure the session secret exists on disk at startup."""
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
await loop.run_in_executor(None, _get_or_create_session_secret)
|
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 ──────────────────
|
# ── Startup: recover stale RUNNING status files ──────────────────
|
||||||
@@ -6265,18 +6230,26 @@ async def _startup_recover_stale_status():
|
|||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def _startup_migrate_deprecated_features():
|
async def _startup_migrate_deprecated_features():
|
||||||
"""Strip deprecated feature lines (e.g. bip110) from the Hub Managed section
|
"""Strip deprecated feature lines from the Hub Managed section of custom.nix."""
|
||||||
of custom.nix so they are never re-written and do not cause stale warnings."""
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
await loop.run_in_executor(None, _migrate_strip_deprecated_features)
|
await loop.run_in_executor(None, _migrate_strip_deprecated_features)
|
||||||
|
|
||||||
|
|
||||||
async def _background_domain_reachability_checker():
|
async def _background_domain_reachability_checker():
|
||||||
"""Periodically curl configured domains and cache reachability results."""
|
"""Periodically curl configured domains and cache reachability results."""
|
||||||
|
global _cached_external_ip
|
||||||
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
|
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
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()
|
cfg = load_config()
|
||||||
services = cfg.get("services", [])
|
services = cfg.get("services", [])
|
||||||
|
|
||||||
@@ -6286,7 +6259,6 @@ async def _background_domain_reachability_checker():
|
|||||||
if unit is not None
|
if unit is not None
|
||||||
}
|
}
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
overrides, *_ = await loop.run_in_executor(None, _read_hub_overrides)
|
overrides, *_ = await loop.run_in_executor(None, _read_hub_overrides)
|
||||||
|
|
||||||
domains_to_check: list[str] = []
|
domains_to_check: list[str] = []
|
||||||
|
|||||||
@@ -178,68 +178,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── BIP-110 status badge (tile + detail modal) ───────────────────── */
|
|
||||||
|
|
||||||
.tile-bip110-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
font-size: 0.64rem;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
margin-top: 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-bip110-badge--active {
|
|
||||||
background: rgba(109, 191, 139, 0.18);
|
|
||||||
color: var(--green);
|
|
||||||
border: 1px solid rgba(109, 191, 139, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-bip110-badge--locked_in {
|
|
||||||
background: rgba(94, 173, 138, 0.15);
|
|
||||||
color: var(--accent-color);
|
|
||||||
border: 1px solid rgba(94, 173, 138, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-bip110-badge--signaling {
|
|
||||||
background: rgba(94, 173, 138, 0.12);
|
|
||||||
color: var(--accent-color);
|
|
||||||
border: 1px solid rgba(94, 173, 138, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-bip110-badge--not_signaling {
|
|
||||||
background: rgba(229, 165, 10, 0.12);
|
|
||||||
color: var(--yellow);
|
|
||||||
border: 1px solid rgba(229, 165, 10, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-bip110-badge--unsupported {
|
|
||||||
background: rgba(94, 122, 106, 0.12);
|
|
||||||
color: var(--grey);
|
|
||||||
border: 1px solid rgba(94, 122, 106, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-bip110-badge--unknown {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-dim);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bip110-status-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bip110-source-label {
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Service detail modal sections ───────────────────────────────── */
|
/* ── Service detail modal sections ───────────────────────────────── */
|
||||||
|
|
||||||
@@ -427,6 +365,45 @@
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-card {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(94, 173, 138, 0.06);
|
||||||
|
border: 1px solid rgba(94, 173, 138, 0.24);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-list {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-list li {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-privacy {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-left: 3px solid var(--accent-color);
|
||||||
|
background: rgba(94, 173, 138, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-privacy strong {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-related-feature-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.feature-conflict-warning {
|
.feature-conflict-warning {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
const POLL_INTERVAL_SERVICES = 5000;
|
const POLL_INTERVAL_SERVICES = 5000;
|
||||||
const POLL_INTERVAL_UPDATES = 1800000;
|
const POLL_INTERVAL_UPDATES = 1800000;
|
||||||
const UPDATE_POLL_INTERVAL = 2000;
|
const UPDATE_POLL_INTERVAL = 2000;
|
||||||
|
// A pending fetch never rejects by itself. Bound every status request so a
|
||||||
|
// wedged browser connection cannot leave the modal spinning forever.
|
||||||
|
const STATUS_POLL_FETCH_TIMEOUT = 15000;
|
||||||
|
// Eight timed-out requests plus the poll interval is a little over two minutes.
|
||||||
|
// A brief Hub restart or a heavily loaded Nix build remains well inside this.
|
||||||
|
const STATUS_POLL_MAX_FAILURES = 8;
|
||||||
|
// Keep verbose Nix output from making textContent updates quadratic and
|
||||||
|
// freezing the Hub renderer (especially noticeable over RDP).
|
||||||
|
const UPDATE_VISIBLE_LOG_MAX_CHARS = 250000;
|
||||||
|
const UPDATE_VISIBLE_LOG_TRIM_CHARS = 200000;
|
||||||
const REBOOT_CHECK_INTERVAL = 5000;
|
const REBOOT_CHECK_INTERVAL = 5000;
|
||||||
const REBOOT_FETCH_TIMEOUT = 12000;
|
const REBOOT_FETCH_TIMEOUT = 12000;
|
||||||
const REBOOT_REQUEST_TIMEOUT = 4000;
|
const REBOOT_REQUEST_TIMEOUT = 4000;
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
/* Sovran_SystemsOS Hub — Shared domain-prerequisite instructions.
|
/* Sovran_SystemsOS Hub — Shared domain and router instructions.
|
||||||
|
|
||||||
SINGLE SOURCE OF TRUTH for the "you need a Njal.la domain + router port
|
This is the single source of truth for the simple setup guidance shown in:
|
||||||
forwarding" guidance that must read identically everywhere it appears:
|
|
||||||
|
|
||||||
• First-boot onboarding wizard (Server + Desktop role)
|
• First-boot onboarding for Server + Desktop
|
||||||
— onboarding.js step 3
|
• Feature setup in the Hub
|
||||||
• Feature-enable domain modal (Node role, and any role)
|
• Domain reconfiguration and troubleshooting
|
||||||
— features.js openDomainSetupModal()
|
|
||||||
(Lightning Wallet Connections / NWC, BTCPay Server, Haven, …)
|
|
||||||
• Domain reconfigure / troubleshooting modal
|
|
||||||
— features.js openDomainReconfigureModal()
|
|
||||||
|
|
||||||
Keep these three surfaces word-for-word consistent: always edit this
|
Keep the wording consistent by editing it here. */
|
||||||
file, never fork the wording inline. Plain classic script (no modules) —
|
|
||||||
both templates load it with a plain <script> tag. */
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
/* Escape helper local to this file so it is self-contained on both pages. */
|
|
||||||
function dpEsc(str) {
|
function dpEsc(str) {
|
||||||
return String(str)
|
return String(str)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -26,105 +18,71 @@ function dpEsc(str) {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── "What you'll need" intro ──────────────────────────────────────
|
/* Explain only what the user needs for the step in front of them. */
|
||||||
opts.serviceName — e.g. "Lightning Wallet Connections", or null for
|
|
||||||
the multi-service (onboarding) wording.
|
|
||||||
opts.hostExample — e.g. "lightning" → lightning.yourdomain.com */
|
|
||||||
function renderDomainNeedsHtml(opts) {
|
function renderDomainNeedsHtml(opts) {
|
||||||
|
opts = opts || {};
|
||||||
var serviceName = opts.serviceName || null;
|
var serviceName = opts.serviceName || null;
|
||||||
var hostExample = dpEsc(opts.hostExample || "myservice");
|
var purpose = opts.purpose || "";
|
||||||
var html = "";
|
var html = "";
|
||||||
|
|
||||||
if (serviceName) {
|
if (purpose === "btcpay") {
|
||||||
html += "<p>To enable <strong>" + dpEsc(serviceName) + "</strong>, you'll need two things first:</p>";
|
html += "<p><strong>Put your BTCPay Server online</strong></p>";
|
||||||
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
html += "<p>Let people donate Bitcoin, pay you, or visit your online store.</p>";
|
||||||
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
|
html += "<p>BTCPay Server needs a domain so people can reach it. "
|
||||||
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
|
+ "Sovran_SystemsOS walks you through getting your domain from "
|
||||||
+ dpEsc(serviceName) + " gets its own hostname: a subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
|
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||||
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many.</li>";
|
+ " and connecting it. Just follow the steps below.</p>";
|
||||||
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
|
} else if (serviceName) {
|
||||||
+ "to this computer once. All domain-based services share these two ports; "
|
html += "<p>To make <strong>" + dpEsc(serviceName) + "</strong> available outside your home, it needs a domain.</p>";
|
||||||
+ "they're required for HTTPS and SSL certificates.</li>";
|
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
|
||||||
html += "</ol>";
|
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||||
|
+ " and connecting it. Just follow the steps below.</p>";
|
||||||
} else {
|
} else {
|
||||||
html += "<p><strong>Each service below needs two things set up:</strong></p>";
|
html += "<p>Your domain is the address people use to reach your services on the internet.</p>";
|
||||||
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
|
||||||
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
|
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||||
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
|
+ " and connecting your services. Just follow the steps below.</p>";
|
||||||
+ "Each service gets its own hostname: its own subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
|
|
||||||
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many, "
|
|
||||||
+ "so a single domain can serve every service.</li>";
|
|
||||||
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
|
|
||||||
+ "to this computer once. All domain-based services share these two ports; "
|
|
||||||
+ "they're required for HTTPS and SSL certificates.</li>";
|
|
||||||
html += "</ol>";
|
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── "How to set it up at Njal.la" steps ───────────────────────────
|
/* The shortest Njal.la steps that still tell the user exactly what to do. */
|
||||||
opts.hostExample — host part used in the examples (default "call").
|
|
||||||
opts.pasteHint — where the curl command goes:
|
|
||||||
"below" (single service) or "next to its service below"
|
|
||||||
(onboarding, many services). */
|
|
||||||
function renderNjallaStepsHtml(opts) {
|
function renderNjallaStepsHtml(opts) {
|
||||||
var hostExample = dpEsc((opts && opts.hostExample) || "call");
|
var hostExample = dpEsc((opts && opts.hostExample) || "call");
|
||||||
var pasteHint = (opts && opts.pasteHint) || "below";
|
var pasteHint = (opts && opts.pasteHint) || "below";
|
||||||
var html = "";
|
var html = "";
|
||||||
|
|
||||||
html += "<p style=\"margin-top:12px;\"><strong>How to set it up at Njal.la:</strong></p>";
|
html += '<p style="margin-top:12px;"><strong>Get and connect your domain:</strong></p>';
|
||||||
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
||||||
html += "<li>Create an account at <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">https://njal.la</a> and buy a domain.</li>";
|
html += '<li>Open <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>, create an account, and get a domain.</li>';
|
||||||
html += "<li>Add a <strong>Dynamic</strong> record for the hostname:"
|
html += "<li>Open your domain and add a <strong>Dynamic</strong> record for this service. "
|
||||||
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
|
+ "In the <strong>Name</strong> box, enter only the first part of the address. "
|
||||||
+ "<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>"
|
+ "For <code>" + hostExample + ".yourdomain.com</code>, enter <code>" + hostExample + "</code>. "
|
||||||
+ "(For "" + hostExample + ".yourdomain.com" you'd type just: <code>" + hostExample + "</code>.)<br>"
|
+ "If you are using the whole domain, leave the box blank or enter <code>@</code>.</li>";
|
||||||
+ "⚠ Do NOT type the full domain here — Njal.la adds it automatically.</li>"
|
html += "<li>Copy the update command Njal.la gives you and paste it " + pasteHint + ".</li>";
|
||||||
+ "<li>Dedicating a whole separate domain to this service? Leave the Name field blank or use <code>@</code>.</li>"
|
|
||||||
+ "</ul>"
|
|
||||||
+ "</li>";
|
|
||||||
html += "<li>A Dynamic record has <strong>NO IP field</strong> — you don't enter an IP anywhere. "
|
|
||||||
+ "It auto-fills once Sovran_SystemsOS runs the update command (on save, and again after every reboot).</li>";
|
|
||||||
html += "<li>Njal.la gives you a curl command, e.g.:<br>"
|
|
||||||
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=' + hostExample + '.yourdomain.com&k=abc123&auto"</code><br>'
|
|
||||||
+ "Copy it and paste it " + pasteHint + ".</li>";
|
|
||||||
html += "</ol>";
|
html += "</ol>";
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── "One router task" port-forwarding box ─────────────────────────
|
/* One router task shared by all normal domain-based services. */
|
||||||
opts.internalIp — LAN IP string, or empty/null for generic wording.
|
|
||||||
opts.plural — true for multi-service (onboarding) wording.
|
|
||||||
opts.includeSsh — also mention port 22 for SSH (onboarding).
|
|
||||||
opts.extraNote — extra sentence appended at the end (optional). */
|
|
||||||
function renderRouterPortsHtml(opts) {
|
function renderRouterPortsHtml(opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var ipPart = opts.internalIp
|
var ipPart = opts.internalIp
|
||||||
? " to this computer’s internal IP <strong>" + dpEsc(opts.internalIp) + "</strong>"
|
? " to this computer at <strong>" + dpEsc(opts.internalIp) + "</strong>"
|
||||||
: " to this computer’s internal IP";
|
: " to this computer";
|
||||||
var serviceWord = opts.plural ? "services" : "service";
|
|
||||||
|
|
||||||
var html = "";
|
var html = "";
|
||||||
html += "🔌 <strong>One router task:</strong> in your router’s <strong>port forwarding</strong> settings, "
|
html += "🔌 <strong>One router task:</strong> in your router’s <strong>Port Forwarding</strong> settings, "
|
||||||
+ "forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>"
|
+ "forward <strong>80 (TCP)</strong> and <strong>443 (TCP)</strong>"
|
||||||
+ ipPart + ". Use the <strong>same number for the internal and external port</strong>. "
|
+ ipPart + ". If your router asks for outside and inside port numbers, use the same number for both. "
|
||||||
+ "This only needs to be done once — all domain services share these ports — "
|
+ "You only need to do this once. All your services share these two ports.";
|
||||||
+ "but HTTPS and SSL certificates won’t work without them, so your " + serviceWord + " "
|
|
||||||
+ "can’t be reached from outside your home network. "
|
|
||||||
+ "You’ll need normal access to your router’s settings with working port forwarding — "
|
|
||||||
+ "if your ISP blocks it (e.g. CGNAT), domain-based services can’t be reached from the internet. ";
|
|
||||||
if (opts.includeSsh) {
|
|
||||||
html += "Add port <strong>22 (TCP)</strong> as well if you want remote SSH access. ";
|
|
||||||
}
|
|
||||||
if (opts.extraNote) {
|
if (opts.extraNote) {
|
||||||
html += dpEsc(opts.extraNote);
|
html += " " + dpEsc(opts.extraNote);
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Async helper: fill a router-box container with the internal IP ──
|
/* Show generic wording immediately, then add the computer's address when found. */
|
||||||
Renders generic text immediately, then upgrades to the concrete LAN IP
|
|
||||||
if /api/network provides one. Best-effort — never blocks the UI. */
|
|
||||||
function renderRouterPortsBox(elId, opts) {
|
function renderRouterPortsBox(elId, opts) {
|
||||||
var el = document.getElementById(elId);
|
var el = document.getElementById(elId);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -142,5 +100,5 @@ function renderRouterPortsBox(elId, opts) {
|
|||||||
target.innerHTML = renderRouterPortsHtml(next);
|
target.innerHTML = renderRouterPortsHtml(next);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() { /* generic wording already shown — fine */ });
|
.catch(function() { /* Generic wording is already shown. */ });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,16 @@
|
|||||||
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
||||||
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
||||||
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.addEventListener("click", retryUpdateStatus);
|
||||||
|
|
||||||
|
// Browser timers and requests may be suspended while an RDP session/tab is in
|
||||||
|
// the background. Reconcile immediately when the user returns instead of
|
||||||
|
// waiting for the next interval.
|
||||||
|
window.addEventListener("focus", resumeUpdateStatusAfterInterruption);
|
||||||
|
window.addEventListener("online", resumeUpdateStatusAfterInterruption);
|
||||||
|
document.addEventListener("visibilitychange", function() {
|
||||||
|
if (document.visibilityState === "visible") resumeUpdateStatusAfterInterruption();
|
||||||
|
});
|
||||||
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
||||||
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
||||||
|
|
||||||
@@ -248,6 +258,11 @@ async function init() {
|
|||||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||||
loadAutolaunchToggle();
|
loadAutolaunchToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the page was reloaded or the RDP/browser session resumed during an
|
||||||
|
// update, reopen the modal from the persisted backend state. This also
|
||||||
|
// surfaces a completed update that is waiting for its activation reboot.
|
||||||
|
await restoreUpdateModalIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
@@ -60,6 +60,7 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
||||||
|
|
||||||
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
||||||
|
var isBtcpay = feat.id === "btcpay-web";
|
||||||
|
|
||||||
var npubField = "";
|
var npubField = "";
|
||||||
if (feat.id === "haven") {
|
if (feat.id === "haven") {
|
||||||
@@ -77,30 +78,30 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
|
|
||||||
var nwcWarning = isWalletConnections
|
var nwcWarning = isWalletConnections
|
||||||
? '<div class="domain-nwc-warning">' +
|
? '<div class="domain-nwc-warning">' +
|
||||||
'<strong>⚠ Lightning Wallet Connections requires its own unique hostname.</strong> ' +
|
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
|
||||||
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
|
||||||
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
|
||||||
'</div>'
|
'</div>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
|
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : (isBtcpay ? "btcpay.yourdomain.com" : "myservice.example.com");
|
||||||
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
|
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : (isBtcpay ? "btcpay.yourdomain.com" : "call.yourdomain.com");
|
||||||
|
|
||||||
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
|
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
|
||||||
// identical wording to the Server + Desktop onboarding wizard and every other
|
// identical wording to the Server + Desktop onboarding wizard and every other
|
||||||
// domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
|
// domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
|
||||||
var hostExample = isWalletConnections ? "lightning" : "call";
|
var hostExample = isWalletConnections ? "lightning" : (isBtcpay ? "btcpay" : "call");
|
||||||
|
var purpose = isBtcpay ? "btcpay" : "";
|
||||||
|
|
||||||
$domainSetupBody.innerHTML =
|
$domainSetupBody.innerHTML =
|
||||||
'<div class="domain-setup-intro">' +
|
'<div class="domain-setup-intro">' +
|
||||||
nwcWarning +
|
nwcWarning +
|
||||||
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample }) +
|
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample, purpose: purpose }) +
|
||||||
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
|
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
|
||||||
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
|
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
|
||||||
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' +
|
'<p style="margin-top:10px;">Enter the address for this service and paste the update command from Njal.la.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service address (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=' + domainPlaceholder + '&k=abc123&auto"" /><p class="domain-field-hint">ℹ Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=' + domainPlaceholder + '&k=abc123&auto"" /><p class="domain-field-hint">Paste the update command from Njal.la</p></div>' +
|
||||||
npubField +
|
npubField +
|
||||||
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Enable</button></div>';
|
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Enable</button></div>';
|
||||||
|
|
||||||
@@ -169,9 +170,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
|||||||
|
|
||||||
var nwcWarning = isWalletConnections
|
var nwcWarning = isWalletConnections
|
||||||
? '<div class="domain-nwc-warning">' +
|
? '<div class="domain-nwc-warning">' +
|
||||||
'<strong>⚠ Lightning Wallet Connections requires its own unique hostname.</strong> ' +
|
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
|
||||||
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
|
||||||
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
|
||||||
'</div>'
|
'</div>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
@@ -342,8 +342,11 @@ async function performFeatureToggle(featId, enabled, extra) {
|
|||||||
function handleFeatureToggle(feat, newEnabled) {
|
function handleFeatureToggle(feat, newEnabled) {
|
||||||
if (!newEnabled) {
|
if (!newEnabled) {
|
||||||
// Disable: ask confirmation
|
// Disable: ask confirmation
|
||||||
|
var disableMessage = (feat.id === "bitcoin-tor-gossip")
|
||||||
|
? "This will stop advertising your Bitcoin Core onion address to other peers. Nodes that already know the address can still connect through Tor, and no clearnet port will be opened. The system will rebuild. Continue?"
|
||||||
|
: "This will disable " + feat.name + ". The system will rebuild. Continue?";
|
||||||
openFeatureConfirm(
|
openFeatureConfirm(
|
||||||
"This will disable " + feat.name + ". The system will rebuild. Continue?",
|
disableMessage,
|
||||||
function() { performFeatureToggle(feat.id, false, {}); }
|
function() { performFeatureToggle(feat.id, false, {}); }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -403,9 +406,9 @@ function handleFeatureToggle(feat, newEnabled) {
|
|||||||
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feat.id === "bitcoin-core") {
|
if (feat.id === "bitcoin-tor-gossip") {
|
||||||
var confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will replace Bitcoin Knots + BIP110 as the active node. Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
|
var torGossipConfirmMsg = "This will advertise your Bitcoin Core .onion P2P address through Bitcoin peer gossip. More Tor-capable nodes may discover your node and request historical blocks during IBD, which can use significant upload bandwidth. Your home IP remains hidden and no clearnet port or router forwarding is opened. Continue?";
|
||||||
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
openFeatureConfirm(torGossipConfirmMsg, proceedAfterConflictCheck);
|
||||||
} else if (conflictNames.length > 0) {
|
} else if (conflictNames.length > 0) {
|
||||||
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -111,8 +111,19 @@ function formatDuration(seconds) {
|
|||||||
|
|
||||||
// ── Fetch wrappers ────────────────────────────────────────────────
|
// ── Fetch wrappers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Avoid issuing multiple redirects when several startup requests discover an
|
||||||
|
// expired session at the same time.
|
||||||
|
let _authRedirectInProgress = false;
|
||||||
|
|
||||||
async function apiFetch(path, options) {
|
async function apiFetch(path, options) {
|
||||||
const res = await fetch(path, options || {});
|
const res = await fetch(path, options || {});
|
||||||
|
if (res.status === 401) {
|
||||||
|
if (!_authRedirectInProgress) {
|
||||||
|
_authRedirectInProgress = true;
|
||||||
|
window.location.replace("/login");
|
||||||
|
}
|
||||||
|
throw new Error("Unauthenticated");
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let detail = res.status + " " + res.statusText;
|
let detail = res.status + " " + res.statusText;
|
||||||
try {
|
try {
|
||||||
@@ -134,16 +145,21 @@ async function apiFetch(path, options) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function apiFetchWithTimeout(path, options, timeoutMs) {
|
||||||
// ── BIP-110 badge state config ────────────────────────────────────
|
var controller = new AbortController();
|
||||||
// Shared lookup used by tiles.js and service-detail.js.
|
var fetchOptions = Object.assign({}, options || {});
|
||||||
// Keys match the "state" values returned by /api/bitcoin/bip110.
|
fetchOptions.signal = controller.signal;
|
||||||
|
var timer = setTimeout(function() { controller.abort(); }, timeoutMs);
|
||||||
var BIP110_BADGE_CONFIG = {
|
try {
|
||||||
active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' },
|
return await apiFetch(path, fetchOptions);
|
||||||
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' },
|
} catch (err) {
|
||||||
signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' },
|
if (controller.signal.aborted) {
|
||||||
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' },
|
var timeoutError = new Error("Request timed out");
|
||||||
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' },
|
timeoutError.name = "TimeoutError";
|
||||||
unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' }
|
throw timeoutError;
|
||||||
};
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ function openRebuildModal() {
|
|||||||
_rebuildLogOffset = 0;
|
_rebuildLogOffset = 0;
|
||||||
_rebuildServerDown = false;
|
_rebuildServerDown = false;
|
||||||
_rebuildFinished = false;
|
_rebuildFinished = false;
|
||||||
|
_rebuildPollInFlight = false;
|
||||||
|
_rebuildPollFailures = 0;
|
||||||
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
|
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
|
||||||
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
|
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
|
||||||
var label = _rebuildFeatureName || "feature";
|
var label = _rebuildFeatureName || "feature";
|
||||||
@@ -33,6 +35,7 @@ function appendRebuildLog(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startRebuildPoll() {
|
function startRebuildPoll() {
|
||||||
|
if (_rebuildPollTimer) clearInterval(_rebuildPollTimer);
|
||||||
pollRebuildStatus();
|
pollRebuildStatus();
|
||||||
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
|
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
@@ -42,9 +45,15 @@ function stopRebuildPoll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollRebuildStatus() {
|
async function pollRebuildStatus() {
|
||||||
if (_rebuildFinished) return;
|
if (_rebuildFinished || _rebuildPollInFlight) return;
|
||||||
|
_rebuildPollInFlight = true;
|
||||||
try {
|
try {
|
||||||
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
|
var data = await apiFetchWithTimeout(
|
||||||
|
"/api/rebuild/status?offset=" + _rebuildLogOffset,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
|
_rebuildPollFailures = 0;
|
||||||
if (_rebuildServerDown) { _rebuildServerDown = false; }
|
if (_rebuildServerDown) { _rebuildServerDown = false; }
|
||||||
if (data.log) appendRebuildLog(data.log);
|
if (data.log) appendRebuildLog(data.log);
|
||||||
_rebuildLogOffset = data.offset;
|
_rebuildLogOffset = data.offset;
|
||||||
@@ -57,7 +66,19 @@ async function pollRebuildStatus() {
|
|||||||
onRebuildDone(data.result === "success");
|
onRebuildDone(data.result === "success");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
_rebuildPollFailures += 1;
|
||||||
|
// The Hub restarts itself during activation, which briefly drops this poll.
|
||||||
|
// If polling stays broken long past a normal restart, reload to
|
||||||
|
// re-authenticate and show the resulting feature state.
|
||||||
|
if (_rebuildPollFailures >= STATUS_POLL_MAX_FAILURES) {
|
||||||
|
_rebuildFinished = true;
|
||||||
|
stopRebuildPoll();
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
||||||
|
} finally {
|
||||||
|
_rebuildPollInFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">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">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">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>' +
|
||||||
'<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>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,20 +704,6 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>');
|
'</div>');
|
||||||
|
|
||||||
// Section B2: BIP-110 live status (bip110 tile only)
|
|
||||||
if (icon === 'bip110' && data.bip110) {
|
|
||||||
var bip110 = data.bip110;
|
|
||||||
var bip110State = bip110.state || 'unknown';
|
|
||||||
var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown;
|
|
||||||
var bip110Source = bip110.source ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
|
|
||||||
html += '<div class="svc-detail-section">' +
|
|
||||||
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
|
|
||||||
'<div class="bip110-status-row">' +
|
|
||||||
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
|
|
||||||
bip110Source +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Section C: Domain diagnostics (domain services)
|
// Section C: Domain diagnostics (domain services)
|
||||||
if (data.needs_domain) {
|
if (data.needs_domain) {
|
||||||
@@ -901,9 +887,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||||
|
|
||||||
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
||||||
var addonSectionTitle = (feat.id === "bitcoin-core")
|
var addonSectionTitle = "\uD83D\uDD27 Addon Feature";
|
||||||
? "\u20BF Bitcoin Node Selection"
|
|
||||||
: "\uD83D\uDD27 Addon Feature";
|
|
||||||
|
|
||||||
// Description: prefer the feature's own description over a generic fallback
|
// Description: prefer the feature's own description over a generic fallback
|
||||||
var addonDesc = feat.description
|
var addonDesc = feat.description
|
||||||
@@ -934,6 +918,54 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
'</div>');
|
'</div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Section G: Contextual feature options. The Tor IBD advertising control is
|
||||||
|
// deliberately shown only inside the Bitcoin Core modal so its bandwidth
|
||||||
|
// and privacy implications are explained before the user enables it.
|
||||||
|
var relatedFeatures = Array.isArray(data.related_features) ? data.related_features : [];
|
||||||
|
relatedFeatures.forEach(function(optionFeat) {
|
||||||
|
// Keep the shared feature state current for the standard rebuild flow.
|
||||||
|
if (!_featuresData) {
|
||||||
|
_featuresData = { features: [optionFeat], ssl_email_configured: false };
|
||||||
|
} else {
|
||||||
|
var optionIndex = _featuresData.features.findIndex(function(f) { return f.id === optionFeat.id; });
|
||||||
|
if (optionIndex >= 0) _featuresData.features[optionIndex] = optionFeat;
|
||||||
|
else _featuresData.features.push(optionFeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailsHtml = "";
|
||||||
|
if (Array.isArray(optionFeat.details) && optionFeat.details.length > 0) {
|
||||||
|
detailsHtml = '<ul class="svc-detail-option-list">' +
|
||||||
|
optionFeat.details.map(function(detail) {
|
||||||
|
return '<li>' + escHtml(detail) + '</li>';
|
||||||
|
}).join("") +
|
||||||
|
'</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var optionAvailable = optionFeat.available !== false;
|
||||||
|
var optionStatusLabel = optionFeat.enabled ? "Advertising enabled \u2713" : "Not advertised";
|
||||||
|
var optionStatusCls = optionFeat.enabled ? "addon-status--on" : "addon-status--off";
|
||||||
|
var optionButtonLabel = optionFeat.enabled ? "Stop Advertising" : "Advertise Onion Address";
|
||||||
|
var optionButtonCls = optionFeat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||||
|
if (!optionAvailable) {
|
||||||
|
optionStatusLabel = "Bitcoin Core is not enabled";
|
||||||
|
optionButtonLabel = "Enable Bitcoin Core First";
|
||||||
|
optionButtonCls = "btn btn-close-modal";
|
||||||
|
}
|
||||||
|
|
||||||
|
addSetup('<div class="svc-detail-section svc-detail-option-card">' +
|
||||||
|
'<div class="svc-detail-section-title">Tor IBD Service Advertising</div>' +
|
||||||
|
'<p class="svc-detail-desc">' + escHtml(optionFeat.description || "") + '</p>' +
|
||||||
|
detailsHtml +
|
||||||
|
'<div class="svc-detail-option-privacy">' +
|
||||||
|
'<strong>Tor-only:</strong> This setting advertises the onion service. It does not open a clearnet port, expose your home IP address, or require router port forwarding.' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="svc-detail-addon-row">' +
|
||||||
|
'<span class="svc-detail-addon-status ' + optionStatusCls + '">' + escHtml(optionStatusLabel) + '</span>' +
|
||||||
|
'<button class="' + optionButtonCls + ' svc-detail-related-feature-btn" data-feature-id="' + escHtml(optionFeat.id) + '"' + (!optionAvailable ? ' disabled' : '') + '>' + escHtml(optionButtonLabel) + '</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>');
|
||||||
|
});
|
||||||
|
|
||||||
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
||||||
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
|
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
|
||||||
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
||||||
@@ -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 restartBtn = document.getElementById("svc-detail-restart-btn");
|
||||||
var restartResult = document.getElementById("svc-detail-restart-result");
|
var restartResult = document.getElementById("svc-detail-restart-result");
|
||||||
if (restartBtn && restartResult) {
|
if (restartBtn && restartResult) {
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ let _servicesCache = [];
|
|||||||
let _categoryLabels = {};
|
let _categoryLabels = {};
|
||||||
let _updateLog = "";
|
let _updateLog = "";
|
||||||
let _updatePollTimer = null;
|
let _updatePollTimer = null;
|
||||||
|
let _updatePollInFlight = false;
|
||||||
let _updateLogOffset = 0;
|
let _updateLogOffset = 0;
|
||||||
|
let _updateVisibleLogChars = 0;
|
||||||
let _serverWasDown = false;
|
let _serverWasDown = false;
|
||||||
let _updateFinished = false;
|
let _updateFinished = false;
|
||||||
|
let _updateStatusUnavailable = false;
|
||||||
|
let _updatePollFailures = 0; // consecutive failed update-status polls
|
||||||
let _supportTimerInt = null;
|
let _supportTimerInt = null;
|
||||||
let _supportEnabledAt = null;
|
let _supportEnabledAt = null;
|
||||||
let _supportStatus = null; // last fetched /api/support/status payload
|
let _supportStatus = null; // last fetched /api/support/status payload
|
||||||
@@ -23,8 +27,10 @@ let _featuresData = null;
|
|||||||
let _rebuildLog = "";
|
let _rebuildLog = "";
|
||||||
let _rebuildLogOffset = 0;
|
let _rebuildLogOffset = 0;
|
||||||
let _rebuildPollTimer = null;
|
let _rebuildPollTimer = null;
|
||||||
|
let _rebuildPollInFlight = false;
|
||||||
let _rebuildFinished = false;
|
let _rebuildFinished = false;
|
||||||
let _rebuildServerDown = false;
|
let _rebuildServerDown = false;
|
||||||
|
let _rebuildPollFailures = 0; // consecutive failed rebuild-status polls
|
||||||
let _pendingToggle = null; // {feature, extra} waiting for domain/confirm
|
let _pendingToggle = null; // {feature, extra} waiting for domain/confirm
|
||||||
let _rebuildFeatureName = "";
|
let _rebuildFeatureName = "";
|
||||||
let _rebuildIsEnabling = true;
|
let _rebuildIsEnabling = true;
|
||||||
@@ -46,6 +52,7 @@ const $modalStatus = document.getElementById("modal-status");
|
|||||||
const $modalLog = document.getElementById("modal-log");
|
const $modalLog = document.getElementById("modal-log");
|
||||||
const $btnReboot = document.getElementById("btn-reboot");
|
const $btnReboot = document.getElementById("btn-reboot");
|
||||||
const $btnSave = document.getElementById("btn-save-report");
|
const $btnSave = document.getElementById("btn-save-report");
|
||||||
|
const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
|
||||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||||
|
|
||||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||||
|
|||||||
@@ -4,14 +4,6 @@
|
|||||||
// Keyed by tileId: { progress: float, timestamp: ms }
|
// Keyed by tileId: { progress: float, timestamp: ms }
|
||||||
var _btcSyncPrev = {};
|
var _btcSyncPrev = {};
|
||||||
|
|
||||||
// ── BIP-110 badge helper ──────────────────────────────────────────
|
|
||||||
|
|
||||||
function _renderBip110Badge(bip110) {
|
|
||||||
if (!bip110) return '';
|
|
||||||
var state = bip110.state || 'unknown';
|
|
||||||
var cfg = BIP110_BADGE_CONFIG[state] || BIP110_BADGE_CONFIG.unknown;
|
|
||||||
return '<div class="tile-bip110-badge ' + cfg.cls + '" title="' + escHtml(cfg.title) + '">' + escHtml(cfg.label) + '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function _firstElementFromHtml(html) {
|
function _firstElementFromHtml(html) {
|
||||||
var tmp = document.createElement("div");
|
var tmp = document.createElement("div");
|
||||||
@@ -175,8 +167,7 @@ function buildTile(svc) {
|
|||||||
return tile;
|
return tile;
|
||||||
}
|
}
|
||||||
|
|
||||||
var bip110Badge = (svc.icon === 'bip110') ? _renderBip110Badge(svc.bip110) : '';
|
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
|
||||||
|
|
||||||
tile.style.cursor = "pointer";
|
tile.style.cursor = "pointer";
|
||||||
tile.addEventListener("click", function() {
|
tile.addEventListener("click", function() {
|
||||||
@@ -244,23 +235,6 @@ function updateTiles(services) {
|
|||||||
var text = tile.querySelector(".status-text");
|
var text = tile.querySelector(".status-text");
|
||||||
if (dot) dot.className = "status-dot " + sc;
|
if (dot) dot.className = "status-dot " + sc;
|
||||||
if (text) text.textContent = st;
|
if (text) text.textContent = st;
|
||||||
// Update BIP-110 badge for bip110 tiles
|
|
||||||
if (svc.icon === 'bip110') {
|
|
||||||
var badgeHtml = _renderBip110Badge(svc.bip110);
|
|
||||||
var badgeEl = tile.querySelector(".tile-bip110-badge");
|
|
||||||
if (badgeEl) {
|
|
||||||
// Replace existing badge in-place
|
|
||||||
var newBadge = _firstElementFromHtml(badgeHtml);
|
|
||||||
if (newBadge) { badgeEl.replaceWith(newBadge); } else { badgeEl.remove(); }
|
|
||||||
} else if (badgeHtml) {
|
|
||||||
// Insert badge after the service name
|
|
||||||
var anchorEl = tile.querySelector(".tile-name");
|
|
||||||
if (anchorEl) {
|
|
||||||
var newBadgeEl = _firstElementFromHtml(badgeHtml);
|
|
||||||
if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,10 +271,19 @@ async function checkUpdates() {
|
|||||||
try {
|
try {
|
||||||
var data = await apiFetch("/api/updates/check");
|
var data = await apiFetch("/api/updates/check");
|
||||||
var hasUpdates = !!data.available;
|
var hasUpdates = !!data.available;
|
||||||
|
var updateStatus = data.status || "idle";
|
||||||
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
||||||
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
||||||
if (sidebarUpdateBtn) {
|
if (sidebarUpdateBtn) {
|
||||||
if (hasUpdates) {
|
if (updateStatus === "reboot_required") {
|
||||||
|
sidebarUpdateBtn.style.borderColor = "#e5a50a";
|
||||||
|
sidebarUpdateBtn.style.backgroundColor = "rgba(229, 165, 10, 0.10)";
|
||||||
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
|
||||||
|
} else if (updateStatus === "running") {
|
||||||
|
sidebarUpdateBtn.style.borderColor = "#3584e4";
|
||||||
|
sidebarUpdateBtn.style.backgroundColor = "rgba(53, 132, 228, 0.10)";
|
||||||
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
|
||||||
|
} else if (hasUpdates) {
|
||||||
sidebarUpdateBtn.style.borderColor = "#2ec27e";
|
sidebarUpdateBtn.style.borderColor = "#2ec27e";
|
||||||
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)";
|
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)";
|
||||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
||||||
|
|||||||
@@ -2,20 +2,45 @@
|
|||||||
|
|
||||||
// ── Update modal ──────────────────────────────────────────────────
|
// ── Update modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function openUpdateModal() {
|
async function openUpdateModal() {
|
||||||
if (!$modal) return;
|
if (!$modal) return;
|
||||||
apiFetch("/api/updates/check")
|
|
||||||
|
// Reattach before checking for new updates. This makes a browser reload,
|
||||||
|
// RDP reconnect, or suspended tab recover the authoritative systemd-backed
|
||||||
|
// state instead of starting over or claiming the system is merely up to date.
|
||||||
|
try {
|
||||||
|
var current = await apiFetchWithTimeout(
|
||||||
|
"/api/updates/status?offset=0",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
|
if (current.running || current.result === "reboot_required") {
|
||||||
|
showExistingUpdate(current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// The normal start path below has its own visible error handling.
|
||||||
|
}
|
||||||
|
|
||||||
|
apiFetchWithTimeout(
|
||||||
|
"/api/updates/check",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
)
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.available) {
|
if (!data.available) {
|
||||||
stopUpdatePoll();
|
stopUpdatePoll();
|
||||||
_updateLog = "";
|
_updateLog = "";
|
||||||
_updateLogOffset = 0;
|
_updateLogOffset = 0;
|
||||||
|
_updateVisibleLogChars = 0;
|
||||||
_updateFinished = true;
|
_updateFinished = true;
|
||||||
|
_updateStatusUnavailable = false;
|
||||||
if ($modalLog) $modalLog.textContent = "";
|
if ($modalLog) $modalLog.textContent = "";
|
||||||
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
||||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
if ($btnReboot) $btnReboot.style.display = "none";
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
if ($btnSave) $btnSave.style.display = "none";
|
if ($btnSave) $btnSave.style.display = "none";
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
$modal.classList.add("open");
|
$modal.classList.add("open");
|
||||||
return;
|
return;
|
||||||
@@ -27,22 +52,69 @@ function openUpdateModal() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _doOpenUpdateModal() {
|
function prepareUpdateModal() {
|
||||||
if (!$modal) return;
|
if (!$modal) return;
|
||||||
|
stopUpdatePoll();
|
||||||
_updateLog = "";
|
_updateLog = "";
|
||||||
_updateLogOffset = 0;
|
_updateLogOffset = 0;
|
||||||
|
_updateVisibleLogChars = 0;
|
||||||
|
_updatePollInFlight = false;
|
||||||
_serverWasDown = false;
|
_serverWasDown = false;
|
||||||
_updateFinished = false;
|
_updateFinished = false;
|
||||||
|
_updateStatusUnavailable = false;
|
||||||
|
_updatePollFailures = 0;
|
||||||
if ($modalLog) $modalLog.textContent = "";
|
if ($modalLog) $modalLog.textContent = "";
|
||||||
if ($modalStatus) $modalStatus.textContent = "Starting update…";
|
if ($modalStatus) $modalStatus.textContent = "Starting update…";
|
||||||
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||||
if ($btnReboot) $btnReboot.style.display = "none";
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
if ($btnSave) $btnSave.style.display = "none";
|
if ($btnSave) $btnSave.style.display = "none";
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||||
$modal.classList.add("open");
|
$modal.classList.add("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _doOpenUpdateModal() {
|
||||||
|
prepareUpdateModal();
|
||||||
startUpdate();
|
startUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showExistingUpdate(data) {
|
||||||
|
prepareUpdateModal();
|
||||||
|
if (data.log) appendLog(data.log);
|
||||||
|
_updateLogOffset = Number(data.offset) || 0;
|
||||||
|
|
||||||
|
if (data.running) {
|
||||||
|
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||||
|
startUpdatePoll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateFinished = true;
|
||||||
|
if (data.result === "reboot_required") {
|
||||||
|
onUpdateDone("reboot_required");
|
||||||
|
} else if (data.result === "success") {
|
||||||
|
onUpdateDone(true);
|
||||||
|
} else {
|
||||||
|
onUpdateDone(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreUpdateModalIfNeeded() {
|
||||||
|
if (!$modal || $modal.classList.contains("open")) return;
|
||||||
|
try {
|
||||||
|
var data = await apiFetchWithTimeout(
|
||||||
|
"/api/updates/status?offset=0",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
|
if (data.running || data.result === "reboot_required") {
|
||||||
|
showExistingUpdate(data);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Dashboard startup must remain usable when status cannot be reached.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeUpdateModal() {
|
function closeUpdateModal() {
|
||||||
if (!$modal) return;
|
if (!$modal) return;
|
||||||
$modal.classList.remove("open");
|
$modal.classList.remove("open");
|
||||||
@@ -52,21 +124,36 @@ function closeUpdateModal() {
|
|||||||
function appendLog(text) {
|
function appendLog(text) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
_updateLog += text;
|
_updateLog += text;
|
||||||
if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; }
|
if ($modalLog) {
|
||||||
|
// Appending a text node avoids reparsing/replacing the complete log on
|
||||||
|
// every two-second poll. Trim only occasionally once the visible log is
|
||||||
|
// large; the complete _updateLog remains available for error reports.
|
||||||
|
if (_updateVisibleLogChars + text.length > UPDATE_VISIBLE_LOG_MAX_CHARS) {
|
||||||
|
var tail = _updateLog.slice(-UPDATE_VISIBLE_LOG_TRIM_CHARS);
|
||||||
|
var notice = "[Earlier update output hidden from this view; it remains in the saved report.]\n\n";
|
||||||
|
$modalLog.textContent = notice + tail;
|
||||||
|
_updateVisibleLogChars = notice.length + tail.length;
|
||||||
|
} else {
|
||||||
|
$modalLog.appendChild(document.createTextNode(text));
|
||||||
|
_updateVisibleLogChars += text.length;
|
||||||
|
}
|
||||||
|
$modalLog.scrollTop = $modalLog.scrollHeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startUpdate() {
|
function startUpdate() {
|
||||||
fetch("/api/updates/run", { method: "POST" })
|
apiFetchWithTimeout(
|
||||||
.then(function(response) {
|
"/api/updates/run",
|
||||||
if (!response.ok) return response.text().then(function(t) { throw new Error(t); });
|
{ method: "POST" },
|
||||||
return response.json();
|
STATUS_POLL_FETCH_TIMEOUT * 2
|
||||||
})
|
)
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.status === "no_updates") {
|
if (data.status === "no_updates") {
|
||||||
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
||||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
if ($btnReboot) $btnReboot.style.display = "none";
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
if ($btnSave) $btnSave.style.display = "none";
|
if ($btnSave) $btnSave.style.display = "none";
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
_updateFinished = true;
|
_updateFinished = true;
|
||||||
return;
|
return;
|
||||||
@@ -82,6 +169,7 @@ function startUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startUpdatePoll() {
|
function startUpdatePoll() {
|
||||||
|
if (_updatePollTimer) clearInterval(_updatePollTimer);
|
||||||
pollUpdateStatus();
|
pollUpdateStatus();
|
||||||
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
|
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
@@ -91,32 +179,45 @@ function stopUpdatePoll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollUpdateStatus() {
|
async function pollUpdateStatus() {
|
||||||
if (_updateFinished) return;
|
// setInterval does not wait for an async callback. The guard prevents a slow
|
||||||
|
// request from creating overlapping, out-of-order status polls.
|
||||||
|
if (_updateFinished || _updatePollInFlight) return;
|
||||||
|
_updatePollInFlight = true;
|
||||||
try {
|
try {
|
||||||
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
|
var data = await apiFetchWithTimeout(
|
||||||
|
"/api/updates/status?offset=" + _updateLogOffset,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
|
_updatePollFailures = 0;
|
||||||
if (_serverWasDown) {
|
if (_serverWasDown) {
|
||||||
_serverWasDown = false;
|
_serverWasDown = false;
|
||||||
if (!data.running) {
|
if (!data.running) {
|
||||||
// The update finished while the server was restarting. Reset to
|
// The update finished while the server or browser connection was away.
|
||||||
// offset 0 and re-fetch so the complete log is shown from the top.
|
// Re-fetch from offset 0 so the final result and complete tail agree.
|
||||||
_updateLog = "";
|
_updateLog = "";
|
||||||
_updateLogOffset = 0;
|
_updateLogOffset = 0;
|
||||||
|
_updateVisibleLogChars = 0;
|
||||||
if ($modalLog) $modalLog.textContent = "";
|
if ($modalLog) $modalLog.textContent = "";
|
||||||
try {
|
try {
|
||||||
var fullData = await apiFetch("/api/updates/status?offset=0");
|
var fullData = await apiFetchWithTimeout(
|
||||||
|
"/api/updates/status?offset=0",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
if (fullData.log) appendLog(fullData.log);
|
if (fullData.log) appendLog(fullData.log);
|
||||||
_updateLogOffset = fullData.offset;
|
_updateLogOffset = fullData.offset;
|
||||||
} catch (e) {
|
data = fullData;
|
||||||
// If the re-fetch fails, fall through with whatever we have.
|
} catch (_) {
|
||||||
if (data.log) appendLog(data.log);
|
if (data.log) appendLog(data.log);
|
||||||
_updateLogOffset = data.offset;
|
_updateLogOffset = data.offset;
|
||||||
}
|
}
|
||||||
if (data.result === "reboot_required") {
|
if (data.result === "reboot_required") {
|
||||||
appendLog("[Server restarted — update completed, reboot required.]\n");
|
appendLog("[Reconnected — update completed, reboot required.]\n");
|
||||||
} else if (data.result === "success") {
|
} else if (data.result === "success") {
|
||||||
appendLog("[Server restarted — update completed successfully.]\n");
|
appendLog("[Reconnected — update completed successfully.]\n");
|
||||||
} else {
|
} else {
|
||||||
appendLog("[Server restarted — update encountered an error.]\n");
|
appendLog("[Reconnected — update encountered an error.]\n");
|
||||||
}
|
}
|
||||||
_updateFinished = true;
|
_updateFinished = true;
|
||||||
stopUpdatePoll();
|
stopUpdatePoll();
|
||||||
@@ -127,7 +228,7 @@ async function pollUpdateStatus() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appendLog("[Server reconnected]\n");
|
appendLog("[Update status reconnected]\n");
|
||||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||||
}
|
}
|
||||||
if (data.log) appendLog(data.log);
|
if (data.log) appendLog(data.log);
|
||||||
@@ -143,12 +244,58 @@ async function pollUpdateStatus() {
|
|||||||
onUpdateDone(false);
|
onUpdateDone(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
|
_updatePollFailures += 1;
|
||||||
|
if (_updatePollFailures >= STATUS_POLL_MAX_FAILURES) {
|
||||||
|
showUpdateStatusUnavailable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_serverWasDown) {
|
||||||
|
_serverWasDown = true;
|
||||||
|
appendLog("\n[Update status connection interrupted — retrying…]\n");
|
||||||
|
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_updatePollInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUpdateStatusUnavailable() {
|
||||||
|
_updateFinished = true;
|
||||||
|
_updateStatusUnavailable = true;
|
||||||
|
stopUpdatePoll();
|
||||||
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
|
if ($modalStatus) $modalStatus.textContent = "Update status unavailable — update may still be running";
|
||||||
|
appendLog("\n[The Hub could not confirm update status. The background update was not stopped. Select Retry Status after reconnecting.]\n");
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "inline-flex";
|
||||||
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryUpdateStatus() {
|
||||||
|
if (!$modal) return;
|
||||||
|
_updateFinished = false;
|
||||||
|
_updateStatusUnavailable = false;
|
||||||
|
_updatePollFailures = 0;
|
||||||
|
_serverWasDown = true;
|
||||||
|
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||||
|
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…";
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
|
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||||
|
startUpdatePoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeUpdateStatusAfterInterruption() {
|
||||||
|
if (!$modal || !$modal.classList.contains("open")) return;
|
||||||
|
if (_updateStatusUnavailable) {
|
||||||
|
retryUpdateStatus();
|
||||||
|
} else if (!_updateFinished) {
|
||||||
|
pollUpdateStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUpdateDone(result) {
|
function onUpdateDone(result) {
|
||||||
|
_updateStatusUnavailable = false;
|
||||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
if (result === true) {
|
if (result === true) {
|
||||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||||
@@ -175,6 +322,7 @@ function saveErrorReport() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Reboot ────────────────────────────────────────────────────────
|
// ── Reboot ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var _rebootStartTime = 0;
|
var _rebootStartTime = 0;
|
||||||
|
|||||||
@@ -364,17 +364,12 @@ async function loadStep3() {
|
|||||||
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
|
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
|
||||||
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
|
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
|
html += '<p class="onboarding-hint">Enter the address for each service and paste its update command from Njal.la.</p>';
|
||||||
|
|
||||||
// Router note (the same wording is shown again, per-service, whenever a
|
// Router note (the same wording is shown again, per-service, whenever a
|
||||||
// domain-based feature is enabled, and lives on each service tile afterwards)
|
// domain-based feature is enabled, and lives on each service tile afterwards)
|
||||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||||
+ renderRouterPortsHtml({
|
+ renderRouterPortsHtml({ internalIp: internalIp })
|
||||||
internalIp: internalIp,
|
|
||||||
plural: true,
|
|
||||||
includeSsh: true,
|
|
||||||
extraNote: "Element Call needs a few extra ports (some UDP), and you’ll be shown exactly which when you enable it.",
|
|
||||||
})
|
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
|
|
||||||
relevantDomains.forEach(function(d) {
|
relevantDomains.forEach(function(d) {
|
||||||
@@ -382,20 +377,20 @@ async function loadStep3() {
|
|||||||
html += '<div class="onboarding-domain-group">';
|
html += '<div class="onboarding-domain-group">';
|
||||||
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
|
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
|
||||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />';
|
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />';
|
||||||
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la DDNS Curl Command</label>';
|
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la Update Command</label>';
|
||||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl "https://njal.la/update/?h=...&k=...&auto"" />';
|
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl "https://njal.la/update/?h=...&k=...&auto"" />';
|
||||||
html += '<p class="onboarding-hint" style="margin-top:4px;">ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p>';
|
html += '<p class="onboarding-hint" style="margin-top:4px;">Paste the update command from Njal.la</p>';
|
||||||
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-domain="' + escHtml(d.name) + '" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-domain="' + escHtml(d.name) + '" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
||||||
html += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
html += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSL email section
|
// Contact email section
|
||||||
var emailVal = domainValues["sslemail"] || "";
|
var emailVal = domainValues["sslemail"] || "";
|
||||||
html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
|
html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
|
||||||
html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>';
|
html += '<label class="onboarding-domain-label">📧 Email Address</label>';
|
||||||
html += '<p class="onboarding-hint onboarding-hint--inline">Let\'s Encrypt uses this for certificate expiry notifications.</p>';
|
html += '<p class="onboarding-hint onboarding-hint--inline">Used to send important notices about your services.</p>';
|
||||||
html += '<input class="onboarding-domain-input domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" value="' + escHtml(emailVal) + '" />';
|
html += '<input class="onboarding-domain-input domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" value="' + escHtml(emailVal) + '" />';
|
||||||
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-email="true" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-email="true" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
||||||
html += '<span class="onboarding-domain-save-status" id="domain-save-status-email" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
html += '<span class="onboarding-domain-save-status" id="domain-save-status-email" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
||||||
|
<button class="btn btn-save" id="btn-retry-update-status" style="display:none">Retry Status</button>
|
||||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
||||||
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,22 +197,14 @@
|
|||||||
encrypted messaging, password management, cloud storage, website hosting, and more.
|
encrypted messaging, password management, cloud storage, website hosting, and more.
|
||||||
</p>
|
</p>
|
||||||
<div class="upgrade-info-box">
|
<div class="upgrade-info-box">
|
||||||
<p class="upgrade-info-title">⚠ What you should know:</p>
|
<p class="upgrade-info-title">Simple, guided setup:</p>
|
||||||
<ul class="upgrade-info-list">
|
<ul class="upgrade-info-list">
|
||||||
<li>You will need to purchase domains for your services — <a href="https://njal.la" target="_blank" rel="noopener noreferrer">Njal.la</a> is the only supported domain provider</li>
|
<li>Sovran_SystemsOS walks you through getting your domain from <a href="https://njal.la" target="_blank" rel="noopener noreferrer">Njal.la</a> and connecting your services</li>
|
||||||
<li>Some services require ports to be opened on your router</li>
|
<li>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</li>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="upgrade-info-box">
|
|
||||||
<p class="upgrade-info-title">ℹ️ Good to know:</p>
|
|
||||||
<ul class="upgrade-info-list">
|
|
||||||
<li>Your services will be accessible via your home internet connection. Your approximate geographic area may be visible through DNS records — this is normal for all self-hosted services</li>
|
|
||||||
<li>Your Bitcoin node remains fully private over Tor</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p class="support-desc">
|
<p class="support-desc">
|
||||||
<strong>Don't worry</strong> — the Hub will walk you through every step after the upgrade.
|
The Hub guides you through every step.
|
||||||
Domain setup, port forwarding, and configuration are all guided.
|
|
||||||
</p>
|
</p>
|
||||||
<p class="support-desc upgrade-rebuild-note">
|
<p class="support-desc upgrade-rebuild-note">
|
||||||
The system will rebuild after upgrading. This may take several minutes.
|
The system will rebuild after upgrading. This may take several minutes.
|
||||||
|
|||||||
@@ -123,10 +123,10 @@
|
|||||||
<div class="onboarding-panel" id="step-3" style="display:none">
|
<div class="onboarding-panel" id="step-3" style="display:none">
|
||||||
<div class="onboarding-step-header">
|
<div class="onboarding-step-header">
|
||||||
<span class="onboarding-step-icon">🌐</span>
|
<span class="onboarding-step-icon">🌐</span>
|
||||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
<h2 class="onboarding-step-title">Connect Your Services</h2>
|
||||||
<p class="onboarding-step-desc">
|
<p class="onboarding-step-desc">
|
||||||
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS, and your router needs ports <strong>80</strong> and <strong>443</strong> (TCP) forwarded to this computer.
|
Sovran_SystemsOS walks you through getting your domain from <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> and connecting your services.
|
||||||
Everything you need — Njal.la account, Dynamic records, and the one router task — is laid out step by step below.
|
To make your services available outside your home, you’ll complete one router task: forward ports <strong>80</strong> and <strong>443</strong> to this computer.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-card" id="step-3-body">
|
<div class="onboarding-card" id="step-3-body">
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
"bitcoind.service": "27.1.0",
|
"bitcoind.service": "27.1.0",
|
||||||
"electrs.service": "0.10.6",
|
"electrs.service": "0.10.6",
|
||||||
"lnd.service": "0.18.0",
|
"lnd.service": "0.18.0",
|
||||||
"rtl.service": "0.15.8",
|
"rtl.service": "0.15.10",
|
||||||
"btcpayserver.service": "2.4.2",
|
"btcpayserver.service": "2.4.2",
|
||||||
"albyhub.service": "1.8.0",
|
"albyhub.service": "1.24.0",
|
||||||
"mempool.service": "3.2.1",
|
"mempool.service": "3.2.1",
|
||||||
"matrix-synapse.service": "1.115.0",
|
"matrix-synapse.service": "1.115.0",
|
||||||
"livekit.service": "1.5.2",
|
"livekit.service": "1.5.2",
|
||||||
|
|||||||
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
@@ -145,12 +145,13 @@
|
|||||||
ranger fastfetch gedit openssl pwgen
|
ranger fastfetch gedit openssl pwgen
|
||||||
aspell aspellDicts.en lm_sensors
|
aspell aspellDicts.en lm_sensors
|
||||||
hunspell hunspellDicts.en_US
|
hunspell hunspellDicts.en_US
|
||||||
synadm brave dua
|
synadm brave-origin dua
|
||||||
gparted pv unzip parted screen zenity
|
gparted pv unzip parted screen zenity
|
||||||
libargon2 gnome-terminal libreoffice-fresh
|
libargon2 gnome-terminal libreoffice-fresh
|
||||||
dig firefox wp-cli axel
|
dig firefox wp-cli axel
|
||||||
lk-jwt-service livekit-libwebrtc livekit
|
lk-jwt-service livekit-libwebrtc livekit
|
||||||
matrix-synapse age onlyoffice-desktopeditors
|
matrix-synapse age onlyoffice-desktopeditors
|
||||||
|
tor-browser
|
||||||
];
|
];
|
||||||
|
|
||||||
# ── Shell ──────────────────────────────────────────────────
|
# ── Shell ──────────────────────────────────────────────────
|
||||||
|
|||||||
Generated
+72
-19
@@ -5,11 +5,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785778881,
|
"lastModified": 1787839684,
|
||||||
"narHash": "sha256-yHJJTm3o7ZhiVp68G+hh+dz1GKV+ACOT6mQj+eYvJLQ=",
|
"narHash": "sha256-xTafqsVs2/zyGsaCel41QT5Lpbs/rG56Kp+YsmLXL8I=",
|
||||||
"owner": "emmanuelrosa",
|
"owner": "emmanuelrosa",
|
||||||
"repo": "btc-clients-nix",
|
"repo": "btc-clients-nix",
|
||||||
"rev": "fc1aca94d839f82e7501fe0d01279d5c1fba6e63",
|
"rev": "ef81159876930802ef9861aa5c039cec6886a30f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -26,11 +26,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782949081,
|
"lastModified": 1785627969,
|
||||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -56,11 +56,27 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786201459,
|
"lastModified": 1787753485,
|
||||||
"narHash": "sha256-CiOTEjmwAmG2AWnaIno9YaCJJmpca2FXPhMAsnrolCg=",
|
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "8b8c811c7c2541c30382c5de7ed26be055569c60",
|
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-26.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-stable_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1787753485,
|
||||||
|
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -72,11 +88,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786106723,
|
"lastModified": 1787498568,
|
||||||
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
|
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
|
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -88,11 +104,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_3": {
|
"nixpkgs_3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784555310,
|
"lastModified": 1787364730,
|
||||||
"narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=",
|
"narHash": "sha256-NcYt9QJfpJiF1lAyN8BDPB4EeScbPU+EwQqPiBElrpU=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493",
|
"rev": "a831408e6378bc02ebf8cc09b52c96ca86f6bab4",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -102,6 +118,22 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nixpkgs_4": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1787736819,
|
||||||
|
"narHash": "sha256-cV5xEJJK3BvhU8rEd4mC9UsmDi5qscv/kzGPhBRC5WA=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixvim": {
|
"nixvim": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
@@ -109,11 +141,11 @@
|
|||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785763201,
|
"lastModified": 1787691219,
|
||||||
"narHash": "sha256-wA373y/B9orM3HatLu9oS+Ke5lmdZyBl/bdjd2gLMq4=",
|
"narHash": "sha256-CjeJTOjrluiDuL0W/YBFkSBOQjBYmdKwj7hWAJ4PIh0=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "c7be49306b23a952c0151cf4bbeacd944ed82f2a",
|
"rev": "ebec1ae277a50b16e7d6682edd009ef585d68261",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -127,10 +159,31 @@
|
|||||||
"btc-clients": "btc-clients",
|
"btc-clients": "btc-clients",
|
||||||
"nixpkgs": "nixpkgs_2",
|
"nixpkgs": "nixpkgs_2",
|
||||||
"nixpkgs-stable": "nixpkgs-stable",
|
"nixpkgs-stable": "nixpkgs-stable",
|
||||||
"nixvim": "nixvim"
|
"nixvim": "nixvim",
|
||||||
|
"sovran-bitcoin": "sovran-bitcoin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sovran-bitcoin": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs_4",
|
||||||
|
"nixpkgs-stable": "nixpkgs-stable_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1788225336,
|
||||||
|
"narHash": "sha256-ixw/ChkLWWrLOzoiSa9n1g2g5KDIVkedC6eYFCba+gA=",
|
||||||
|
"owner": "naturallaw777",
|
||||||
|
"repo": "Sovran_Bitcoin",
|
||||||
|
"rev": "37caecaa3f3666231c8ec82a9a9a0df53fa40910",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "naturallaw777",
|
||||||
|
"repo": "Sovran_Bitcoin",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"systems": {
|
||||||
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774449309,
|
"lastModified": 1774449309,
|
||||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
nixvim.url = "github:nix-community/nixvim";
|
nixvim.url = "github:nix-community/nixvim";
|
||||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
|
|
||||||
|
# Bitcoin / Lightning stack — standalone flake, consumed as a module.
|
||||||
|
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, ... }:
|
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, sovran-bitcoin, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
overlay-stable = final: prev: {
|
overlay-stable = final: prev: {
|
||||||
@@ -33,7 +36,8 @@
|
|||||||
modules = [
|
modules = [
|
||||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||||
./iso/common.nix
|
./iso/common.nix
|
||||||
./modules/bitcoin
|
sovran-bitcoin.nixosModules.default
|
||||||
|
./modules/sovran-bitcoin-integration.nix
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -44,7 +48,8 @@
|
|||||||
nixpkgs.overlays = [ overlay-stable ];
|
nixpkgs.overlays = [ overlay-stable ];
|
||||||
})
|
})
|
||||||
./configuration.nix
|
./configuration.nix
|
||||||
./modules/bitcoin
|
sovran-bitcoin.nixosModules.default
|
||||||
|
./modules/sovran-bitcoin-integration.nix
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
config = {
|
config = {
|
||||||
@@ -60,17 +65,9 @@
|
|||||||
pkgs = import nixpkgs {
|
pkgs = import nixpkgs {
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
};
|
};
|
||||||
fetchNodeModules =
|
|
||||||
pkgs.callPackage ./packages/build-support/fetch-node-modules.nix {};
|
|
||||||
mempoolPkgs =
|
|
||||||
pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; };
|
|
||||||
in {
|
in {
|
||||||
bitcoin-btcpay-hardening = import ./tests/bitcoin-btcpay-hardening.nix {
|
# Bitcoin hardening and package checks now live in the Sovran_Bitcoin flake.
|
||||||
inherit nixpkgs overlay-stable;
|
# Run them with: nix build github:naturallaw777/Sovran_Bitcoin#checks.x86_64-linux
|
||||||
};
|
|
||||||
mempool-backend = mempoolPkgs.mempool-backend;
|
|
||||||
mempool-frontend = mempoolPkgs.mempool-frontend;
|
|
||||||
rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; };
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-35
@@ -423,40 +423,6 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
sep.set_margin_end(40)
|
sep.set_margin_end(40)
|
||||||
outer.append(sep)
|
outer.append(sep)
|
||||||
|
|
||||||
notice_frame = Gtk.Frame()
|
|
||||||
notice_frame.add_css_class("card")
|
|
||||||
notice_frame.set_margin_start(40)
|
|
||||||
notice_frame.set_margin_end(40)
|
|
||||||
notice_frame.set_margin_top(20)
|
|
||||||
notice_frame.set_margin_bottom(4)
|
|
||||||
|
|
||||||
notice_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
|
||||||
notice_box.set_margin_top(12)
|
|
||||||
notice_box.set_margin_bottom(12)
|
|
||||||
notice_box.set_margin_start(16)
|
|
||||||
notice_box.set_margin_end(16)
|
|
||||||
|
|
||||||
notice_icon = symbolic_icon("dialog-information-symbolic")
|
|
||||||
notice_icon.set_valign(Gtk.Align.START)
|
|
||||||
notice_box.append(notice_icon)
|
|
||||||
|
|
||||||
notice_lbl = Gtk.Label()
|
|
||||||
notice_lbl.set_use_markup(True)
|
|
||||||
notice_lbl.set_wrap(True)
|
|
||||||
notice_lbl.set_xalign(0)
|
|
||||||
notice_lbl.set_halign(Gtk.Align.FILL)
|
|
||||||
notice_lbl.set_markup(
|
|
||||||
"<span weight='bold'>Heads up — Server + Desktop prerequisites</span>\n"
|
|
||||||
"• A domain or subdomain from <span weight='bold'>https://njal.la</span>\n"
|
|
||||||
"• The ability to open / forward ports on your router\n\n"
|
|
||||||
"Don't worry — after install, the onboarding wizard walks you through every step.\n"
|
|
||||||
"<span size='small'>Desktop Only and Node Only do not require a domain or port forwarding.</span>"
|
|
||||||
)
|
|
||||||
notice_box.append(notice_lbl)
|
|
||||||
|
|
||||||
notice_frame.set_child(notice_box)
|
|
||||||
outer.append(notice_frame)
|
|
||||||
|
|
||||||
if self.virtualization:
|
if self.virtualization:
|
||||||
vm_frame = Gtk.Frame()
|
vm_frame = Gtk.Frame()
|
||||||
vm_frame.add_css_class("card")
|
vm_frame.add_css_class("card")
|
||||||
@@ -505,7 +471,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
# Role cards
|
# Role cards
|
||||||
roles = [
|
roles = [
|
||||||
("Server + Desktop",
|
("Server + Desktop",
|
||||||
"Full sovereign experience: beautiful desktop, your own cloud, secure messaging, Bitcoin node, and more.",
|
"Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer.",
|
||||||
"Server+Desktop"),
|
"Server+Desktop"),
|
||||||
("Desktop Only",
|
("Desktop Only",
|
||||||
"A beautiful, easy-to-use desktop without the background server applications.",
|
"A beautiful, easy-to-use desktop without the background server applications.",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# RPC calls that are safe for public use
|
|
||||||
# Vendored from nix-bitcoin - do not fetch from upstream at runtime
|
|
||||||
[
|
|
||||||
"echo"
|
|
||||||
"getinfo"
|
|
||||||
"getindexinfo"
|
|
||||||
"help"
|
|
||||||
"ping"
|
|
||||||
"uptime"
|
|
||||||
# Blockchain
|
|
||||||
"getbestblockhash"
|
|
||||||
"getblock"
|
|
||||||
"getblockchaininfo"
|
|
||||||
"getblockcount"
|
|
||||||
"getblockfilter"
|
|
||||||
"getblockfrompeer"
|
|
||||||
"getblockhash"
|
|
||||||
"getblockheader"
|
|
||||||
"getblockstats"
|
|
||||||
"getchaintips"
|
|
||||||
"getchaintxstats"
|
|
||||||
"getdeploymentinfo"
|
|
||||||
"getdifficulty"
|
|
||||||
"getmempoolancestors"
|
|
||||||
"getmempooldescendants"
|
|
||||||
"getmempoolentry"
|
|
||||||
"getmempoolinfo"
|
|
||||||
"getrawmempool"
|
|
||||||
"gettxout"
|
|
||||||
"gettxoutproof"
|
|
||||||
"gettxoutsetinfo"
|
|
||||||
"scantxoutset"
|
|
||||||
"verifytxoutproof"
|
|
||||||
# Mining
|
|
||||||
"getblocktemplate"
|
|
||||||
"getmininginfo"
|
|
||||||
"getnetworkhashps"
|
|
||||||
# Network
|
|
||||||
"getnetworkinfo"
|
|
||||||
"getnodeaddresses"
|
|
||||||
"getpeerinfo"
|
|
||||||
# Rawtransactions
|
|
||||||
"analyzepsbt"
|
|
||||||
"combinepsbt"
|
|
||||||
"combinerawtransaction"
|
|
||||||
"converttopsbt"
|
|
||||||
"createpsbt"
|
|
||||||
"createrawtransaction"
|
|
||||||
"decodepsbt"
|
|
||||||
"decoderawtransaction"
|
|
||||||
"decodescript"
|
|
||||||
"finalizepsbt"
|
|
||||||
"fundrawtransaction"
|
|
||||||
"getrawtransaction"
|
|
||||||
"joinpsbts"
|
|
||||||
"sendrawtransaction"
|
|
||||||
"signrawtransactionwithkey"
|
|
||||||
"testmempoolaccept"
|
|
||||||
"utxoupdatepsbt"
|
|
||||||
# Util
|
|
||||||
"createmultisig"
|
|
||||||
"deriveaddresses"
|
|
||||||
"estimatesmartfee"
|
|
||||||
"getdescriptorinfo"
|
|
||||||
"signmessagewithprivkey"
|
|
||||||
"validateaddress"
|
|
||||||
"verifymessage"
|
|
||||||
# Zmq
|
|
||||||
"getzmqnotifications"
|
|
||||||
]
|
|
||||||
@@ -1,506 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options = {
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = mkEnableOption "Bitcoin daemon";
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for peer connections.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = if !cfg.regtest then 8333 else 18444;
|
|
||||||
defaultText = "if !cfg.regtest then 8333 else 18444";
|
|
||||||
description = "Port to listen for peer connections.";
|
|
||||||
};
|
|
||||||
onionPort = mkOption {
|
|
||||||
type = types.nullOr types.port;
|
|
||||||
# When the bitcoind onion service is enabled, add an onion-tagged socket
|
|
||||||
# to distinguish local connections from Tor connections
|
|
||||||
default = if (config.nix-bitcoin.onionServices.bitcoind.enable or false) then 8334 else null;
|
|
||||||
description = ''
|
|
||||||
Port to listen for Tor peer connections.
|
|
||||||
If set, inbound connections to this port are tagged as onion peers.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
listen = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Listen for peer connections at `address:port`
|
|
||||||
and `address:onionPort` (if {option}`onionPort` is set).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
listenWhitelisted = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Listen for peer connections at `address:whitelistedPort`.
|
|
||||||
Peers connected through this socket are automatically whitelisted.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
whitelistedPort = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 8335;
|
|
||||||
description = "See `listenWhitelisted`.";
|
|
||||||
};
|
|
||||||
getPublicAddressCmd = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "";
|
|
||||||
description = ''
|
|
||||||
Bash expression which outputs the public service address to announce to peers.
|
|
||||||
If left empty, no address is announced.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = pkgs.bitcoind;
|
|
||||||
defaultText = "pkgs.bitcoind";
|
|
||||||
description = ''
|
|
||||||
The package providing bitcoind binaries.
|
|
||||||
|
|
||||||
You can use this option to select other bitcoind-compatible implementations.
|
|
||||||
Example:
|
|
||||||
```nix
|
|
||||||
services.bitcoind.package = pkgs.bitcoind-knots;
|
|
||||||
```
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
extraConfig = mkOption {
|
|
||||||
type = types.lines;
|
|
||||||
default = "";
|
|
||||||
example = ''
|
|
||||||
par=16
|
|
||||||
logips=1
|
|
||||||
'';
|
|
||||||
description = "Extra lines appended to {file}`bitcoin.conf`.";
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/bitcoind";
|
|
||||||
description = "The data directory for bitcoind.";
|
|
||||||
};
|
|
||||||
rpc = {
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = ''
|
|
||||||
Address to listen for JSON-RPC connections.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = if !cfg.regtest then 8332 else 18443;
|
|
||||||
defaultText = "if !cfg.regtest then 8332 else 18443";
|
|
||||||
description = "Port to listen for JSON-RPC connections.";
|
|
||||||
};
|
|
||||||
threads = mkOption {
|
|
||||||
type = types.nullOr types.ints.u16;
|
|
||||||
default = null;
|
|
||||||
description = "The number of threads to service RPC calls.";
|
|
||||||
};
|
|
||||||
allowip = mkOption {
|
|
||||||
type = types.listOf types.str;
|
|
||||||
default = [ "127.0.0.1" ];
|
|
||||||
description = ''
|
|
||||||
Allow JSON-RPC connections from specified sources.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
users = mkOption {
|
|
||||||
default = {};
|
|
||||||
description = ''
|
|
||||||
Allowed users for JSON-RPC connections.
|
|
||||||
'';
|
|
||||||
example = {
|
|
||||||
alice = {
|
|
||||||
passwordHMAC = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
|
|
||||||
rpcwhitelist = [ "sendtoaddress" "getnewaddress" ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
type = with types; attrsOf (submodule ({ name, ... }: {
|
|
||||||
options = {
|
|
||||||
name = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = name;
|
|
||||||
example = "alice";
|
|
||||||
description = ''
|
|
||||||
Username for JSON-RPC connections.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
passwordHMAC = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
example = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
|
|
||||||
description = ''
|
|
||||||
Password HMAC-SHA-256 for JSON-RPC connections. Must be a string of the
|
|
||||||
format `<SALT-HEX>$<HMAC-HEX>`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
passwordHMACFromFile = mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
internal = true;
|
|
||||||
default = false;
|
|
||||||
};
|
|
||||||
rpcwhitelist = mkOption {
|
|
||||||
type = types.listOf types.str;
|
|
||||||
default = [];
|
|
||||||
description = ''
|
|
||||||
List of allowed rpc calls for each user.
|
|
||||||
If empty list, rpcwhitelist is disabled for that user.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
};
|
|
||||||
regtest = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable regtest mode.";
|
|
||||||
};
|
|
||||||
network = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = if cfg.regtest then "regtest" else "mainnet";
|
|
||||||
};
|
|
||||||
makeNetworkName = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = mainnet: regtest: if cfg.regtest then regtest else mainnet;
|
|
||||||
};
|
|
||||||
proxy = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
|
|
||||||
description = "Connect through SOCKS5 proxy";
|
|
||||||
};
|
|
||||||
i2p = mkOption {
|
|
||||||
type = types.enum [ false true "only-outgoing" ];
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable peer connections via i2p.
|
|
||||||
With `only-outgoing`, incoming i2p connections are disabled.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
dataDirReadableByGroup = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
If enabled, data dir content is readable by the bitcoind service group.
|
|
||||||
Warning: This disables bitcoind's wallet support.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
sysperms = mkOption {
|
|
||||||
type = types.nullOr types.bool;
|
|
||||||
default = null;
|
|
||||||
description = ''
|
|
||||||
Create new files with system default permissions, instead of umask 077
|
|
||||||
(only effective with disabled wallet functionality)
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
disablewallet = mkOption {
|
|
||||||
type = types.nullOr types.bool;
|
|
||||||
default = null;
|
|
||||||
description = ''
|
|
||||||
Do not load the wallet and disable wallet RPC calls
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
dbCache = mkOption {
|
|
||||||
type = types.nullOr (intAtLeast 4);
|
|
||||||
default = null;
|
|
||||||
example = 4000;
|
|
||||||
description = "Override the default database cache size in MiB.";
|
|
||||||
};
|
|
||||||
prune = mkOption {
|
|
||||||
type = types.ints.unsigned;
|
|
||||||
default = 0;
|
|
||||||
example = 10000;
|
|
||||||
description = ''
|
|
||||||
Automatically prune block files to stay under the specified target size in MiB.
|
|
||||||
Value 0 disables pruning.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
txindex = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable the transaction index.";
|
|
||||||
};
|
|
||||||
zmqpubrawblock = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = null;
|
|
||||||
example = "tcp://127.0.0.1:28332";
|
|
||||||
description = "ZMQ address for zmqpubrawblock notifications";
|
|
||||||
};
|
|
||||||
zmqpubrawtx = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = null;
|
|
||||||
example = "tcp://127.0.0.1:28333";
|
|
||||||
description = "ZMQ address for zmqpubrawtx notifications";
|
|
||||||
};
|
|
||||||
assumevalid = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = null;
|
|
||||||
example = "00000000000000000000e5abc3a74fe27dc0ead9c70ea1deb456f11c15fd7bc6";
|
|
||||||
description = ''
|
|
||||||
If this block is in the chain assume that it and its ancestors are
|
|
||||||
valid and potentially skip their script verification.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
addnodes = mkOption {
|
|
||||||
type = types.listOf types.str;
|
|
||||||
default = [];
|
|
||||||
example = [ "ecoc5q34tmbq54wl.onion" ];
|
|
||||||
description = "Add nodes to connect to and attempt to keep the connections open";
|
|
||||||
};
|
|
||||||
discover = mkOption {
|
|
||||||
type = types.nullOr types.bool;
|
|
||||||
default = null;
|
|
||||||
description = "Discover own IP addresses";
|
|
||||||
};
|
|
||||||
addresstype = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = null;
|
|
||||||
example = "bech32";
|
|
||||||
description = "The type of addresses to use";
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "bitcoin";
|
|
||||||
description = "The user as which to run bitcoind.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run bitcoind.";
|
|
||||||
};
|
|
||||||
cli = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
type = types.package;
|
|
||||||
default = pkgs.writers.writeBashBin "bitcoin-cli" ''
|
|
||||||
exec ${cfg.package}/bin/bitcoin-cli -datadir='${cfg.dataDir}' "$@"
|
|
||||||
'';
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = "Binary to connect with the bitcoind instance.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.bitcoind;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
|
|
||||||
i2pSAM = config.services.i2pd.proto.sam;
|
|
||||||
|
|
||||||
configFile = builtins.toFile "bitcoin.conf" ''
|
|
||||||
# We're already logging via journald
|
|
||||||
nodebuglogfile=1
|
|
||||||
logtimestamps=0
|
|
||||||
|
|
||||||
startupnotify=/run/current-system/systemd/bin/systemd-notify --ready
|
|
||||||
|
|
||||||
${optionalString cfg.regtest ''
|
|
||||||
regtest=1
|
|
||||||
[regtest]
|
|
||||||
''}
|
|
||||||
${optionalString (cfg.dbCache != null) "dbcache=${toString cfg.dbCache}"}
|
|
||||||
prune=${toString cfg.prune}
|
|
||||||
${optionalString cfg.txindex "txindex=1"}
|
|
||||||
${optionalString (cfg.sysperms != null) "sysperms=${if cfg.sysperms then "1" else "0"}"}
|
|
||||||
${optionalString (cfg.disablewallet != null) "disablewallet=${if cfg.disablewallet then "1" else "0"}"}
|
|
||||||
${optionalString (cfg.assumevalid != null) "assumevalid=${cfg.assumevalid}"}
|
|
||||||
|
|
||||||
# Connection options
|
|
||||||
listen=${if (cfg.listen || cfg.listenWhitelisted) then "1" else "0"}
|
|
||||||
${optionalString cfg.listen
|
|
||||||
"bind=${cfg.address}:${toString cfg.port}"}
|
|
||||||
${optionalString (cfg.listen && cfg.onionPort != null)
|
|
||||||
"bind=${cfg.address}:${toString cfg.onionPort}=onion"}
|
|
||||||
${optionalString cfg.listenWhitelisted
|
|
||||||
"whitebind=${cfg.address}:${toString cfg.whitelistedPort}"}
|
|
||||||
${optionalString (cfg.proxy != null) "proxy=${cfg.proxy}"}
|
|
||||||
${optionalString (cfg.i2p != false) "i2psam=${nbLib.addressWithPort i2pSAM.address i2pSAM.port}"}
|
|
||||||
${optionalString (cfg.i2p == "only-outgoing") "i2pacceptincoming=0"}
|
|
||||||
|
|
||||||
${optionalString (cfg.discover != null) "discover=${if cfg.discover then "1" else "0"}"}
|
|
||||||
${lib.concatMapStrings (node: "addnode=${node}\n") cfg.addnodes}
|
|
||||||
|
|
||||||
# RPC server options
|
|
||||||
rpcbind=${cfg.rpc.address}
|
|
||||||
rpcport=${toString cfg.rpc.port}
|
|
||||||
rpcconnect=${cfg.rpc.address}
|
|
||||||
${optionalString (cfg.rpc.threads != null) "rpcthreads=${toString cfg.rpc.threads}"}
|
|
||||||
rpcwhitelistdefault=0
|
|
||||||
${concatMapStrings (user: ''
|
|
||||||
${optionalString (!user.passwordHMACFromFile) "rpcauth=${user.name}:${user.passwordHMAC}"}
|
|
||||||
${optionalString (user.rpcwhitelist != [])
|
|
||||||
"rpcwhitelist=${user.name}:${lib.strings.concatStringsSep "," user.rpcwhitelist}"}
|
|
||||||
'') (builtins.attrValues cfg.rpc.users)
|
|
||||||
}
|
|
||||||
${lib.concatMapStrings (rpcallowip: "rpcallowip=${rpcallowip}\n") cfg.rpc.allowip}
|
|
||||||
|
|
||||||
# Wallet options
|
|
||||||
${optionalString (cfg.addresstype != null) "addresstype=${cfg.addresstype}"}
|
|
||||||
|
|
||||||
# ZMQ options
|
|
||||||
${optionalString (cfg.zmqpubrawblock != null) "zmqpubrawblock=${cfg.zmqpubrawblock}"}
|
|
||||||
${optionalString (cfg.zmqpubrawtx != null) "zmqpubrawtx=${cfg.zmqpubrawtx}"}
|
|
||||||
|
|
||||||
# Extra options
|
|
||||||
${cfg.extraConfig}
|
|
||||||
'';
|
|
||||||
|
|
||||||
zmqServerEnabled = (cfg.zmqpubrawblock != null) || (cfg.zmqpubrawtx != null);
|
|
||||||
|
|
||||||
intAtLeast = n: types.addCheck types.int (x: x >= n) // {
|
|
||||||
name = "intAtLeast";
|
|
||||||
description = "integer >= ${toString n}";
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
|
||||||
|
|
||||||
services.bitcoind = mkMerge [
|
|
||||||
(mkIf cfg.dataDirReadableByGroup {
|
|
||||||
disablewallet = true;
|
|
||||||
sysperms = true;
|
|
||||||
})
|
|
||||||
{
|
|
||||||
rpc.users.privileged = {
|
|
||||||
passwordHMACFromFile = true;
|
|
||||||
};
|
|
||||||
rpc.users.public = {
|
|
||||||
passwordHMACFromFile = true;
|
|
||||||
rpcwhitelist = import ./bitcoind-rpc-public-whitelist.nix;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
services.i2pd = mkIf (cfg.i2p != false) {
|
|
||||||
enable = true;
|
|
||||||
proto.sam.enable = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
systemd.services.bitcoind = rec {
|
|
||||||
wants = [
|
|
||||||
"network-online.target"
|
|
||||||
# Use `wants` instead of `requires` for `nix-bitcoin-secrets.target`
|
|
||||||
# so that bitcoind and all dependent services are not restarted when
|
|
||||||
# the secrets target restarts.
|
|
||||||
# The secrets target always restarts when deploying with one of the methods
|
|
||||||
# in ./deployment.
|
|
||||||
#
|
|
||||||
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
|
||||||
# that propagates initial start failures but no restarts
|
|
||||||
"nix-bitcoin-secrets.target"
|
|
||||||
];
|
|
||||||
after = wants;
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
|
|
||||||
preStart = let
|
|
||||||
extraRpcauth = concatMapStrings (name: let
|
|
||||||
user = cfg.rpc.users.${name};
|
|
||||||
in optionalString user.passwordHMACFromFile ''
|
|
||||||
hmacPayload="$(readValidatedRpcHmac '${secretsDir}/bitcoin-HMAC-${name}')" || exit 1
|
|
||||||
printf '%s\n' "rpcauth=${user.name}:$hmacPayload"
|
|
||||||
''
|
|
||||||
) (builtins.attrNames cfg.rpc.users);
|
|
||||||
in ''
|
|
||||||
${optionalString cfg.dataDirReadableByGroup ''
|
|
||||||
if [[ -e '${cfg.dataDir}/blocks' ]]; then
|
|
||||||
chmod -R g+rX '${cfg.dataDir}/blocks'
|
|
||||||
fi
|
|
||||||
''}
|
|
||||||
|
|
||||||
readValidatedRpcHmac() {
|
|
||||||
local hmacFile="$1"
|
|
||||||
local hmacPayload
|
|
||||||
|
|
||||||
if [[ ! -e "$hmacFile" ]]; then
|
|
||||||
echo "Error: Bitcoin RPC HMAC file is missing: $hmacFile" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ ! -r "$hmacFile" ]]; then
|
|
||||||
echo "Error: Bitcoin RPC HMAC file is unreadable: $hmacFile" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
hmacPayload="$(<"$hmacFile")"
|
|
||||||
if [[ -z "$hmacPayload" ]]; then
|
|
||||||
echo "Error: Bitcoin RPC HMAC file is empty: $hmacFile" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ ! "$hmacPayload" =~ ^[[:xdigit:]]+\$[[:xdigit:]]+$ ]]; then
|
|
||||||
echo "Error: Bitcoin RPC HMAC file has invalid format: $hmacFile" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s\n' "$hmacPayload"
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg=$(
|
|
||||||
cat ${configFile}
|
|
||||||
${extraRpcauth}
|
|
||||||
echo
|
|
||||||
${optionalString (cfg.getPublicAddressCmd != "") ''
|
|
||||||
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
|
||||||
''}
|
|
||||||
)
|
|
||||||
confFile='${cfg.dataDir}/bitcoin.conf'
|
|
||||||
if [[ ! -e $confFile || $cfg != $(cat $confFile) ]]; then
|
|
||||||
install -o '${cfg.user}' -g '${cfg.group}' -m 640 <(echo "$cfg") $confFile
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Enable RPC access for group
|
|
||||||
postStart = ''
|
|
||||||
chmod g=r '${cfg.dataDir}/${optionalString cfg.regtest "regtest/"}.cookie'
|
|
||||||
'' + (optionalString cfg.regtest) ''
|
|
||||||
chmod g=x '${cfg.dataDir}/regtest'
|
|
||||||
'';
|
|
||||||
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
Type = "notify";
|
|
||||||
NotifyAccess = "all";
|
|
||||||
User = cfg.user;
|
|
||||||
Group = cfg.group;
|
|
||||||
TimeoutStartSec = "30min";
|
|
||||||
TimeoutStopSec = "30min";
|
|
||||||
ExecStart = "${cfg.package}/bin/bitcoind -datadir='${cfg.dataDir}'";
|
|
||||||
Restart = "on-failure";
|
|
||||||
UMask = mkIf cfg.dataDirReadableByGroup "0027";
|
|
||||||
ReadWritePaths = [ cfg.dataDir ];
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
|
||||||
// optionalAttrs zmqServerEnabled nbLib.allowNetlink;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
users.groups.bitcoinrpc-public = {};
|
|
||||||
|
|
||||||
nix-bitcoin.operator.groups = [ cfg.group ];
|
|
||||||
|
|
||||||
nix-bitcoin.secrets = {
|
|
||||||
bitcoin-rpcpassword-privileged.user = cfg.user;
|
|
||||||
bitcoin-rpcpassword-public = {
|
|
||||||
user = cfg.user;
|
|
||||||
group = "bitcoinrpc-public";
|
|
||||||
};
|
|
||||||
|
|
||||||
bitcoin-HMAC-privileged.user = cfg.user;
|
|
||||||
bitcoin-HMAC-public.user = cfg.user;
|
|
||||||
};
|
|
||||||
nix-bitcoin.generateSecretsCmds.bitcoind = ''
|
|
||||||
makeBitcoinRPCPassword privileged
|
|
||||||
makeBitcoinRPCPassword public
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services = {
|
|
||||||
nbxplorer = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable nbxplorer, a lightweight API for Bitcoin HD wallets.
|
|
||||||
|
|
||||||
Access API documentation here:
|
|
||||||
{option}`services.nbxplorer.address`:{option}`services.nbxplorer.port`
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen on.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 24444;
|
|
||||||
description = "Port to listen on.";
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = pkgs.stable.nbxplorer;
|
|
||||||
defaultText = "pkgs.stable.nbxplorer";
|
|
||||||
description = "The package providing nbxplorer binaries.";
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/nbxplorer";
|
|
||||||
description = "The data directory for nbxplorer.";
|
|
||||||
};
|
|
||||||
# TODO-EXTERNAL:
|
|
||||||
# The shortcut link `main` in the datadir has changed to a directory
|
|
||||||
# in version 2.3.3.
|
|
||||||
# Add a dummy symlink, if it does not already exist, to be compatible with older modules.
|
|
||||||
# When the old system uses a link and the new system a directory, switching fails with:
|
|
||||||
# mv: cannot move '/var/lib/nbxplorer/Main' to '/var/lib/nbxplorer/.Main.tmp':
|
|
||||||
# No such file or directory
|
|
||||||
#
|
|
||||||
# Remove this option when it is irrelevant (i.e. when the old system will never
|
|
||||||
# be nix-bitcoin <=0.0.91)
|
|
||||||
addNetworkSymlink = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = pkgs.stable.nbxplorer != cfg.nbxplorer.package;
|
|
||||||
description = ''
|
|
||||||
Whether to add a compatibility symlink (like `${cfg.nbxplorer.dataDir}/Main`)
|
|
||||||
to the dataDir.
|
|
||||||
This is enabled by default if the nbxplorer package is set to the version-locked package.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "nbxplorer";
|
|
||||||
description = "The user as which to run NBXplorer.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.nbxplorer.user;
|
|
||||||
description = "The group as which to run NBXplorer.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
|
|
||||||
btcpayserver = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable BTCPay Server, a self-hosted, open-source payment processor.
|
|
||||||
|
|
||||||
Extra recommendations:
|
|
||||||
- Enable `services.btcpayserver.lightningBackend` to provide Lightning payment support.
|
|
||||||
- Secure this service if the instance is publically accessible. For example, set
|
|
||||||
{option}`services.btcpayserver.address` to `127.0.0.1` and use a reverse proxy
|
|
||||||
that enforces TLS (Transport Layer Security).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = pkgs.stable.btcpayserver;
|
|
||||||
defaultText = "pkgs.stable.btcpayserver";
|
|
||||||
description = "The package providing BTCPay Server binaries.";
|
|
||||||
};
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen on.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 23000;
|
|
||||||
description = "Port to listen on.";
|
|
||||||
};
|
|
||||||
lightningBackend = mkOption {
|
|
||||||
type = types.nullOr (types.enum [ "lnd" ]);
|
|
||||||
default = null;
|
|
||||||
description = ''
|
|
||||||
The lightning node to use as a backend.
|
|
||||||
Enables the node service if not already enabled.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
lbtc = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable Liquid support.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/btcpayserver";
|
|
||||||
description = "The data directory for BTCPay Server.";
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "btcpayserver";
|
|
||||||
description = "The user as which to run BTCPay Server.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.btcpayserver.user;
|
|
||||||
description = "The group as which to run BTCPay Server.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = {
|
|
||||||
inherit (config.services)
|
|
||||||
nbxplorer
|
|
||||||
btcpayserver
|
|
||||||
bitcoind;
|
|
||||||
};
|
|
||||||
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkMerge [
|
|
||||||
(mkIf cfg.nbxplorer.enable {
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.nbxplorer.dataDir}' 0770 ${cfg.nbxplorer.user} ${cfg.nbxplorer.group} - -"
|
|
||||||
] ++ optional cfg.nbxplorer.addNetworkSymlink
|
|
||||||
"L+ '${cfg.nbxplorer.dataDir}/Main' - - - - '${cfg.nbxplorer.dataDir}/main'";
|
|
||||||
|
|
||||||
systemd.services.nbxplorer = let
|
|
||||||
configFile = builtins.toFile "nbxplorer-config" ''
|
|
||||||
network=${cfg.bitcoind.network}
|
|
||||||
btcrpcuser=${cfg.bitcoind.rpc.users.btcpayserver.name}
|
|
||||||
btcrpcurl=http://${nbLib.addressWithPort cfg.bitcoind.rpc.address cfg.bitcoind.rpc.port}
|
|
||||||
btcnodeendpoint=${nbLib.addressWithPort cfg.bitcoind.address cfg.bitcoind.whitelistedPort}
|
|
||||||
bind=${cfg.nbxplorer.address}
|
|
||||||
port=${toString cfg.nbxplorer.port}
|
|
||||||
postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
|
|
||||||
'';
|
|
||||||
in rec {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "postgresql.target" ];
|
|
||||||
wants = [ "bitcoind.service" ];
|
|
||||||
after = requires ++ wants ++ [ "nix-bitcoin-secrets.target" ];
|
|
||||||
preStart = ''
|
|
||||||
install -m 600 ${configFile} '${cfg.nbxplorer.dataDir}/settings.config'
|
|
||||||
printf '%s\n' "btcrpcpassword=$(<${secretsDir}/bitcoin-rpcpassword-btcpayserver)" \
|
|
||||||
>> '${cfg.nbxplorer.dataDir}/settings.config'
|
|
||||||
'';
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
ExecStart = ''
|
|
||||||
${cfg.nbxplorer.package}/bin/nbxplorer --conf=${cfg.nbxplorer.dataDir}/settings.config \
|
|
||||||
--datadir='${cfg.nbxplorer.dataDir}'
|
|
||||||
'';
|
|
||||||
RuntimeDirectory = "nbxplorer";
|
|
||||||
StateDirectory = "nbxplorer";
|
|
||||||
User = cfg.nbxplorer.user;
|
|
||||||
Group = cfg.nbxplorer.group;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.nbxplorer.dataDir ];
|
|
||||||
MemoryDenyWriteExecute = false;
|
|
||||||
} // nbLib.allowedIPAddresses cfg.nbxplorer.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
listenWhitelisted = true;
|
|
||||||
txindex = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.nbxplorer.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.nbxplorer.group;
|
|
||||||
home = cfg.nbxplorer.dataDir;
|
|
||||||
};
|
|
||||||
users.groups.${cfg.nbxplorer.group} = {};
|
|
||||||
})
|
|
||||||
|
|
||||||
(mkIf cfg.btcpayserver.enable {
|
|
||||||
services.nbxplorer.enable = true;
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
listenWhitelisted = true;
|
|
||||||
rpc.users.btcpayserver = {
|
|
||||||
name = "btcpayserver";
|
|
||||||
passwordHMACFromFile = true;
|
|
||||||
rpcwhitelist = [
|
|
||||||
"getblockchaininfo"
|
|
||||||
"getblock"
|
|
||||||
"getblockhash"
|
|
||||||
"getblockheader"
|
|
||||||
"getblockstats"
|
|
||||||
"gettransaction"
|
|
||||||
"getrawtransaction"
|
|
||||||
"sendrawtransaction"
|
|
||||||
"getblockcount"
|
|
||||||
"getbestblockhash"
|
|
||||||
"getnetworkinfo"
|
|
||||||
"getpeerinfo"
|
|
||||||
"estimatesmartfee"
|
|
||||||
"getmempoolinfo"
|
|
||||||
"getmempoolentry"
|
|
||||||
"getrawmempool"
|
|
||||||
"gettxout"
|
|
||||||
"scantxoutset"
|
|
||||||
"importmulti"
|
|
||||||
"listunspent"
|
|
||||||
"getwalletinfo"
|
|
||||||
"listtransactions"
|
|
||||||
"listreceivedbyaddress"
|
|
||||||
"getnewaddress"
|
|
||||||
"uptime"
|
|
||||||
"getrpcinfo"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.btcpayserver.dataDir}' 0770 ${cfg.btcpayserver.user} ${cfg.btcpayserver.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
systemd.services.btcpayserver = let
|
|
||||||
nbExplorerUrl = "http://${nbLib.addressWithPort cfg.nbxplorer.address cfg.nbxplorer.port}/";
|
|
||||||
nbExplorerCookie =
|
|
||||||
"${cfg.nbxplorer.dataDir}/${cfg.bitcoind.makeNetworkName "Main" "RegTest"}/.cookie";
|
|
||||||
configFile = builtins.toFile "btcpayserver-config" (
|
|
||||||
''
|
|
||||||
network=${cfg.bitcoind.network}
|
|
||||||
bind=${cfg.btcpayserver.address}
|
|
||||||
port=${toString cfg.btcpayserver.port}
|
|
||||||
socksendpoint=${config.nix-bitcoin.torClientAddressWithPort}
|
|
||||||
btcexplorerurl=${nbExplorerUrl}
|
|
||||||
btcexplorercookiefile=${nbExplorerCookie}
|
|
||||||
explorer.postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
|
|
||||||
postgres=User ID=${cfg.btcpayserver.user};Host=/run/postgresql;Database=btcpayserver
|
|
||||||
'' + optionalString (cfg.btcpayserver.lightningBackend == "lnd")
|
|
||||||
(
|
|
||||||
"btclightning=type=lnd-rest;"
|
|
||||||
+ "server=https://${nbLib.address config.services.lnd.restAddress}:${toString config.services.lnd.restPort}/;"
|
|
||||||
+ "macaroonfilepath=/run/lnd/btcpayserver.macaroon;"
|
|
||||||
+ "certfilepath=${config.services.lnd.certPath}\n"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
in rec {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "postgresql.target" "nbxplorer.service" ];
|
|
||||||
wants = optional (cfg.btcpayserver.lightningBackend == "lnd") "lnd.service";
|
|
||||||
after = requires ++ wants;
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
ExecStart = ''
|
|
||||||
${cfg.btcpayserver.package}/bin/btcpayserver --conf=${configFile} \
|
|
||||||
--datadir='${cfg.btcpayserver.dataDir}'
|
|
||||||
'';
|
|
||||||
WorkingDirectory = "${cfg.btcpayserver.package}/lib/btcpayserver";
|
|
||||||
RuntimeDirectory = "btcpayserver";
|
|
||||||
StateDirectory = "btcpayserver";
|
|
||||||
User = cfg.btcpayserver.user;
|
|
||||||
Group = cfg.btcpayserver.group;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.btcpayserver.dataDir ];
|
|
||||||
MemoryDenyWriteExecute = false;
|
|
||||||
} // nbLib.allowedIPAddresses cfg.btcpayserver.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.postgresql = {
|
|
||||||
enable = true;
|
|
||||||
ensureDatabases = [ "btcpayserver" "nbxplorer" ];
|
|
||||||
ensureUsers = [
|
|
||||||
{ name = cfg.btcpayserver.user; ensureDBOwnership = true; }
|
|
||||||
{ name = cfg.nbxplorer.user; ensureDBOwnership = true; }
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.btcpayserver.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.btcpayserver.group;
|
|
||||||
home = cfg.btcpayserver.dataDir;
|
|
||||||
extraGroups = optional (cfg.btcpayserver.lightningBackend == "lnd") config.services.lnd.group;
|
|
||||||
};
|
|
||||||
users.groups.${cfg.btcpayserver.group} = {};
|
|
||||||
|
|
||||||
nix-bitcoin.secrets = {
|
|
||||||
bitcoin-rpcpassword-btcpayserver = {
|
|
||||||
user = cfg.bitcoind.user;
|
|
||||||
group = cfg.nbxplorer.group;
|
|
||||||
};
|
|
||||||
bitcoin-HMAC-btcpayserver.user = cfg.bitcoind.user;
|
|
||||||
};
|
|
||||||
nix-bitcoin.generateSecretsCmds.btcpayserver = ''
|
|
||||||
makeBitcoinRPCPassword btcpayserver
|
|
||||||
'';
|
|
||||||
})
|
|
||||||
|
|
||||||
(mkIf (cfg.btcpayserver.enable && cfg.btcpayserver.lightningBackend == "lnd") {
|
|
||||||
services.lnd = {
|
|
||||||
enable = true;
|
|
||||||
macaroons.btcpayserver = {
|
|
||||||
user = cfg.btcpayserver.user;
|
|
||||||
permissions = ''
|
|
||||||
{"entity":"address","action":"write"},{"entity":"info","action":"read"},{"entity":"invoices","action":"read"},{"entity":"invoices","action":"write"},{"entity":"offchain","action":"read"},{"entity":"offchain","action":"write"},{"entity":"onchain","action":"read"},{"entity":"onchain","action":"write"},{"entity":"peers","action":"read"},{"entity":"peers","action":"write"}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${config.services.lnd.user}.extraGroups = [ cfg.btcpayserver.group ];
|
|
||||||
})
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Common Bitcoin infrastructure: secrets, onion services, nodeinfo, security
|
|
||||||
# Extracted from nix-bitcoin, tailored for Sovran (lnd-only)
|
|
||||||
{ config, lib, pkgs, ... }:
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
./nix-bitcoin.nix
|
|
||||||
./secrets/secrets.nix
|
|
||||||
./operator.nix
|
|
||||||
./security.nix
|
|
||||||
./onion-addresses.nix
|
|
||||||
./onion-services.nix
|
|
||||||
./nodeinfo.nix
|
|
||||||
./versioning.nix
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Sovran Bitcoin stack - tailored from nix-bitcoin, lnd-only, nixpkgs packages
|
|
||||||
# Original: https://github.com/fort-nix/nix-bitcoin
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
./common.nix
|
|
||||||
./bitcoind.nix
|
|
||||||
./electrs.nix
|
|
||||||
./lnd.nix
|
|
||||||
./lndconnect.nix
|
|
||||||
./rtl.nix
|
|
||||||
./btcpayserver.nix
|
|
||||||
./mempool.nix
|
|
||||||
];
|
|
||||||
|
|
||||||
disabledModules = [ "services/networking/bitcoind.nix" ];
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services.electrs = {
|
|
||||||
enable = mkEnableOption "electrs, an Electrum server implemented in Rust";
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for RPC connections.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 50001;
|
|
||||||
description = "Port to listen for RPC connections.";
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/electrs";
|
|
||||||
description = "The data directory for electrs.";
|
|
||||||
};
|
|
||||||
monitoringPort = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 4224;
|
|
||||||
description = "Prometheus monitoring port.";
|
|
||||||
};
|
|
||||||
extraArgs = mkOption {
|
|
||||||
type = types.separatedString " ";
|
|
||||||
default = "";
|
|
||||||
description = "Extra command line arguments passed to electrs.";
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "electrs";
|
|
||||||
description = "The user as which to run electrs.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run electrs.";
|
|
||||||
};
|
|
||||||
tor.enforce = nbLib.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.electrs;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
bitcoind = config.services.bitcoind;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
assertions = [
|
|
||||||
{ assertion = bitcoind.prune == 0;
|
|
||||||
message = "electrs does not support bitcoind pruning.";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
listenWhitelisted = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
systemd.services.electrs = {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "bitcoind.service" ];
|
|
||||||
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
|
||||||
preStart = ''
|
|
||||||
echo "auth = \"${bitcoind.rpc.users.public.name}:$(cat ${secretsDir}/bitcoin-rpcpassword-public)\"" \
|
|
||||||
> electrs.toml
|
|
||||||
'';
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
# electrs only uses the working directory for reading electrs.toml
|
|
||||||
WorkingDirectory = cfg.dataDir;
|
|
||||||
ExecStart = ''
|
|
||||||
${pkgs.electrs}/bin/electrs \
|
|
||||||
--log-filters=INFO \
|
|
||||||
--network=${bitcoind.makeNetworkName "bitcoin" "regtest"} \
|
|
||||||
--db-dir='${cfg.dataDir}' \
|
|
||||||
--daemon-dir='${bitcoind.dataDir}' \
|
|
||||||
--electrum-rpc-addr=${cfg.address}:${toString cfg.port} \
|
|
||||||
--monitoring-addr=${cfg.address}:${toString cfg.monitoringPort} \
|
|
||||||
--daemon-rpc-addr=${nbLib.addressWithPort bitcoind.rpc.address bitcoind.rpc.port} \
|
|
||||||
--daemon-p2p-addr=${nbLib.addressWithPort bitcoind.address bitcoind.whitelistedPort} \
|
|
||||||
${cfg.extraArgs}
|
|
||||||
'';
|
|
||||||
User = cfg.user;
|
|
||||||
Group = cfg.group;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.dataDir ];
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
extraGroups = [ "bitcoinrpc-public" ];
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
lib: pkgs: config:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
|
|
||||||
# See `man systemd.exec` and `man systemd.resource-control` for an explanation
|
|
||||||
# of the systemd-related options available through this file.
|
|
||||||
let self = {
|
|
||||||
# These settings roughly follow systemd's "strict" security profile
|
|
||||||
defaultHardening = {
|
|
||||||
PrivateTmp = true;
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
ProtectHome = true;
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
PrivateDevices = true;
|
|
||||||
MemoryDenyWriteExecute = true;
|
|
||||||
ProtectKernelTunables = true;
|
|
||||||
ProtectKernelModules = true;
|
|
||||||
ProtectKernelLogs = true;
|
|
||||||
ProtectClock = true;
|
|
||||||
ProtectProc = "invisible";
|
|
||||||
ProcSubset = "pid";
|
|
||||||
ProtectControlGroups = true;
|
|
||||||
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
|
|
||||||
RestrictNamespaces = true;
|
|
||||||
LockPersonality = true;
|
|
||||||
IPAddressDeny = "any";
|
|
||||||
PrivateUsers = true;
|
|
||||||
RestrictSUIDSGID = true;
|
|
||||||
RemoveIPC = true;
|
|
||||||
RestrictRealtime = true;
|
|
||||||
ProtectHostname = true;
|
|
||||||
CapabilityBoundingSet = "";
|
|
||||||
# @system-service whitelist and docker seccomp blacklist (except for "clone"
|
|
||||||
# which is a core requirement for systemd services)
|
|
||||||
# @system-service is defined in src/shared/seccomp-util.c (systemd source)
|
|
||||||
SystemCallFilter = [ "@system-service" "~add_key kcmp keyctl mbind move_pages name_to_handle_at personality process_vm_readv process_vm_writev request_key setns unshare userfaultfd" ];
|
|
||||||
SystemCallArchitectures = "native";
|
|
||||||
};
|
|
||||||
|
|
||||||
allowNetlink = {
|
|
||||||
RestrictAddressFamilies = self.defaultHardening.RestrictAddressFamilies + " AF_NETLINK";
|
|
||||||
};
|
|
||||||
|
|
||||||
nodejs = {
|
|
||||||
# Required for JIT compilation
|
|
||||||
MemoryDenyWriteExecute = false;
|
|
||||||
# Required by nodejs >= 18
|
|
||||||
SystemCallFilter = self.defaultHardening.SystemCallFilter ++ [ "@pkey" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
# Allow takes precedence over Deny.
|
|
||||||
allowLocalIPAddresses = {
|
|
||||||
IPAddressAllow = [
|
|
||||||
"127.0.0.1/32"
|
|
||||||
"::1/128"
|
|
||||||
"169.254.0.0/16"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
allowAllIPAddresses = { IPAddressAllow = "any"; };
|
|
||||||
allowTor = self.allowLocalIPAddresses;
|
|
||||||
allowedIPAddresses = onlyLocal:
|
|
||||||
if onlyLocal
|
|
||||||
then self.allowLocalIPAddresses
|
|
||||||
else self.allowAllIPAddresses;
|
|
||||||
|
|
||||||
tor = {
|
|
||||||
proxy = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Whether to proxy outgoing connections with Tor.";
|
|
||||||
};
|
|
||||||
enforce = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Whether to enforce Tor on this service by only allowing connections
|
|
||||||
from and to localhost and link-local addresses.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
script = name: src: pkgs.writers.writeBash name ''
|
|
||||||
set -eo pipefail
|
|
||||||
${src}
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Used for ExecStart*
|
|
||||||
rootScript = name: src: "+${self.script name src}";
|
|
||||||
|
|
||||||
cliExec = mkOption {
|
|
||||||
# Used by netns-isolation to execute the cli in the service's private netns
|
|
||||||
internal = true;
|
|
||||||
type = types.str;
|
|
||||||
default = "exec";
|
|
||||||
};
|
|
||||||
|
|
||||||
mkOnionService = map: {
|
|
||||||
map = [ map ];
|
|
||||||
version = 3;
|
|
||||||
};
|
|
||||||
|
|
||||||
# Convert a bind address, which may be a special INADDR_ANY address,
|
|
||||||
# to an actual IP address
|
|
||||||
address = addr:
|
|
||||||
if addr == "0.0.0.0" then
|
|
||||||
"127.0.0.1"
|
|
||||||
else if addr == "::" then
|
|
||||||
"::1"
|
|
||||||
else
|
|
||||||
addr;
|
|
||||||
|
|
||||||
addressWithPort = addr: port: "${self.address addr}:${toString port}";
|
|
||||||
|
|
||||||
optionalAttr = cond: name: if cond then name else null;
|
|
||||||
|
|
||||||
mkCertExtraAltNames = cert:
|
|
||||||
builtins.concatStringsSep "," (
|
|
||||||
(map (domain: "DNS:${domain}") cert.extraDomains) ++
|
|
||||||
(map (ip: "IP:${ip}") cert.extraIPs)
|
|
||||||
);
|
|
||||||
|
|
||||||
test = {
|
|
||||||
mkIfTest = test: mkIf (config.tests.${test} or false);
|
|
||||||
};
|
|
||||||
|
|
||||||
mkAlias = default: mkOption {
|
|
||||||
internal = true;
|
|
||||||
readOnly = true;
|
|
||||||
inherit default;
|
|
||||||
};
|
|
||||||
|
|
||||||
}; in self
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services.lnd = {
|
|
||||||
enable = mkEnableOption "Lightning Network daemon, a Lightning Network implementation in Go";
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for peer connections";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 9735;
|
|
||||||
description = "Port to listen for peer connections";
|
|
||||||
};
|
|
||||||
rpcAddress = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for RPC connections.";
|
|
||||||
};
|
|
||||||
rpcPort = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 10009;
|
|
||||||
description = "Port to listen for gRPC connections.";
|
|
||||||
};
|
|
||||||
restAddress = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for REST connections.";
|
|
||||||
};
|
|
||||||
restPort = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 8080;
|
|
||||||
description = "Port to listen for REST connections.";
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/lnd";
|
|
||||||
description = "The data directory for LND.";
|
|
||||||
};
|
|
||||||
networkDir = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = "${cfg.dataDir}/chain/bitcoin/${bitcoind.network}";
|
|
||||||
description = "The network data directory.";
|
|
||||||
};
|
|
||||||
tor-socks = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
|
|
||||||
description = "Socks proxy for connecting to Tor nodes";
|
|
||||||
};
|
|
||||||
macaroons = mkOption {
|
|
||||||
default = {};
|
|
||||||
type = with types; attrsOf (submodule {
|
|
||||||
options = {
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
description = "User who owns the macaroon.";
|
|
||||||
};
|
|
||||||
permissions = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
example = ''
|
|
||||||
{"entity":"info","action":"read"},{"entity":"onchain","action":"read"}
|
|
||||||
'';
|
|
||||||
description = "List of granted macaroon permissions.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
});
|
|
||||||
description = ''
|
|
||||||
Extra macaroon definitions.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
certificate = {
|
|
||||||
extraIPs = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
example = [ "60.100.0.1" ];
|
|
||||||
description = ''
|
|
||||||
Extra `subjectAltName` IPs added to the certificate.
|
|
||||||
This works the same as lnd option {option}`tlsextraip`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
extraDomains = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
example = [ "example.com" ];
|
|
||||||
description = ''
|
|
||||||
Extra `subjectAltName` domain names added to the certificate.
|
|
||||||
This works the same as lnd option {option}`tlsextradomain`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
extraConfig = mkOption {
|
|
||||||
type = types.lines;
|
|
||||||
default = "";
|
|
||||||
example = ''
|
|
||||||
autopilot.active=1
|
|
||||||
'';
|
|
||||||
description = ''
|
|
||||||
Extra lines appended to {file}`lnd.conf`.
|
|
||||||
See here for all available options:
|
|
||||||
https://github.com/lightningnetwork/lnd/blob/master/sample-lnd.conf
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = pkgs.lnd;
|
|
||||||
defaultText = "pkgs.lnd";
|
|
||||||
description = "The package providing lnd binaries.";
|
|
||||||
};
|
|
||||||
cli = mkOption {
|
|
||||||
default = pkgs.writers.writeBashBin "lncli"
|
|
||||||
# Switch user because lnd makes datadir contents readable by user only
|
|
||||||
''
|
|
||||||
${runAsUser} ${cfg.user} ${cfg.package}/bin/lncli \
|
|
||||||
--rpcserver ${cfg.rpcAddress}:${toString cfg.rpcPort} \
|
|
||||||
--tlscertpath '${cfg.certPath}' \
|
|
||||||
--macaroonpath '${networkDir}/admin.macaroon' "$@"
|
|
||||||
'';
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = "Binary to connect with the lnd instance.";
|
|
||||||
};
|
|
||||||
getPublicAddressCmd = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "";
|
|
||||||
description = ''
|
|
||||||
Bash expression which outputs the public service address to announce to peers.
|
|
||||||
If left empty, no address is announced.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "lnd";
|
|
||||||
description = "The user as which to run LND.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run LND.";
|
|
||||||
};
|
|
||||||
certPath = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = "${secretsDir}/lnd-cert";
|
|
||||||
description = "LND TLS certificate path.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.lnd;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
runAsUser = config.nix-bitcoin.runAsUserCmd;
|
|
||||||
lndinit = "${(pkgs.callPackage ../../packages/lndinit {})}/bin/lndinit";
|
|
||||||
|
|
||||||
bitcoind = config.services.bitcoind;
|
|
||||||
|
|
||||||
bitcoindRpcAddress = nbLib.address bitcoind.rpc.address;
|
|
||||||
networkDir = cfg.networkDir;
|
|
||||||
configFile = pkgs.writeText "lnd.conf" ''
|
|
||||||
datadir=${cfg.dataDir}
|
|
||||||
tlscertpath=${cfg.certPath}
|
|
||||||
tlskeypath=${secretsDir}/lnd-key
|
|
||||||
|
|
||||||
# We're logging via journald
|
|
||||||
logging.file.disable=1
|
|
||||||
logging.console.no-timestamps=1
|
|
||||||
|
|
||||||
listen=${toString cfg.address}:${toString cfg.port}
|
|
||||||
rpclisten=${cfg.rpcAddress}:${toString cfg.rpcPort}
|
|
||||||
restlisten=${cfg.restAddress}:${toString cfg.restPort}
|
|
||||||
|
|
||||||
bitcoin.${bitcoind.network}=1
|
|
||||||
bitcoin.node=bitcoind
|
|
||||||
|
|
||||||
${optionalString (cfg.tor.proxy) "tor.active=true"}
|
|
||||||
${optionalString (cfg.tor-socks != null) "tor.socks=${cfg.tor-socks}"}
|
|
||||||
|
|
||||||
bitcoind.rpchost=${bitcoindRpcAddress}:${toString bitcoind.rpc.port}
|
|
||||||
bitcoind.rpcuser=${bitcoind.rpc.users.public.name}
|
|
||||||
bitcoind.zmqpubrawblock=${zmqHandleSpecialAddress bitcoind.zmqpubrawblock}
|
|
||||||
bitcoind.zmqpubrawtx=${zmqHandleSpecialAddress bitcoind.zmqpubrawtx}
|
|
||||||
|
|
||||||
wallet-unlock-password-file=${secretsDir}/lnd-wallet-password
|
|
||||||
|
|
||||||
${cfg.extraConfig}
|
|
||||||
'';
|
|
||||||
|
|
||||||
zmqHandleSpecialAddress = builtins.replaceStrings [ "0.0.0.0" "[::]" ] [ "127.0.0.1" "[::1]" ];
|
|
||||||
in {
|
|
||||||
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
assertions = [
|
|
||||||
{ assertion =
|
|
||||||
!(config.services ? clightning)
|
|
||||||
|| true; # clightning enable/port check disabled - option structure differs between nixpkgs versions (f13ff45 has plugins only, 8b8c811 removed). Sovran uses lnd only, so no conflict.
|
|
||||||
message = ''
|
|
||||||
LND and clightning can't both bind to lightning port 9735. Either
|
|
||||||
disable LND/clightning or change services.clightning.port or
|
|
||||||
services.lnd.port to a port other than 9735.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
|
|
||||||
# Increase rpc thread count due to reports that lightning implementations fail
|
|
||||||
# under high bitcoind rpc load
|
|
||||||
rpc.threads = 16;
|
|
||||||
|
|
||||||
zmqpubrawblock = mkDefault "tcp://${bitcoindRpcAddress}:28332";
|
|
||||||
zmqpubrawtx = mkDefault "tcp://${bitcoindRpcAddress}:28333";
|
|
||||||
};
|
|
||||||
|
|
||||||
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
services.lnd.certificate.extraIPs = mkIf (cfg.rpcAddress != "127.0.0.1") [ "${cfg.rpcAddress}" ];
|
|
||||||
|
|
||||||
systemd.services.lnd = {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "bitcoind.service" ];
|
|
||||||
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
|
||||||
preStart = ''
|
|
||||||
install -m600 ${configFile} '${cfg.dataDir}/lnd.conf'
|
|
||||||
{
|
|
||||||
echo "bitcoind.rpcpass=$(cat ${secretsDir}/bitcoin-rpcpassword-public)"
|
|
||||||
${optionalString (cfg.getPublicAddressCmd != "") ''
|
|
||||||
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
|
||||||
''}
|
|
||||||
} >> '${cfg.dataDir}/lnd.conf'
|
|
||||||
|
|
||||||
if [[ ! -f ${networkDir}/wallet.db ]]; then
|
|
||||||
seed='${cfg.dataDir}/lnd-seed-mnemonic'
|
|
||||||
|
|
||||||
if [[ ! -f "$seed" ]]; then
|
|
||||||
echo "Create lnd seed"
|
|
||||||
(umask u=r,go=; ${lndinit} gen-seed > "$seed")
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Create lnd wallet"
|
|
||||||
${lndinit} -v init-wallet \
|
|
||||||
--file.seed="$seed" \
|
|
||||||
--file.wallet-password='${secretsDir}/lnd-wallet-password' \
|
|
||||||
--init-file.output-wallet-dir='${cfg.networkDir}'
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
Type = "notify";
|
|
||||||
RuntimeDirectory = "lnd"; # Only used to store custom macaroons
|
|
||||||
RuntimeDirectoryMode = "711";
|
|
||||||
ExecStart = "${cfg.package}/bin/lnd --configfile='${cfg.dataDir}/lnd.conf'";
|
|
||||||
User = cfg.user;
|
|
||||||
TimeoutSec = "15min";
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.dataDir ];
|
|
||||||
ExecStartPost = let
|
|
||||||
curl = "${pkgs.curl}/bin/curl -fsS --cacert ${cfg.certPath}";
|
|
||||||
restUrl = "https://${nbLib.addressWithPort cfg.restAddress cfg.restPort}/v1";
|
|
||||||
# Setting macaroon permissions for other users needs root permissions
|
|
||||||
script = nbLib.rootScript "lnd-create-macaroons" ''
|
|
||||||
umask ug=r,o=
|
|
||||||
${lib.concatMapStrings (macaroon: ''
|
|
||||||
echo "Create custom macaroon ${macaroon}"
|
|
||||||
macaroonPath="$RUNTIME_DIRECTORY/${macaroon}.macaroon"
|
|
||||||
${curl} \
|
|
||||||
-H "Grpc-Metadata-macaroon: $(${pkgs.xxd}/bin/xxd -ps -u -c 99999 '${networkDir}/admin.macaroon')" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"permissions":[${cfg.macaroons.${macaroon}.permissions}]}' \
|
|
||||||
${restUrl}/macaroon |\
|
|
||||||
${pkgs.jq}/bin/jq -c '.macaroon' | ${pkgs.xxd}/bin/xxd -p -r > "$macaroonPath"
|
|
||||||
chown ${cfg.macaroons.${macaroon}.user}: "$macaroonPath"
|
|
||||||
'') (attrNames cfg.macaroons)}
|
|
||||||
'';
|
|
||||||
in [
|
|
||||||
script
|
|
||||||
];
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
extraGroups = [ "bitcoinrpc-public" ];
|
|
||||||
home = cfg.dataDir; # lnd creates .lnd dir in HOME
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
nix-bitcoin.operator = {
|
|
||||||
groups = [ cfg.group ];
|
|
||||||
allowRunAsUsers = [ cfg.user ];
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.secrets = {
|
|
||||||
lnd-wallet-password.user = cfg.user;
|
|
||||||
lnd-key.user = cfg.user;
|
|
||||||
lnd-cert.user = cfg.user;
|
|
||||||
lnd-cert.permissions = "444"; # world readable
|
|
||||||
};
|
|
||||||
# Advantages of manually pre-generating certs:
|
|
||||||
# - Reduces dynamic state
|
|
||||||
# - Enables deployment of a mesh of server plus client nodes with predefined certs
|
|
||||||
nix-bitcoin.generateSecretsCmds.lnd = ''
|
|
||||||
makePasswordSecret lnd-wallet-password
|
|
||||||
makeCert lnd '${nbLib.mkCertExtraAltNames cfg.certificate}'
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
cfg = config.services.lnd;
|
|
||||||
operatorName = config.nix-bitcoin.operator.name;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
|
|
||||||
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}"
|
|
||||||
else
|
|
||||||
host="${nbLib.address lnd.restAddress}"
|
|
||||||
port="${toString lnd.restPort}"
|
|
||||||
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";
|
|
||||||
};
|
|
||||||
onion = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Expose lndconnect via Tor onion service";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = mkIf cfg.enable (mkMerge [
|
|
||||||
(mkIf cfg.lndconnect.enable {
|
|
||||||
environment.systemPackages = [
|
|
||||||
(mkLndconnect {
|
|
||||||
name = "lndconnect";
|
|
||||||
enableOnion = cfg.lndconnect.onion;
|
|
||||||
onionService = "${operatorName}/lnd";
|
|
||||||
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 {
|
|
||||||
port = cfg.restPort;
|
|
||||||
target = { addr = nbLib.address cfg.restAddress; port = cfg.restPort; };
|
|
||||||
};
|
|
||||||
nix-bitcoin.onionAddresses.access.${operatorName} = [ "lnd" ];
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services = {
|
|
||||||
mempool = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable Mempool, a fully featured Bitcoin visualizer, explorer, and API service.
|
|
||||||
|
|
||||||
Note: Mempool enables `txindex` in bitcoind (this is a requirement).
|
|
||||||
|
|
||||||
This module has two components:
|
|
||||||
- A backend service (systemd service `mempool`)
|
|
||||||
|
|
||||||
- An optional web interface run by nginx, defined by options `services.mempool.frontend.*`.
|
|
||||||
The frontend is enabled by default when mempool is enabled.
|
|
||||||
For details, see `services.mempool.frontend.enable`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
frontend = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = cfg.enable;
|
|
||||||
description = ''
|
|
||||||
Enable the mempool frontend (web interface).
|
|
||||||
This starts a simple nginx instance, configured for local usage with
|
|
||||||
settings similar to the `mempool/frontend` Docker image.
|
|
||||||
|
|
||||||
IMPORTANT:
|
|
||||||
If you want to expose the mempool frontend to the internet, you
|
|
||||||
should create a custom nginx config that includes TLS, backend caching, rate limiting
|
|
||||||
and performance tuning.
|
|
||||||
For this task, reuse the config snippets from option `services.mempool.frontend.nginxConfig`.
|
|
||||||
See also: https://github.com/fort-nix/nixbitcoin.org/blob/master/website/mempool.nix,
|
|
||||||
which contains a mempool nginx config for public hosting (running at
|
|
||||||
https://mempool.nixbitcoin.org).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "HTTP server address.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 60845; # A random private port
|
|
||||||
description = "HTTP server port.";
|
|
||||||
};
|
|
||||||
settings = mkOption {
|
|
||||||
type = with types; attrsOf anything;
|
|
||||||
default = {};
|
|
||||||
example = {
|
|
||||||
TESTNET_ENABLED = true;
|
|
||||||
MEMPOOL_WEBSITE_URL = "mempool.mynode.org";
|
|
||||||
};
|
|
||||||
description = ''
|
|
||||||
Mempool frontend settings.
|
|
||||||
See here for available options:
|
|
||||||
https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
|
|
||||||
(`interface Env` and `defaultEnv`)
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
staticContentRoot = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-frontend.withConfig cfg.frontend.settings;
|
|
||||||
defaultText = "mempoolPkgs.mempool-frontend";
|
|
||||||
description = "
|
|
||||||
Path of the static frontend content root.
|
|
||||||
";
|
|
||||||
};
|
|
||||||
nginxConfig = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = frontend.nginxConfig;
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = "
|
|
||||||
An attrset of nginx config snippets for assembling a custom
|
|
||||||
mempool nginx config.
|
|
||||||
For details, see the source comments at the point of definition.
|
|
||||||
";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Mempool backend address.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 8999;
|
|
||||||
description = "Mempool backend port.";
|
|
||||||
};
|
|
||||||
electrumServer = mkOption {
|
|
||||||
type = types.enum [ "electrs" ];
|
|
||||||
default = "electrs";
|
|
||||||
description = ''
|
|
||||||
The Electrum server to use for fetching address information.
|
|
||||||
|
|
||||||
Possible options:
|
|
||||||
- electrs:
|
|
||||||
Small database size, slow when querying new addresses.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
settings = mkOption {
|
|
||||||
type = with types; attrsOf (attrsOf anything);
|
|
||||||
example = {
|
|
||||||
MEMPOOL = {
|
|
||||||
POLL_RATE_MS = 3000;
|
|
||||||
STDOUT_LOG_MIN_PRIORITY = "debug";
|
|
||||||
};
|
|
||||||
PRICE_DATA_SERVER = {
|
|
||||||
CLEARNET_URL = "https://myserver.org/prices";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
description = ''
|
|
||||||
Mempool backend settings.
|
|
||||||
See here for available options:
|
|
||||||
https://github.com/mempool/mempool/blob/master/backend/src/config.ts
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
database = {
|
|
||||||
name = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "mempool";
|
|
||||||
description = "Database name.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-backend;
|
|
||||||
defaultText = "mempoolPkgs.mempool-backend";
|
|
||||||
description = "The package providing mempool binaries.";
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "mempool";
|
|
||||||
description = "The user as which to run Mempool.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run Mempool.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
|
|
||||||
# Internal read-only options used by `./nodeinfo.nix` and `./onion-services.nix`
|
|
||||||
mempool-frontend = let
|
|
||||||
inherit (nbLib) mkAlias;
|
|
||||||
in {
|
|
||||||
enable = mkAlias cfg.frontend.enable;
|
|
||||||
address = mkAlias cfg.frontend.address;
|
|
||||||
port = mkAlias cfg.frontend.port;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.mempool;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
nbPkgs = pkgs; # vendored: now alias to pkgs
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
|
|
||||||
configFile = builtins.toFile "mempool-config" (builtins.toJSON cfg.settings);
|
|
||||||
cacheDir = "/var/cache/mempool";
|
|
||||||
|
|
||||||
inherit (config.services)
|
|
||||||
bitcoind
|
|
||||||
electrs;
|
|
||||||
|
|
||||||
torSocket = config.services.tor.client.socksListenAddress;
|
|
||||||
# Vendored mempool package
|
|
||||||
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
|
|
||||||
mempoolPkgs = pkgs.callPackage ../../packages/mempool { inherit fetchNodeModules; };
|
|
||||||
|
|
||||||
# See the `services.nginx` definition further below
|
|
||||||
# on how to use these snippets.
|
|
||||||
frontend.nginxConfig = {
|
|
||||||
# This must be added to `services.nginx.commonHttpConfig` when
|
|
||||||
# `mempool/location-static.conf` is used
|
|
||||||
httpConfig = ''
|
|
||||||
include ${mempoolPkgs.mempool-nginx-conf}/http-language.conf;
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Config for static website content.
|
|
||||||
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
|
|
||||||
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-redirects.conf
|
|
||||||
staticContent = ''
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
add_header Cache-Control "public, no-transform";
|
|
||||||
add_header Vary Accept-Language;
|
|
||||||
add_header Vary Cookie;
|
|
||||||
|
|
||||||
include ${mempoolPkgs.mempool-nginx-conf}/location-static.conf;
|
|
||||||
|
|
||||||
# Redirect /api to /docs/api
|
|
||||||
location = /api {
|
|
||||||
return 308 https://$host/docs/api;
|
|
||||||
}
|
|
||||||
location = /api/ {
|
|
||||||
return 308 https://$host/docs/api;
|
|
||||||
}
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Config for backend API.
|
|
||||||
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
|
|
||||||
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-api.conf.
|
|
||||||
proxyApi = let
|
|
||||||
backend = "http://${nbLib.addressWithPort cfg.address cfg.port}";
|
|
||||||
in ''
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass ${backend}/api/v1/;
|
|
||||||
}
|
|
||||||
location /api/v1 {
|
|
||||||
proxy_pass ${backend};
|
|
||||||
}
|
|
||||||
# Websocket API
|
|
||||||
location /api/v1/ws {
|
|
||||||
proxy_pass ${backend};
|
|
||||||
|
|
||||||
# Websocket header settings
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "Upgrade";
|
|
||||||
|
|
||||||
# Relevant settings from `recommendedProxyConfig` (nixos/nginx/default.nix)
|
|
||||||
# (In the above api locations, these are inherited from the parent scope)
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
services.bitcoind.txindex = true;
|
|
||||||
services.electrs.enable = true;
|
|
||||||
services.mysql = {
|
|
||||||
enable = true;
|
|
||||||
package = pkgs.mariadb;
|
|
||||||
ensureDatabases = [ cfg.database.name ];
|
|
||||||
ensureUsers = [
|
|
||||||
{
|
|
||||||
name = cfg.user;
|
|
||||||
ensurePermissions."${cfg.database.name}.*" = "ALL PRIVILEGES";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
# Available options:
|
|
||||||
# https://github.com/mempool/mempool/blob/master/backend/src/config.ts
|
|
||||||
services.mempool.settings = {
|
|
||||||
MEMPOOL = {
|
|
||||||
# mempool doesn't support regtest
|
|
||||||
NETWORK = "mainnet";
|
|
||||||
BACKEND = "electrum";
|
|
||||||
HTTP_PORT = cfg.port;
|
|
||||||
CACHE_DIR = "${cacheDir}/cache";
|
|
||||||
STDOUT_LOG_MIN_PRIORITY = mkDefault "info";
|
|
||||||
AUTOMATIC_POOLS_UPDATE = true;
|
|
||||||
};
|
|
||||||
CORE_RPC = {
|
|
||||||
HOST = bitcoind.rpc.address;
|
|
||||||
PORT = bitcoind.rpc.port;
|
|
||||||
USERNAME = bitcoind.rpc.users.public.name;
|
|
||||||
PASSWORD = "@btcRpcPassword@";
|
|
||||||
};
|
|
||||||
ELECTRUM = let
|
|
||||||
server = config.services.${cfg.electrumServer};
|
|
||||||
in {
|
|
||||||
HOST = server.address;
|
|
||||||
PORT = server.port;
|
|
||||||
TLS_ENABLED = false;
|
|
||||||
};
|
|
||||||
DATABASE = {
|
|
||||||
ENABLED = true;
|
|
||||||
DATABASE = cfg.database.name;
|
|
||||||
SOCKET = "/run/mysqld/mysqld.sock";
|
|
||||||
PID_DIR = cacheDir;
|
|
||||||
};
|
|
||||||
} // optionalAttrs (cfg.tor.proxy) {
|
|
||||||
# Use Tor for rate fetching and pool updating
|
|
||||||
SOCKS5PROXY = {
|
|
||||||
ENABLED = true;
|
|
||||||
USE_ONION = true;
|
|
||||||
HOST = torSocket.addr;
|
|
||||||
PORT = torSocket.port;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.mempool = rec {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "mysql.service" ];
|
|
||||||
wants = [ "${cfg.electrumServer}.service" ];
|
|
||||||
after = requires ++ wants;
|
|
||||||
preStart = ''
|
|
||||||
mkdir -p '${cacheDir}/cache'
|
|
||||||
<${configFile} sed \
|
|
||||||
-e "s|@btcRpcPassword@|$(cat ${secretsDir}/bitcoin-rpcpassword-public)|" \
|
|
||||||
> '${cacheDir}/config.json'
|
|
||||||
'';
|
|
||||||
environment.MEMPOOL_CONFIG_FILE = "${cacheDir}/config.json";
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
ExecStart = "${cfg.package}/bin/mempool-backend";
|
|
||||||
CacheDirectory = "mempool";
|
|
||||||
CacheDirectoryMode = "770";
|
|
||||||
# Show "mempool" instead of "node" in the journal
|
|
||||||
SyslogIdentifier = "mempool";
|
|
||||||
User = cfg.user;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
|
||||||
// nbLib.nodejs;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.nginx = mkIf cfg.frontend.enable {
|
|
||||||
enable = true;
|
|
||||||
enableReload = true;
|
|
||||||
recommendedBrotliSettings = true;
|
|
||||||
recommendedGzipSettings = true;
|
|
||||||
recommendedOptimisation = true;
|
|
||||||
recommendedProxySettings = true;
|
|
||||||
recommendedTlsSettings = true;
|
|
||||||
commonHttpConfig = frontend.nginxConfig.httpConfig;
|
|
||||||
virtualHosts."mempool" = {
|
|
||||||
serverName = "_";
|
|
||||||
listen = [ { addr = cfg.frontend.address; port = cfg.frontend.port; } ];
|
|
||||||
root = cfg.frontend.staticContentRoot;
|
|
||||||
extraConfig =
|
|
||||||
frontend.nginxConfig.staticContent +
|
|
||||||
frontend.nginxConfig.proxyApi;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
extraGroups = [ "bitcoinrpc-public" ];
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
{
|
|
||||||
options = {
|
|
||||||
nix-bitcoin = {
|
|
||||||
# Kept for compatibility, now simply aliases system pkgs
|
|
||||||
pkgs = mkOption {
|
|
||||||
type = types.attrs;
|
|
||||||
default = pkgs;
|
|
||||||
defaultText = "pkgs";
|
|
||||||
description = "Alias to system pkgs (vendored nix-bitcoin now uses nixpkgs directly).";
|
|
||||||
};
|
|
||||||
|
|
||||||
useVersionLockedPkgs = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Deprecated — vendored modules always use system pkgs.";
|
|
||||||
};
|
|
||||||
|
|
||||||
pkgOverlays = mkOption {
|
|
||||||
internal = true;
|
|
||||||
type = with types; functionTo attrs;
|
|
||||||
default = _: _: {};
|
|
||||||
description = "Deprecated stub.";
|
|
||||||
};
|
|
||||||
|
|
||||||
lib = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = import ./lib.nix lib pkgs config;
|
|
||||||
defaultText = "vendor/nix-bitcoin/lib.nix";
|
|
||||||
};
|
|
||||||
|
|
||||||
torClientAddressWithPort = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = with config.services.tor.client.socksListenAddress;
|
|
||||||
"${addr}:${toString port}";
|
|
||||||
defaultText = "(See source)";
|
|
||||||
};
|
|
||||||
|
|
||||||
torify = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = pkgs.writers.writeBashBin "torify" ''
|
|
||||||
${pkgs.tor}/bin/torify \
|
|
||||||
--address ${config.services.tor.client.socksListenAddress.addr} \
|
|
||||||
"$@"
|
|
||||||
'';
|
|
||||||
defaultText = "(See source)";
|
|
||||||
};
|
|
||||||
|
|
||||||
runAsUserCmd = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = if config.security.doas.enable
|
|
||||||
then "doas -u"
|
|
||||||
else "sudo -u";
|
|
||||||
defaultText = "(See source)";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options = {
|
|
||||||
nix-bitcoin.nodeinfo = {
|
|
||||||
enable = mkEnableOption "nodeinfo";
|
|
||||||
|
|
||||||
program = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = script;
|
|
||||||
defaultText = "(See source)";
|
|
||||||
};
|
|
||||||
|
|
||||||
services = mkOption {
|
|
||||||
internal = true;
|
|
||||||
type = types.attrs;
|
|
||||||
default = {};
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = ''
|
|
||||||
Nodeinfo service definitions.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
lib = mkOption {
|
|
||||||
internal = true;
|
|
||||||
readOnly = true;
|
|
||||||
default = nodeinfoLib;
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = ''
|
|
||||||
Helper functions for defining nodeinfo services.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin.nodeinfo;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
|
|
||||||
script = pkgs.writeScriptBin "nodeinfo" ''
|
|
||||||
#!${pkgs.python3}/bin/python
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
def success(*args):
|
|
||||||
return subprocess.call(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
|
|
||||||
|
|
||||||
def is_active(unit):
|
|
||||||
return success("systemctl", "is-active", "--quiet", unit)
|
|
||||||
|
|
||||||
def is_enabled(unit):
|
|
||||||
return success("systemctl", "is-enabled", "--quiet", unit)
|
|
||||||
|
|
||||||
def cmd(*args):
|
|
||||||
return subprocess.run(args, stdout=subprocess.PIPE).stdout.decode('utf-8')
|
|
||||||
|
|
||||||
def shell(*args):
|
|
||||||
return cmd("bash", "-c", *args).strip()
|
|
||||||
|
|
||||||
infos = OrderedDict()
|
|
||||||
operator = "${config.nix-bitcoin.operator.name}"
|
|
||||||
|
|
||||||
def get_onion_address(name, port):
|
|
||||||
path = f"/var/lib/onion-addresses/{operator}/{name}"
|
|
||||||
try:
|
|
||||||
with open(path, "r") as f:
|
|
||||||
onion_address = f.read().strip()
|
|
||||||
except OSError:
|
|
||||||
print(f"error reading file {path}", file=sys.stderr)
|
|
||||||
return
|
|
||||||
return f"{onion_address}:{port}"
|
|
||||||
|
|
||||||
def add_service(service, make_info, systemd_service = None):
|
|
||||||
systemd_service = systemd_service or service
|
|
||||||
if not is_active(systemd_service):
|
|
||||||
infos[service] = f"'{systemd_service}.service' is not running"
|
|
||||||
else:
|
|
||||||
info = OrderedDict()
|
|
||||||
exec(make_info, globals(), locals())
|
|
||||||
infos[service] = info
|
|
||||||
|
|
||||||
if is_enabled("onion-addresses") and not is_active("onion-addresses"):
|
|
||||||
print("error: service 'onion-addresses' is not running")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
${concatStrings infos}
|
|
||||||
|
|
||||||
print(json.dumps(infos, indent=2))
|
|
||||||
'';
|
|
||||||
|
|
||||||
infos = map (serviceName:
|
|
||||||
let serviceCfg = config.services.${serviceName};
|
|
||||||
in optionalString serviceCfg.enable (cfg.services.${serviceName} serviceName serviceCfg)
|
|
||||||
) (builtins.attrNames cfg.services);
|
|
||||||
|
|
||||||
nodeinfoLib = rec {
|
|
||||||
mkInfo = extraCode: name: cfg:
|
|
||||||
mkInfoLong {
|
|
||||||
inherit extraCode name cfg;
|
|
||||||
};
|
|
||||||
|
|
||||||
mkInfoLong = { extraCode ? "", name, cfg, systemdServiceName ? name }: ''
|
|
||||||
add_service("${name}", """
|
|
||||||
info["local_address"] = "${nbLib.addressWithPort cfg.address cfg.port}"
|
|
||||||
'' + mkIfOnionPort name (onionPort: ''
|
|
||||||
info["onion_address"] = get_onion_address("${name}", ${onionPort})
|
|
||||||
'') + extraCode + ''
|
|
||||||
|
|
||||||
""", "${systemdServiceName}")
|
|
||||||
'';
|
|
||||||
|
|
||||||
mkIfOnionPort = name: fn:
|
|
||||||
if onionServices ? ${name} then
|
|
||||||
fn (toString (builtins.elemAt onionServices.${name}.map 0).port)
|
|
||||||
else
|
|
||||||
"";
|
|
||||||
};
|
|
||||||
|
|
||||||
inherit (config.services.tor.relay) onionServices;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
environment.systemPackages = [ script ];
|
|
||||||
|
|
||||||
nix-bitcoin.operator.enable = true;
|
|
||||||
|
|
||||||
nix-bitcoin.nodeinfo.services = with nodeinfoLib; {
|
|
||||||
bitcoind = mkInfo "";
|
|
||||||
lnd = name: cfg: mkInfo (''
|
|
||||||
info["rest_address"] = "${nbLib.addressWithPort cfg.restAddress cfg.restPort}"
|
|
||||||
'' + mkIfOnionPort "lnd-rest" (onionPort: ''
|
|
||||||
info["onion_rest_address"] = get_onion_address("lnd-rest", ${onionPort})
|
|
||||||
'') + ''
|
|
||||||
info["nodeid"] = shell("lncli getinfo | jq -r '.identity_pubkey'")
|
|
||||||
'') name cfg;
|
|
||||||
electrs = mkInfo "";
|
|
||||||
btcpayserver = mkInfo "";
|
|
||||||
rtl = mkInfo "";
|
|
||||||
mempool = mkInfo "";
|
|
||||||
mempool-frontend = name: cfg: mkInfoLong {
|
|
||||||
inherit name cfg;
|
|
||||||
systemdServiceName = "nginx";
|
|
||||||
};
|
|
||||||
# Only add sshd when it has an onion service
|
|
||||||
sshd = name: cfg: mkIfOnionPort "sshd" (onionPort: ''
|
|
||||||
add_service("sshd", """info["onion_address"] = get_onion_address("sshd", ${onionPort})""")
|
|
||||||
'');
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# This module enables unprivileged users to read onion addresses.
|
|
||||||
# By default, onion addresses in /var/lib/tor/onion are only readable by the
|
|
||||||
# tor user.
|
|
||||||
# The included service copies onion addresses to /var/lib/onion-addresses/<user>/
|
|
||||||
# and sets permissions according to option 'access'.
|
|
||||||
|
|
||||||
{ config, lib, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin.onionAddresses = {
|
|
||||||
access = mkOption {
|
|
||||||
type = with types; attrsOf (listOf str);
|
|
||||||
default = {};
|
|
||||||
description = ''
|
|
||||||
This option controls who is allowed to access onion addresses.
|
|
||||||
For example, the following allows user 'myuser' to access bitcoind
|
|
||||||
and clightning onion addresses:
|
|
||||||
```nix
|
|
||||||
{
|
|
||||||
"myuser" = [ "bitcoind" "clightning" ];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
The onion hostnames can then be read from
|
|
||||||
{file}`/var/lib/onion-addresses/myuser`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
services = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
description = ''
|
|
||||||
Services that can access their onion address via file
|
|
||||||
{file}`/var/lib/onion-addresses/<service>`
|
|
||||||
The file is readable only by the service user.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = "/var/lib/onion-addresses";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin.onionAddresses;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf (cfg.access != {} || cfg.services != []) {
|
|
||||||
systemd.services.onion-addresses = {
|
|
||||||
wantedBy = [ "tor.service" ];
|
|
||||||
bindsTo = [ "tor.service" ];
|
|
||||||
after = [ "tor.service" ];
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
StateDirectory = "onion-addresses";
|
|
||||||
StateDirectoryMode = "771";
|
|
||||||
PrivateNetwork = true; # This service needs no network access
|
|
||||||
PrivateUsers = false;
|
|
||||||
CapabilityBoundingSet = "CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER";
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
waitForFile() {
|
|
||||||
file=$1
|
|
||||||
for ((i=0; i<300; i++)); do
|
|
||||||
if [[ -e $file ]]; then
|
|
||||||
return;
|
|
||||||
fi
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
echo "Error: File $file did not appear after 30 sec."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Wait until tor is up
|
|
||||||
waitForFile /var/lib/tor/state
|
|
||||||
|
|
||||||
cd ${cfg.dataDir}
|
|
||||||
rm -rf ./*
|
|
||||||
|
|
||||||
${concatMapStrings
|
|
||||||
(user: ''
|
|
||||||
mkdir -p -m 0700 ${user}
|
|
||||||
chown ${user} ${user}
|
|
||||||
${concatMapStrings
|
|
||||||
(service: ''
|
|
||||||
onionFile='/var/lib/tor/onion/${service}/hostname'
|
|
||||||
waitForFile "$onionFile"
|
|
||||||
cp "$onionFile" '${user}/${service}'
|
|
||||||
chown '${user}' '${user}/${service}'
|
|
||||||
'')
|
|
||||||
cfg.access.${user}
|
|
||||||
}
|
|
||||||
'')
|
|
||||||
(builtins.attrNames cfg.access)
|
|
||||||
}
|
|
||||||
|
|
||||||
${concatMapStrings (service: ''
|
|
||||||
onionFile=/var/lib/tor/onion/${service}/hostname
|
|
||||||
waitForFile "$onionFile"
|
|
||||||
install -D -o ${config.systemd.services.${service}.serviceConfig.User} -m 400 "$onionFile" services/${service}
|
|
||||||
'') cfg.services}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# This module creates onion-services for NixOS services.
|
|
||||||
# An onion service can be enabled for every service that defines
|
|
||||||
# options 'address', 'port' and optionally 'getPublicAddressCmd'.
|
|
||||||
#
|
|
||||||
# See it in use at ./presets/enable-tor.nix
|
|
||||||
|
|
||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin.onionServices = mkOption {
|
|
||||||
default = {};
|
|
||||||
type = with types; attrsOf (submodule (
|
|
||||||
{ config, ... }: {
|
|
||||||
options = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = config.public;
|
|
||||||
description = ''
|
|
||||||
Create an onion service for the given service.
|
|
||||||
The service must define options {option}`address` and {option}`onionPort` (or `port`).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
public = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Make the onion address accessible to the service.
|
|
||||||
If enabled, the onion service is automatically enabled.
|
|
||||||
Only available for services that define option {option}`getPublicAddressCmd`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
externalPort = mkOption {
|
|
||||||
type = types.nullOr types.port;
|
|
||||||
default = null;
|
|
||||||
description = "Override the external port of the onion service.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin.onionServices;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
|
|
||||||
onionServices = builtins.attrNames cfg;
|
|
||||||
|
|
||||||
activeServices = builtins.filter (service:
|
|
||||||
config.services.${service}.enable && cfg.${service}.enable
|
|
||||||
) onionServices;
|
|
||||||
|
|
||||||
publicServices = builtins.filter (service: cfg.${service}.public) activeServices;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkMerge [
|
|
||||||
(mkIf (activeServices != []) {
|
|
||||||
# Define hidden services
|
|
||||||
services.tor = {
|
|
||||||
enable = true;
|
|
||||||
relay.onionServices = genAttrs activeServices (name:
|
|
||||||
let
|
|
||||||
service = config.services.${name};
|
|
||||||
inherit (cfg.${name}) externalPort;
|
|
||||||
in nbLib.mkOnionService {
|
|
||||||
port = if externalPort != null then externalPort else service.port;
|
|
||||||
target.port = service.onionPort or service.port;
|
|
||||||
target.addr = nbLib.address service.address;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.onionAddresses = {
|
|
||||||
# Enable public services to access their own onion addresses
|
|
||||||
services = publicServices;
|
|
||||||
|
|
||||||
# Allow the operator user to access onion addresses for all active services
|
|
||||||
access.${config.nix-bitcoin.operator.name} = mkIf config.nix-bitcoin.operator.enable activeServices;
|
|
||||||
};
|
|
||||||
systemd.services = let
|
|
||||||
onionAddresses = [ "onion-addresses.service" ];
|
|
||||||
in genAttrs publicServices (service: {
|
|
||||||
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
|
||||||
# that propagates initial start failures but no restarts
|
|
||||||
wants = onionAddresses;
|
|
||||||
after = onionAddresses;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
# Set getPublicAddressCmd for public services
|
|
||||||
{
|
|
||||||
services = let
|
|
||||||
# publicServices' doesn't depend on config.services.*.enable,
|
|
||||||
# so we can use it to define config.services without causing infinite recursion
|
|
||||||
publicServices' = builtins.filter (service:
|
|
||||||
let srv = cfg.${service};
|
|
||||||
in srv.public && srv.enable
|
|
||||||
) onionServices;
|
|
||||||
in genAttrs publicServices' (service: {
|
|
||||||
getPublicAddressCmd = "cat ${config.nix-bitcoin.onionAddresses.dataDir}/services/${service}";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set sensible defaults for some services
|
|
||||||
{
|
|
||||||
nix-bitcoin.onionServices = {
|
|
||||||
btcpayserver = {
|
|
||||||
externalPort = 80;
|
|
||||||
};
|
|
||||||
rtl = {
|
|
||||||
externalPort = 80;
|
|
||||||
};
|
|
||||||
mempool-frontend = {
|
|
||||||
externalPort = 80;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin.operator = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Whether to define a user named `operator` for convenient interactive access
|
|
||||||
to nix-bitcoin features (like `bitcoin-cli`).
|
|
||||||
|
|
||||||
When using nix-bitcoin as part of a larger system config, it makes sense
|
|
||||||
to set your main system user as the operator, by setting option
|
|
||||||
`nix-bitcoin.operator.name = "MAIN_USER_NAME";`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
name = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "operator";
|
|
||||||
description = "Name of the operator user.";
|
|
||||||
};
|
|
||||||
groups = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
description = "Extra groups of the operatur user.";
|
|
||||||
};
|
|
||||||
allowRunAsUsers = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
description = "Users as which the operator is allowed to run commands.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin.operator;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
users.users.${cfg.name} = {
|
|
||||||
isNormalUser = true;
|
|
||||||
extraGroups = [
|
|
||||||
"systemd-journal"
|
|
||||||
"proc" # Enable full /proc access and systemd-status
|
|
||||||
] ++ cfg.groups;
|
|
||||||
};
|
|
||||||
|
|
||||||
security = mkIf (cfg.allowRunAsUsers != []) {
|
|
||||||
# Use doas instead of sudo if enabled
|
|
||||||
doas.extraConfig = mkIf config.security.doas.enable ''
|
|
||||||
${lib.concatMapStrings (user: "permit nopass ${cfg.name} as ${user}\n") cfg.allowRunAsUsers}
|
|
||||||
'';
|
|
||||||
sudo.extraConfig = mkIf (!config.security.doas.enable) ''
|
|
||||||
${cfg.name} ALL=(${builtins.concatStringsSep "," cfg.allowRunAsUsers}) NOPASSWD: ALL
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services.rtl = {
|
|
||||||
enable = mkEnableOption "RTL, a web interface for LND";
|
|
||||||
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for HTTP connections.";
|
|
||||||
};
|
|
||||||
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 3000;
|
|
||||||
description = "Port to listen for HTTP connections.";
|
|
||||||
};
|
|
||||||
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/rtl";
|
|
||||||
description = "The data directory for RTL.";
|
|
||||||
};
|
|
||||||
|
|
||||||
nightTheme = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable night theme by default.";
|
|
||||||
};
|
|
||||||
|
|
||||||
extraCurrency = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = null;
|
|
||||||
example = "USD";
|
|
||||||
description = ''
|
|
||||||
Additional currency for displaying amounts.
|
|
||||||
When set, Tor is disabled for the RTL service to allow currency rate fetching.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
nodes = {
|
|
||||||
lnd = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable LND node in RTL.";
|
|
||||||
};
|
|
||||||
loop = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable Lightning Loop integration (requires loopd).";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
clightning = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable Core Lightning node in RTL (not supported in Sovran).";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "rtl";
|
|
||||||
description = "The user as which to run RTL.";
|
|
||||||
};
|
|
||||||
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run RTL.";
|
|
||||||
};
|
|
||||||
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.rtl;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
|
|
||||||
# Vendored RTL package
|
|
||||||
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
|
|
||||||
rtlPackage = pkgs.callPackage ../../packages/rtl { inherit fetchNodeModules; };
|
|
||||||
|
|
||||||
runePath = "${cfg.dataDir}/CLN-Rune.env";
|
|
||||||
|
|
||||||
rtlConfig = {
|
|
||||||
multiPass = "@multiPass@";
|
|
||||||
port = cfg.port;
|
|
||||||
host = cfg.address;
|
|
||||||
defaultNodeIndex = 1;
|
|
||||||
dbDirectoryPath = cfg.dataDir;
|
|
||||||
SSO = {
|
|
||||||
rtlSSO = 0;
|
|
||||||
rtlCookiePath = "";
|
|
||||||
logoutRedirectLink = "";
|
|
||||||
};
|
|
||||||
nodes = optional cfg.nodes.lnd.enable ({
|
|
||||||
index = 1;
|
|
||||||
lnNode = "lnd";
|
|
||||||
lnImplementation = "LND";
|
|
||||||
authentication = {
|
|
||||||
macaroonPath = "${cfg.dataDir}/macaroons";
|
|
||||||
swapMacaroonPath = if cfg.nodes.lnd.loop then "${cfg.dataDir}/loop-macaroons" else "";
|
|
||||||
boltzMacaroonPath = "";
|
|
||||||
};
|
|
||||||
settings = {
|
|
||||||
userPersona = "OPERATOR";
|
|
||||||
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
|
|
||||||
themeColor = "PURPLE";
|
|
||||||
channelBackupPath = "${cfg.dataDir}/backup";
|
|
||||||
logLevel = "INFO";
|
|
||||||
lnServerUrl = "https://${lnd.restAddress}:${toString lnd.restPort}";
|
|
||||||
swapServerUrl = if cfg.nodes.lnd.loop then "https://127.0.0.1:8081" else "";
|
|
||||||
boltzServerUrl = "";
|
|
||||||
fiatConversion = cfg.extraCurrency != null;
|
|
||||||
unannouncedChannels = true;
|
|
||||||
} // optionalAttrs (cfg.extraCurrency != null) {
|
|
||||||
currencyUnit = cfg.extraCurrency;
|
|
||||||
};
|
|
||||||
}) ++ optional cfg.nodes.clightning.enable {
|
|
||||||
index = 2;
|
|
||||||
lnNode = "clightning";
|
|
||||||
lnImplementation = "CLN";
|
|
||||||
authentication = {
|
|
||||||
runePath = runePath;
|
|
||||||
};
|
|
||||||
settings = {
|
|
||||||
userPersona = "OPERATOR";
|
|
||||||
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
|
|
||||||
themeColor = "PURPLE";
|
|
||||||
logLevel = "INFO";
|
|
||||||
fiatConversion = cfg.extraCurrency != null;
|
|
||||||
} // optionalAttrs (cfg.extraCurrency != null) {
|
|
||||||
currencyUnit = cfg.extraCurrency;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
configFile = builtins.toFile "config" (builtins.toJSON rtlConfig);
|
|
||||||
|
|
||||||
inherit (config.services)
|
|
||||||
bitcoind
|
|
||||||
lnd;
|
|
||||||
|
|
||||||
lndLoopEnabled = cfg.nodes.lnd.enable && cfg.nodes.lnd.loop;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
assertions = [
|
|
||||||
{ assertion = cfg.nodes.lnd.enable;
|
|
||||||
message = ''
|
|
||||||
RTL: At least one node must be enabled. Sovran supports LND only.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
{ assertion = !cfg.nodes.clightning.enable;
|
|
||||||
message = ''
|
|
||||||
RTL: Core Lightning (clightning) is not supported in Sovran. Use LND instead.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
services.lnd.enable = mkIf cfg.nodes.lnd.enable true;
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
services.rtl.tor.enforce = mkIf (cfg.extraCurrency != null) false;
|
|
||||||
|
|
||||||
systemd.services.rtl = rec {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
wants = optional cfg.nodes.lnd.enable "lnd.service";
|
|
||||||
after = wants ++ [ "nix-bitcoin-secrets.target" ];
|
|
||||||
environment.RTL_CONFIG_PATH = cfg.dataDir;
|
|
||||||
environment.DB_DIRECTORY_PATH = cfg.dataDir;
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
ExecStartPre = [
|
|
||||||
(nbLib.script "rtl-setup-config" ''
|
|
||||||
<${configFile} sed "s|@multiPass@|$(cat ${secretsDir}/rtl-password)|" \
|
|
||||||
> '${cfg.dataDir}/RTL-Config.json'
|
|
||||||
'')
|
|
||||||
]
|
|
||||||
++ optional cfg.nodes.lnd.enable
|
|
||||||
# The lnd admin macaroon is not readable by group `lnd`, so copy it
|
|
||||||
(nbLib.rootScript "rtl-copy-macaroon" ''
|
|
||||||
install --compare -m 640 -o ${cfg.user} -g ${cfg.group} -D ${lnd.networkDir}/admin.macaroon \
|
|
||||||
'${cfg.dataDir}/macaroons/admin.macaroon'
|
|
||||||
'');
|
|
||||||
ExecStart = "${rtlPackage}/bin/rtl";
|
|
||||||
# Show "rtl" instead of "node" in the journal
|
|
||||||
SyslogIdentifier = "rtl";
|
|
||||||
User = cfg.user;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.dataDir ];
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
|
||||||
// nbLib.nodejs;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
extraGroups = optional lndLoopEnabled lnd.group;
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
|
|
||||||
nix-bitcoin.secrets.rtl-password.user = cfg.user;
|
|
||||||
nix-bitcoin.generateSecretsCmds.rtl = ''
|
|
||||||
makePasswordSecret rtl-password
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin = {
|
|
||||||
secretsDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/etc/nix-bitcoin-secrets";
|
|
||||||
description = "Directory to store secrets";
|
|
||||||
};
|
|
||||||
|
|
||||||
setupSecrets = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Set permissions for existing secrets in {option}`nix-bitcoin.secretsDir`
|
|
||||||
before services are started.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
generateSecrets = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Automatically generate all required secrets before services are started.
|
|
||||||
Note: Make sure to create a backup of the generated secrets.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
generateSecretsCmds = mkOption {
|
|
||||||
type = types.attrsOf types.lines;
|
|
||||||
default = {};
|
|
||||||
description = ''
|
|
||||||
Bash expressions for generating secrets.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
# Currently, this is used only by ../deployment/nixops.nix
|
|
||||||
deployment.secretsDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
description = ''
|
|
||||||
Directory of local secrets that are transferred to the nix-bitcoin node on deployment
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
secrets = mkOption {
|
|
||||||
default = {};
|
|
||||||
type = with types; attrsOf (submodule (
|
|
||||||
{ config, ... }: {
|
|
||||||
options = {
|
|
||||||
user = mkOption {
|
|
||||||
type = str;
|
|
||||||
default = "root";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = str;
|
|
||||||
default = config.user;
|
|
||||||
};
|
|
||||||
permissions = mkOption {
|
|
||||||
type = str;
|
|
||||||
default = "440";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
secretsSetupMethod = mkOption {
|
|
||||||
type = with types; nullOr str;
|
|
||||||
default = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
generateSecretsScript = mkOption {
|
|
||||||
internal = true;
|
|
||||||
default = let
|
|
||||||
rpcauthSrc = pkgs.fetchurl {
|
|
||||||
url = "https://raw.githubusercontent.com/bitcoin/bitcoin/d6cde007db9d3e6ee93bd98a9bbfdce9bfa9b15b/share/rpcauth/rpcauth.py";
|
|
||||||
sha256 = "189mpplam6yzizssrgiyv70c9899ggh8cac76j4n7v0xqzfip07n";
|
|
||||||
};
|
|
||||||
rpcauth = pkgs.writers.writeBash "rpcauth" ''
|
|
||||||
exec ${pkgs.python3}/bin/python ${rpcauthSrc} "$@"
|
|
||||||
'';
|
|
||||||
# Writes secrets to PWD
|
|
||||||
in pkgs.writers.writeBash "generate-secrets" ''
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
export PATH=${lib.makeBinPath (with pkgs; [ coreutils gnugrep ])}
|
|
||||||
|
|
||||||
makePasswordSecret() {
|
|
||||||
# Passwords have alphabet {a-z, A-Z, 0-9} and ~119 bits of entropy
|
|
||||||
[[ -e $1 ]] || ${pkgs.pwgen}/bin/pwgen -s 20 1 > "$1"
|
|
||||||
}
|
|
||||||
makeBitcoinRPCPassword() {
|
|
||||||
user=$1
|
|
||||||
file=bitcoin-rpcpassword-$user
|
|
||||||
HMACfile=bitcoin-HMAC-$user
|
|
||||||
makePasswordSecret "$file"
|
|
||||||
if [[ $file -nt $HMACfile ]]; then
|
|
||||||
${rpcauth} $user $(cat "$file") | grep rpcauth | cut -d ':' -f 2 > "$HMACfile"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
makeCert() {
|
|
||||||
name=$1
|
|
||||||
# Add leading comma if not empty
|
|
||||||
extraAltNames=''${2:+,}''${2:-}
|
|
||||||
if [[ ! -e $name-key ]]; then
|
|
||||||
# Create new key and cert
|
|
||||||
doMakeCert "-newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout $name-key"
|
|
||||||
elif [[ ! -e $name-cert \
|
|
||||||
|| $(cat "$name-cert-alt-names" 2>/dev/null) != $extraAltNames ]]; then
|
|
||||||
# Create cert from existing key
|
|
||||||
doMakeCert "-key $name-key"
|
|
||||||
fi;
|
|
||||||
}
|
|
||||||
doMakeCert() {
|
|
||||||
# This fn uses global variables `name` and `extraAltNames`
|
|
||||||
keyOpts=$1
|
|
||||||
${pkgs.openssl}/bin/openssl req -x509 \
|
|
||||||
-sha256 -days 3650 $keyOpts -out "$name-cert" \
|
|
||||||
-subj "/CN=localhost/O=$name" \
|
|
||||||
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1$extraAltNames"
|
|
||||||
echo "$extraAltNames" > "$name-cert-alt-names"
|
|
||||||
}
|
|
||||||
|
|
||||||
umask u=rw,go=
|
|
||||||
${builtins.concatStringsSep "\n" (builtins.attrValues cfg.generateSecretsCmds)}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = {
|
|
||||||
assertions = [
|
|
||||||
{ assertion = cfg.secretsSetupMethod != null;
|
|
||||||
message = ''
|
|
||||||
No secrets setup method has been defined.
|
|
||||||
To fix this, choose one of the following:
|
|
||||||
|
|
||||||
- Use one of the deployment methods in ${toString ./../deployment}
|
|
||||||
|
|
||||||
- Set `nix-bitcoin.generateSecrets = true` to automatically generate secrets
|
|
||||||
|
|
||||||
- Set `nix-bitcoin.secretsSetupMethod = "manual"` if you want to manually setup secrets
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
# This target is active when secrets have been setup successfully.
|
|
||||||
systemd.targets.nix-bitcoin-secrets = mkIf (cfg.secretsSetupMethod != "manual") {
|
|
||||||
# This ensures that the secrets target is always activated when switching
|
|
||||||
# configurations.
|
|
||||||
# In this way `switch-to-configuration` is guaranteed to show an error
|
|
||||||
# when activating the secrets target fails on deployment.
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.setupSecrets = mkIf cfg.generateSecrets true;
|
|
||||||
|
|
||||||
nix-bitcoin.secretsSetupMethod = mkIf cfg.setupSecrets "setup-secrets";
|
|
||||||
|
|
||||||
# Operation of this service:
|
|
||||||
# - Set owner and permissions for all used secrets
|
|
||||||
# - Make all other secrets accessible to root only
|
|
||||||
# For all steps make sure that no secrets are copied to the nix store.
|
|
||||||
#
|
|
||||||
systemd.services.setup-secrets = mkIf cfg.setupSecrets {
|
|
||||||
requiredBy = [ "nix-bitcoin-secrets.target" ];
|
|
||||||
before = [ "nix-bitcoin-secrets.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
# Use the same sort order for globbing and sorting as in Nix attrsets.
|
|
||||||
# Required for `comm` below.
|
|
||||||
export LC_COLLATE=C
|
|
||||||
|
|
||||||
${optionalString cfg.generateSecrets ''
|
|
||||||
mkdir -p "${cfg.secretsDir}"
|
|
||||||
cd "${cfg.secretsDir}"
|
|
||||||
chown root: .
|
|
||||||
chmod 0700 .
|
|
||||||
${cfg.generateSecretsScript}
|
|
||||||
''}
|
|
||||||
|
|
||||||
setupSecret() {
|
|
||||||
file="$1"
|
|
||||||
user="$2"
|
|
||||||
group="$3"
|
|
||||||
permissions="$4"
|
|
||||||
if [[ ! -e $file ]]; then
|
|
||||||
echo "Error: Secret file '$file' is missing"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
chown "$user:$group" "$file"
|
|
||||||
chmod "$permissions" "$file"
|
|
||||||
processedFiles+=("$file")
|
|
||||||
}
|
|
||||||
|
|
||||||
dir="${cfg.secretsDir}"
|
|
||||||
if [[ ! -e $dir ]]; then
|
|
||||||
echo "Error: Secrets dir '$dir' is missing"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
chown root: "$dir"
|
|
||||||
cd "$dir"
|
|
||||||
|
|
||||||
processedFiles=()
|
|
||||||
${
|
|
||||||
concatStrings (mapAttrsToList (n: v: ''
|
|
||||||
setupSecret ${n} ${v.user} ${v.group} ${v.permissions}
|
|
||||||
'') cfg.secrets)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Make all other files accessible to root only
|
|
||||||
unprocessedFiles=$(
|
|
||||||
comm -23 <(shopt -s nullglob; printf '%s\n' *) <(printf '%s\n' "''${processedFiles[@]}")
|
|
||||||
)
|
|
||||||
if [[ $unprocessedFiles ]]; then
|
|
||||||
IFS=$'\n'
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
chown root: $unprocessedFiles
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
chmod 0440 $unprocessedFiles
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Now make the secrets dir accessible to other users
|
|
||||||
chmod 0751 "$dir"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
{
|
|
||||||
options = {
|
|
||||||
nix-bitcoin.security.dbusHideProcessInformation = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Only allow users with group `proc` to retrieve systemd unit information like
|
|
||||||
cgroup paths (i.e. (sub)process command lines) via D-Bus.
|
|
||||||
|
|
||||||
This mitigates a systemd security issue where (sub)process command lines can
|
|
||||||
be retrieved by services even when their access to /proc is restricted
|
|
||||||
(via ProtectProc).
|
|
||||||
|
|
||||||
This option works by restricting the D-Bus method `GetUnitProcesses`, which
|
|
||||||
is also used internally by {command}`systemctl status`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = mkIf config.nix-bitcoin.security.dbusHideProcessInformation {
|
|
||||||
users.groups.proc = {};
|
|
||||||
nix-bitcoin.operator.groups = [ "proc" ]; # Enable operator access to systemd-status
|
|
||||||
|
|
||||||
services.dbus.packages = lib.mkAfter [ # Apply at the end to override the default policy
|
|
||||||
(pkgs.writeTextDir "etc/dbus-1/system.d/dbus.conf" ''
|
|
||||||
<busconfig>
|
|
||||||
<policy context="default">
|
|
||||||
<deny
|
|
||||||
send_destination="org.freedesktop.systemd1"
|
|
||||||
send_interface="org.freedesktop.systemd1.Manager"
|
|
||||||
send_member="GetUnitProcesses"
|
|
||||||
/>
|
|
||||||
</policy>
|
|
||||||
<policy group="proc">
|
|
||||||
<allow
|
|
||||||
send_destination="org.freedesktop.systemd1"
|
|
||||||
send_interface="org.freedesktop.systemd1.Manager"
|
|
||||||
send_member="GetUnitProcesses"
|
|
||||||
/>
|
|
||||||
</policy>
|
|
||||||
</busconfig>
|
|
||||||
'')
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{ config, lib, ... }:
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin.configVersion = mkOption {
|
|
||||||
type = with types; nullOr str;
|
|
||||||
default = null;
|
|
||||||
description = "Vendored stub — no version migration needed.";
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
config = {};
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
package = pkgs.bitcoind-knots;
|
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
|
||||||
txindex = true;
|
|
||||||
tor.proxy = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
disablewallet = true;
|
|
||||||
extraConfig = ''
|
|
||||||
peerbloomfilters=1
|
|
||||||
server=1
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.onionServices.bitcoind.enable = true;
|
|
||||||
nix-bitcoin.onionServices.electrs.enable = true;
|
|
||||||
nix-bitcoin.onionServices.rtl.enable = true;
|
|
||||||
|
|
||||||
services.electrs = {
|
|
||||||
enable = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data";
|
|
||||||
};
|
|
||||||
|
|
||||||
services.lnd = {
|
|
||||||
enable = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
tor.proxy = true;
|
|
||||||
extraConfig = ''
|
|
||||||
protocol.option-scid-alias=true
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.onionServices.lnd.public = true;
|
|
||||||
|
|
||||||
services.lnd.lndconnect = {
|
|
||||||
enable = true;
|
|
||||||
onion = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.rtl = {
|
|
||||||
enable = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
port = 3050;
|
|
||||||
nightTheme = true;
|
|
||||||
nodes = {
|
|
||||||
lnd = {
|
|
||||||
enable = true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
services.btcpayserver = {
|
|
||||||
enable = config.sovran_systemsOS.web.btcpayserver;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.btcpayserver.lightningBackend = "lnd";
|
|
||||||
|
|
||||||
nix-bitcoin.generateSecrets = true;
|
|
||||||
nix-bitcoin.nodeinfo.enable = true;
|
|
||||||
|
|
||||||
nix-bitcoin.operator = {
|
|
||||||
enable = true;
|
|
||||||
name = "free";
|
|
||||||
};
|
|
||||||
|
|
||||||
# vendored: now no-op (always uses nixpkgs)
|
|
||||||
nix-bitcoin.useVersionLockedPkgs = false;
|
|
||||||
|
|
||||||
systemd.services.bitcoind = {
|
|
||||||
requires = [ "run-media-Second_Drive.mount" ];
|
|
||||||
after = [ "run-media-Second_Drive.mount" ];
|
|
||||||
serviceConfig.PrivateUsers = lib.mkForce false;
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.electrs = {
|
|
||||||
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
|
|
||||||
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
|
|
||||||
wants = [ "bitcoind.service" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.lnd = {
|
|
||||||
wants = [ "bitcoind.service" ];
|
|
||||||
# requires for bitcoind set by nix-bitcoin; mkForce removes it
|
|
||||||
requires = lib.mkForce [ ];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.sovran-btc-permissions = {
|
|
||||||
description = "Fix Bitcoin/Electrs data directory ownership on second drive";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
after = [ "run-media-Second_Drive.mount" ];
|
|
||||||
before = [ "bitcoind.service" "electrs.service" ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node ]; then
|
|
||||||
chown -R bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
|
|
||||||
fi
|
|
||||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data ]; then
|
|
||||||
chown -R electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 3051 ];
|
|
||||||
networking.firewall.allowedUDPPorts = [ 3051 ];
|
|
||||||
|
|
||||||
sovran_systemsOS.domainRequirements = [
|
|
||||||
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
+17
-4
@@ -33,7 +33,7 @@
|
|||||||
# /var/lib/njalla/ddns_urls.json.
|
# /var/lib/njalla/ddns_urls.json.
|
||||||
NoNewPrivileges = true;
|
NoNewPrivileges = true;
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
ReadWritePaths = [ "/var/lib/njalla" ];
|
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
||||||
ReadOnlyPaths = [ "/etc/sovran" ];
|
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||||
ProtectHome = true;
|
ProtectHome = true;
|
||||||
PrivateTmp = true;
|
PrivateTmp = true;
|
||||||
@@ -88,12 +88,15 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
sys.exit(0) # no URLs configured — nothing to do
|
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 = ""
|
public_ip = ""
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
|
||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=20,
|
||||||
)
|
)
|
||||||
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
|
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
|
||||||
ipaddress.ip_address(raw) # validates — raises if not a real IP
|
ipaddress.ip_address(raw) # validates — raises if not a real IP
|
||||||
@@ -101,6 +104,16 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
if not public_ip:
|
||||||
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
|
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
{
|
{
|
||||||
config = lib.mkMerge [
|
config = lib.mkMerge [
|
||||||
|
|
||||||
# Vendored nix-bitcoin is always imported via modules/vendor/nix-bitcoin/modules.nix.
|
# The Sovran_Bitcoin flake module is always imported (see flake.nix).
|
||||||
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
||||||
# without enabling any Bitcoin services.
|
# without enabling any Bitcoin services.
|
||||||
{
|
{
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
haven = lib.mkForce false;
|
haven = lib.mkForce false;
|
||||||
mempool = lib.mkForce false;
|
mempool = lib.mkForce false;
|
||||||
element-calling = lib.mkForce false;
|
element-calling = lib.mkForce false;
|
||||||
bitcoin-core = lib.mkForce false;
|
bitcoin-tor-gossip = lib.mkForce false;
|
||||||
"nwc-wallets" = lib.mkForce false;
|
"nwc-wallets" = lib.mkForce false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+44
-22
@@ -45,23 +45,20 @@
|
|||||||
haven = lib.mkEnableOption "Haven NOSTR relay";
|
haven = lib.mkEnableOption "Haven NOSTR relay";
|
||||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||||
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
bitcoin-tor-gossip = lib.mkEnableOption "Advertise the Bitcoin Core onion service through Bitcoin peer gossip";
|
||||||
"nwc-wallets" = lib.mkEnableOption "Lightning Wallet Connections";
|
# Compatibility shim for Hub-managed settings from releases where Core
|
||||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
# was an optional replacement for the default node. Core is now always
|
||||||
sshd = lib.mkEnableOption "SSH remote access";
|
# selected when the Bitcoin service is enabled.
|
||||||
|
bitcoin-core = lib.mkOption {
|
||||||
# Deprecated: BIP-110 is now built into mainline Bitcoin Knots and is the
|
|
||||||
# default node. This option is retained ONLY so that existing machines with
|
|
||||||
# `sovran_systemsOS.features.bip110 = lib.mkForce true;` left in their local
|
|
||||||
# custom.nix continue to evaluate. It has no effect and will be removed in a
|
|
||||||
# future release once the Hub has cleaned up old custom.nix files.
|
|
||||||
bip110 = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.bool;
|
type = lib.types.nullOr lib.types.bool;
|
||||||
default = null;
|
default = null;
|
||||||
internal = true;
|
internal = true;
|
||||||
visible = false;
|
visible = false;
|
||||||
description = "(Deprecated, no-op) BIP-110 is now built into Bitcoin Knots.";
|
description = "Deprecated no-op: Bitcoin Core is the default node implementation.";
|
||||||
};
|
};
|
||||||
|
"nwc-wallets" = lib.mkEnableOption "Lightning Wallet Connections";
|
||||||
|
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||||
|
sshd = lib.mkEnableOption "SSH remote access";
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── Web exposure (controls Caddy vhosts) ──────────────────
|
# ── Web exposure (controls Caddy vhosts) ──────────────────
|
||||||
@@ -82,6 +79,41 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# ── Element Calling (video/audio) tuning ──────────────────
|
||||||
|
elementCalling = {
|
||||||
|
fullAccessHomeservers = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [ "matrix.peer.example.com" ];
|
||||||
|
description = ''
|
||||||
|
Additional Matrix server_names (beyond this server itself) that may
|
||||||
|
trigger LiveKit room creation on this server's SFU via lk-jwt-service.
|
||||||
|
|
||||||
|
Not needed for the common federated setup: each participant's client
|
||||||
|
always obtains its token from its own homeserver's JWT service and
|
||||||
|
publishes to its own SFU, and the participant who starts a call
|
||||||
|
creates the room on their own SFU — the remote user merely joins
|
||||||
|
(joining does not require full access).
|
||||||
|
|
||||||
|
Only set this for asymmetric cases: e.g. a peer homeserver that has
|
||||||
|
no focus of its own, or calls whose first participant lands on this
|
||||||
|
server's SFU but belongs to the peer.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
externalIP = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "203.0.113.10";
|
||||||
|
description = ''
|
||||||
|
Optional pin: force LiveKit to advertise this public IPv4 in its
|
||||||
|
host/TURN ICE candidates. Not required in normal operation — the
|
||||||
|
module auto-detects the public IP at runtime (HTTPS egress
|
||||||
|
detection, falling back to STUN). Set it only to override a
|
||||||
|
mis-detected address (e.g. multi-WAN/VPN setups).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# ── Domain setup registry ─────────────────────────────────
|
# ── Domain setup registry ─────────────────────────────────
|
||||||
domainRequirements = lib.mkOption {
|
domainRequirements = lib.mkOption {
|
||||||
type = lib.types.listOf (lib.types.submodule {
|
type = lib.types.listOf (lib.types.submodule {
|
||||||
@@ -103,14 +135,4 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf (config.sovran_systemsOS.features.bip110 != null) {
|
|
||||||
warnings = [
|
|
||||||
''
|
|
||||||
sovran_systemsOS.features.bip110 is deprecated and has no effect:
|
|
||||||
BIP-110 is now built into mainline Bitcoin Knots, which is the default node.
|
|
||||||
You can safely remove the `sovran_systemsOS.features.bip110` line from
|
|
||||||
/etc/nixos/custom.nix. The Sovran Hub will also remove it automatically.
|
|
||||||
''
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-50
@@ -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"; }
|
{ label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above"; }
|
||||||
]; }
|
]; }
|
||||||
]
|
]
|
||||||
# ── Bitcoin Base (node implementations) ────────────────────
|
# ── Bitcoin Base ────────────────────────────────────────────
|
||||||
++ lib.optionals cfg.services.bitcoin [
|
++ lib.optionals cfg.services.bitcoin [
|
||||||
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
|
||||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
{ label = "Tor Bitcoin P2P Address — Reachable only through Tor"; file = "/var/lib/tor/onion/bitcoind/hostname"; suffix = ":8333"; }
|
||||||
]; }
|
|
||||||
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
|
||||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
|
||||||
]; }
|
]; }
|
||||||
]
|
]
|
||||||
# ── Bitcoin Apps (services on top of the node) ─────────────
|
# ── Bitcoin Apps (services on top of the node) ─────────────
|
||||||
@@ -125,6 +122,9 @@ let
|
|||||||
role = activeRole;
|
role = activeRole;
|
||||||
services = monitoredServices;
|
services = monitoredServices;
|
||||||
feature_manager = true;
|
feature_manager = true;
|
||||||
|
feature_states = {
|
||||||
|
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
|
||||||
|
};
|
||||||
sovran_version = sovranVersion;
|
sovran_version = sovranVersion;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,19 +132,16 @@ let
|
|||||||
"caddy.service" = if pkgs ? caddy then pkgs.caddy.version else "2.8.4";
|
"caddy.service" = if pkgs ? caddy then pkgs.caddy.version else "2.8.4";
|
||||||
"tor.service" = if pkgs ? tor then pkgs.tor.version else "0.4.8.12";
|
"tor.service" = if pkgs ? tor then pkgs.tor.version else "0.4.8.12";
|
||||||
"gnome-remote-desktop.service" = if pkgs ? gnome-remote-desktop then pkgs.gnome-remote-desktop.version else "46.0";
|
"gnome-remote-desktop.service" = if pkgs ? gnome-remote-desktop then pkgs.gnome-remote-desktop.version else "46.0";
|
||||||
"bitcoind.service" = if pkgs ? bitcoind-knots then pkgs.bitcoind-knots.version else (if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0");
|
"bitcoind.service" = if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0";
|
||||||
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
|
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
|
||||||
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
||||||
# Keep the fallbacks aligned with the vendored packages used by the
|
# Vendored packages come from the Sovran_Bitcoin flake overlay
|
||||||
# service modules (RTL 0.15.8 and Mempool 3.2.1). The nixpkgs attrs are
|
# (pkgs.sovran-bitcoin.*). Evaluate them directly so the Hub shows
|
||||||
# optional because these packages are built locally in this repository.
|
# the exact versions this system ships.
|
||||||
"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" = pkgs.sovran-bitcoin.rtl.version;
|
||||||
# BTCPay Server is intentionally sourced from pkgs.stable by the service
|
|
||||||
# module. Read the configured package here rather than pkgs.btcpayserver
|
|
||||||
# (unstable), otherwise the Hub can advertise a version that is not running.
|
|
||||||
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
||||||
"albyhub.service" = if pkgs ? albyhub then pkgs.albyhub.version else "1.8.0";
|
"albyhub.service" = pkgs.sovran-bitcoin.albyhub.version;
|
||||||
"mempool.service" = if pkgs ? mempool then pkgs.mempool.version else "3.2.1";
|
"mempool.service" = pkgs.sovran-bitcoin.mempool-backend.version;
|
||||||
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
||||||
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
||||||
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
||||||
@@ -160,8 +157,10 @@ let
|
|||||||
|
|
||||||
LOG="/var/log/sovran-hub-update.log"
|
LOG="/var/log/sovran-hub-update.log"
|
||||||
STATUS="/var/log/sovran-hub-update.status"
|
STATUS="/var/log/sovran-hub-update.status"
|
||||||
|
GENERATION="/var/log/sovran-hub-update.generation"
|
||||||
|
|
||||||
echo "RUNNING" > "$STATUS"
|
echo "RUNNING" > "$STATUS"
|
||||||
|
rm -f "$GENERATION"
|
||||||
: > "$LOG"
|
: > "$LOG"
|
||||||
exec > >(tee -a "$LOG") 2>&1
|
exec > >(tee -a "$LOG") 2>&1
|
||||||
|
|
||||||
@@ -185,16 +184,23 @@ let
|
|||||||
|
|
||||||
if [ "$RC" -eq 0 ]; then
|
if [ "$RC" -eq 0 ]; then
|
||||||
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
||||||
BOOT_OUT=$(nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
# Stream output straight into $LOG (see rebuild-script) so the Hub UI
|
||||||
|
# shows live progress instead of an empty log during long builds.
|
||||||
|
nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||||
--option connect-timeout 10 \
|
--option connect-timeout 10 \
|
||||||
--option stalled-download-timeout 90 \
|
--option stalled-download-timeout 90 \
|
||||||
--option download-attempts 7 \
|
--option download-attempts 7 \
|
||||||
--option fallback true 2>&1)
|
--option fallback true
|
||||||
BOOT_RC=$?
|
BOOT_RC=$?
|
||||||
echo "$BOOT_OUT"
|
|
||||||
if [ "$BOOT_RC" -ne 0 ]; then
|
if [ "$BOOT_RC" -ne 0 ]; then
|
||||||
echo "[ERROR] nixos-rebuild boot failed"
|
echo "[ERROR] nixos-rebuild boot failed"
|
||||||
RC=1
|
RC=1
|
||||||
|
elif ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
|
||||||
|
# The marker is informational only. The Hub derives pending-reboot
|
||||||
|
# state from the NixOS system profile itself, so failing to record
|
||||||
|
# the marker must not fail an otherwise successful update.
|
||||||
|
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
||||||
|
rm -f "$GENERATION"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
@@ -240,20 +246,23 @@ let
|
|||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo ""
|
echo ""
|
||||||
echo "── Rebuilding system configuration ──────────────"
|
echo "── Rebuilding system configuration ──────────────"
|
||||||
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
# Stream output straight into $LOG (tee'd by the exec redirect above) so
|
||||||
|
# the Hub UI shows live progress. Capturing the output in a variable
|
||||||
|
# kept the log empty for the entire build+activation, which made long
|
||||||
|
# rebuilds can otherwise look like a hang.
|
||||||
|
nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||||
--option connect-timeout 10 \
|
--option connect-timeout 10 \
|
||||||
--option stalled-download-timeout 90 \
|
--option stalled-download-timeout 90 \
|
||||||
--option download-attempts 7 \
|
--option download-attempts 7 \
|
||||||
--option fallback true 2>&1)
|
--option fallback true
|
||||||
SWITCH_RC=$?
|
SWITCH_RC=$?
|
||||||
echo "$SWITCH_OUT"
|
|
||||||
if [ "$SWITCH_RC" -eq 0 ]; then
|
if [ "$SWITCH_RC" -eq 0 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo " ✓ Rebuild completed successfully"
|
echo " ✓ Rebuild completed successfully"
|
||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo "SUCCESS" > "$STATUS"
|
echo "SUCCESS" > "$STATUS"
|
||||||
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then
|
elif grep -q "switchInhibitors\|Pre-switch checks failed" "$LOG"; then
|
||||||
echo ""
|
echo ""
|
||||||
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
||||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||||
@@ -278,17 +287,31 @@ let
|
|||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# ── Brave launcher wrapper: stable profile dir so Wayland app_id is
|
# ── Brave Origin launcher wrapper: a *persistent* per-user profile dir.
|
||||||
# deterministic and GNOME Shell can match the window to the .desktop
|
# It must NOT be wiped on exit: the Hub's logout marker cookie
|
||||||
# entry (fixes generic gear icon appearing in the dock).
|
# (hub_manual_logout) and the session cookie live in this profile.
|
||||||
|
# Launching from a fresh/ephemeral profile every time throws away the
|
||||||
|
# marker, so /auto-login would mint a new session and silently log the
|
||||||
|
# user straight back in after they signed out and reopened the window.
|
||||||
|
# A stable directory also keeps the Wayland app_id deterministic so
|
||||||
|
# GNOME Shell can match the window to the .desktop entry (dock icon).
|
||||||
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
|
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
|
||||||
export PATH="${lib.makeBinPath [ pkgs.brave pkgs.coreutils ]}:$PATH"
|
export PATH="${lib.makeBinPath [ pkgs.brave-origin pkgs.coreutils ]}:$PATH"
|
||||||
HUB_DATA="/tmp/sovran-hub-brave-$(id -u)"
|
# Per-user, persistent browser state. $XDG_STATE_HOME keeps it out of the
|
||||||
|
# way of backups and survives reboots and window close/reopen.
|
||||||
|
if [ -n "$XDG_STATE_HOME" ]; then
|
||||||
|
HUB_DATA="$XDG_STATE_HOME/sovran-hub-browser"
|
||||||
|
else
|
||||||
|
HUB_DATA="$HOME/.local/state/sovran-hub-browser"
|
||||||
|
fi
|
||||||
mkdir -p "$HUB_DATA"
|
mkdir -p "$HUB_DATA"
|
||||||
trap '[ -n "$HUB_DATA" ] && rm -rf "$HUB_DATA"' EXIT INT TERM
|
|
||||||
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
||||||
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
||||||
brave --app=http://localhost:8937/auto-login \
|
# With a persistent profile Brave Origin's one-time "Proceed with Origin
|
||||||
|
# for free on Linux" onboarding dialog only appears once; keep skipping it
|
||||||
|
# anyway so it can never block auto-login (Linux-only switch).
|
||||||
|
brave-origin --app=http://localhost:8937/auto-login \
|
||||||
|
--skip-origin-startup-dialog \
|
||||||
--class=sovran-hub \
|
--class=sovran-hub \
|
||||||
--user-data-dir="$HUB_DATA" \
|
--user-data-dir="$HUB_DATA" \
|
||||||
--password-store=basic \
|
--password-store=basic \
|
||||||
@@ -382,6 +405,9 @@ DESKTOP
|
|||||||
import os, sys
|
import os, sys
|
||||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
base = os.path.join("$out", "lib", "sovran-hub-web")
|
||||||
sys.path.insert(0, base)
|
sys.path.insert(0, base)
|
||||||
|
# Canonical NWC implementation — single source of truth from the
|
||||||
|
# Sovran_Bitcoin flake (imported directly by server.py).
|
||||||
|
sys.path.insert(0, os.path.join("${pkgs.sovran-bitcoin.nwc}", "lib", "sovran-nwc"))
|
||||||
os.environ["SOVRAN_HUB_CONFIG"] = os.path.join(base, "config.json")
|
os.environ["SOVRAN_HUB_CONFIG"] = os.path.join(base, "config.json")
|
||||||
os.environ["SOVRAN_HUB_VERSIONS"] = os.path.join(base, "versions.json")
|
os.environ["SOVRAN_HUB_VERSIONS"] = os.path.join(base, "versions.json")
|
||||||
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
|
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
|
||||||
@@ -395,26 +421,9 @@ uvicorn.run(
|
|||||||
LAUNCHER
|
LAUNCHER
|
||||||
chmod +x $out/bin/sovran-hub-web
|
chmod +x $out/bin/sovran-hub-web
|
||||||
|
|
||||||
cat > $out/bin/nwc-wallet <<LAUNCHER
|
# nwc-wallet / nwc-lnurl binaries are no longer shipped here: the
|
||||||
#!${pkgs.python3}/bin/python3
|
# Sovran_Bitcoin flake provides them (env-wrapped nwc-wallet via
|
||||||
import os, sys
|
# albyhub.nix, nwc-lnurl.service via lnurl.nix) — single source of truth.
|
||||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
|
||||||
sys.path.insert(0, base)
|
|
||||||
from sovran_systemsos_web.nwc_wallet_cli import main
|
|
||||||
sys.exit(main())
|
|
||||||
LAUNCHER
|
|
||||||
chmod +x $out/bin/nwc-wallet
|
|
||||||
|
|
||||||
cat > $out/bin/nwc-lnurl <<LAUNCHER
|
|
||||||
#!${pkgs.python3}/bin/python3
|
|
||||||
import os, sys
|
|
||||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
|
||||||
sys.path.insert(0, base)
|
|
||||||
from sovran_systemsos_web.nwc_lnurl_service import main
|
|
||||||
main()
|
|
||||||
LAUNCHER
|
|
||||||
chmod +x $out/bin/nwc-lnurl
|
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,35 @@ let
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Brave Origin migration (existing installs upgrading from `brave`) ──
|
||||||
|
# The old package's brave-browser.desktop no longer exists, so GNOME drops
|
||||||
|
# it from the dock and the Browsers folder. Swap the id in place, but only
|
||||||
|
# when the stale value is still present (never touch user customizations).
|
||||||
|
if [ -f "$USER_DB" ]; then
|
||||||
|
FAVS="$(${pkgs.dconf}/bin/dconf read /org/gnome/shell/favorite-apps 2>/dev/null || true)"
|
||||||
|
if [ -n "$FAVS" ]; then
|
||||||
|
NEW_FAVS="''${FAVS//brave-browser.desktop/brave-origin.desktop}"
|
||||||
|
if [ "$NEW_FAVS" != "$FAVS" ]; then
|
||||||
|
${pkgs.dconf}/bin/dconf write /org/gnome/shell/favorite-apps "$NEW_FAVS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
BROWSER_APPS="$(${pkgs.dconf}/bin/dconf read /org/gnome/desktop/app-folders/folders/Browsers/apps 2>/dev/null || true)"
|
||||||
|
if [ -n "$BROWSER_APPS" ]; then
|
||||||
|
NEW_BROWSER_APPS="''${BROWSER_APPS//brave-browser.desktop/brave-origin.desktop}"
|
||||||
|
if [ "$NEW_BROWSER_APPS" != "$BROWSER_APPS" ]; then
|
||||||
|
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/app-folders/folders/Browsers/apps "$NEW_BROWSER_APPS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A previous fresh install wrote the user's mimeapps.list with the old id;
|
||||||
|
# XDG gives it precedence over the new system-wide default, so rewrite it.
|
||||||
|
MIME_LIST="$HOME/.config/mimeapps.list"
|
||||||
|
if [ -f "$MIME_LIST" ] && ${pkgs.gnugrep}/bin/grep -q 'brave-browser\.desktop' "$MIME_LIST"; then
|
||||||
|
${pkgs.gnused}/bin/sed -i 's/brave-browser\.desktop/brave-origin.desktop/g' "$MIME_LIST"
|
||||||
|
fi
|
||||||
|
|
||||||
# Already applied — skip
|
# Already applied — skip
|
||||||
if [ -f "$STAMP" ]; then
|
if [ -f "$STAMP" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
@@ -61,11 +90,11 @@ let
|
|||||||
mkdir -p "$HOME/.config"
|
mkdir -p "$HOME/.config"
|
||||||
cat > "$HOME/.config/mimeapps.list" << EOF
|
cat > "$HOME/.config/mimeapps.list" << EOF
|
||||||
[Default Applications]
|
[Default Applications]
|
||||||
text/html=brave-browser.desktop
|
text/html=brave-origin.desktop
|
||||||
x-scheme-handler/http=brave-browser.desktop
|
x-scheme-handler/http=brave-origin.desktop
|
||||||
x-scheme-handler/https=brave-browser.desktop
|
x-scheme-handler/https=brave-origin.desktop
|
||||||
x-scheme-handler/about=brave-browser.desktop
|
x-scheme-handler/about=brave-origin.desktop
|
||||||
x-scheme-handler/unknown=brave-browser.desktop
|
x-scheme-handler/unknown=brave-origin.desktop
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
${pkgs.dconf}/bin/dconf load / << EOF
|
${pkgs.dconf}/bin/dconf load / << EOF
|
||||||
@@ -104,7 +133,7 @@ search-filter-time-type='last_modified'
|
|||||||
[org/gnome/shell]
|
[org/gnome/shell]
|
||||||
disabled-extensions=['just-perfection-desktop@just-perfection']
|
disabled-extensions=['just-perfection-desktop@just-perfection']
|
||||||
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'light-style@gnome-shell-extensions.gcampax.github.com']
|
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'light-style@gnome-shell-extensions.gcampax.github.com']
|
||||||
favorite-apps=['brave-browser.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
|
favorite-apps=['brave-origin.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
|
||||||
welcome-dialog-last-shown-version='48.4'
|
welcome-dialog-last-shown-version='48.4'
|
||||||
|
|
||||||
[org/gnome/desktop/app-folders]
|
[org/gnome/desktop/app-folders]
|
||||||
@@ -112,7 +141,7 @@ folder-children=['Browsers', 'Office', 'Terminal', 'Chat', 'Bitcoin', 'Media', '
|
|||||||
|
|
||||||
[org/gnome/desktop/app-folders/folders/Browsers]
|
[org/gnome/desktop/app-folders/folders/Browsers]
|
||||||
name='Browsers'
|
name='Browsers'
|
||||||
apps=['brave-browser.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
|
apps=['brave-origin.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
|
||||||
|
|
||||||
[org/gnome/desktop/app-folders/folders/Office]
|
[org/gnome/desktop/app-folders/folders/Office]
|
||||||
name='Office'
|
name='Office'
|
||||||
@@ -269,7 +298,7 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
favorite-apps = [
|
favorite-apps = [
|
||||||
"brave-browser.desktop"
|
"brave-origin.desktop"
|
||||||
"org.gnome.Settings.desktop"
|
"org.gnome.Settings.desktop"
|
||||||
"org.gnome.Nautilus.desktop"
|
"org.gnome.Nautilus.desktop"
|
||||||
"sovran-hub.desktop"
|
"sovran-hub.desktop"
|
||||||
@@ -292,7 +321,7 @@ in
|
|||||||
"org/gnome/desktop/app-folders/folders/Browsers" = {
|
"org/gnome/desktop/app-folders/folders/Browsers" = {
|
||||||
name = "Browsers";
|
name = "Browsers";
|
||||||
apps = [
|
apps = [
|
||||||
"brave-browser.desktop"
|
"brave-origin.desktop"
|
||||||
"firefox.desktop"
|
"firefox.desktop"
|
||||||
"org.gnome.Epiphany.desktop"
|
"org.gnome.Epiphany.desktop"
|
||||||
];
|
];
|
||||||
@@ -433,13 +462,13 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
xdg.mime.defaultApplications = {
|
xdg.mime.defaultApplications = {
|
||||||
"text/html" = "brave-browser.desktop";
|
"text/html" = "brave-origin.desktop";
|
||||||
"x-scheme-handler/http" = "brave-browser.desktop";
|
"x-scheme-handler/http" = "brave-origin.desktop";
|
||||||
"x-scheme-handler/https" = "brave-browser.desktop";
|
"x-scheme-handler/https" = "brave-origin.desktop";
|
||||||
"x-scheme-handler/about" = "brave-browser.desktop";
|
"x-scheme-handler/about" = "brave-origin.desktop";
|
||||||
"x-scheme-handler/unknown" = "brave-browser.desktop";
|
"x-scheme-handler/unknown" = "brave-origin.desktop";
|
||||||
};
|
};
|
||||||
|
|
||||||
environment.sessionVariables.BROWSER = "brave-browser";
|
environment.sessionVariables.BROWSER = "brave-origin";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+261
-21
@@ -33,9 +33,15 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
####### ENSURE SERVICES START AFTER KEY EXISTS #######
|
####### ENSURE SERVICES START AFTER KEY & NETWORK EXIST #######
|
||||||
systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
# Ordering against network-online.target matters: livekit-turn-setup detects
|
||||||
systemd.services.livekit.wants = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
# the primary interface from the IPv4 default route. If it runs before the
|
||||||
|
# network is up (no default route yet) it exits 1 and, being a hard
|
||||||
|
# dependency of livekit.service, takes livekit down with it — the Hub then
|
||||||
|
# shows a "failed" red dot until livekit is restarted manually. See the
|
||||||
|
# livekit-turn-setup block for the matching network-online ordering.
|
||||||
|
systemd.services.livekit.after = [ "network-online.target" "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||||
|
systemd.services.livekit.wants = [ "network-online.target" "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||||
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
|
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
|
||||||
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
|
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
|
||||||
|
|
||||||
@@ -114,7 +120,12 @@ EOF
|
|||||||
# substituted) that the overridden ExecStart loads.
|
# substituted) that the overridden ExecStart loads.
|
||||||
systemd.services.livekit-turn-setup = {
|
systemd.services.livekit-turn-setup = {
|
||||||
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
|
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
|
||||||
after = [ "caddy.service" "livekit-key-setup.service" ];
|
# Wait for a default IPv4 route before detecting the interface, and for
|
||||||
|
# Caddy to have started (cert generation is async, so also see the retry
|
||||||
|
# loop below). Otherwise on a cold boot this unit can fail / produce empty
|
||||||
|
# certs, which breaks livekit.service (requiredBy) and shows a red dot.
|
||||||
|
after = [ "network-online.target" "caddy.service" "livekit-key-setup.service" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
before = [ "livekit.service" ];
|
before = [ "livekit.service" ];
|
||||||
requiredBy = [ "livekit.service" ];
|
requiredBy = [ "livekit.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
@@ -125,19 +136,39 @@ EOF
|
|||||||
unitConfig = {
|
unitConfig = {
|
||||||
ConditionPathExists = "/var/lib/domains/element-calling";
|
ConditionPathExists = "/var/lib/domains/element-calling";
|
||||||
};
|
};
|
||||||
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ];
|
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk pkgs.python3 ];
|
||||||
script = ''
|
script = ''
|
||||||
MATRIX=$(cat /var/lib/domains/matrix)
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
|
|
||||||
mkdir -p /run/livekit
|
mkdir -p /run/livekit
|
||||||
|
|
||||||
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir.
|
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir.
|
||||||
# The ACME CA hostname directory can vary, so glob for the domain dir.
|
# The ACME CA hostname directory can vary, so glob for the domain dir.
|
||||||
|
# Caddy issues ACME certs asynchronously, so on a fresh boot the cert may
|
||||||
|
# not exist yet. Retry (bounded) so we never write an empty turn.crt/key;
|
||||||
|
# otherwise embedded TURN silently breaks until the next livekit restart.
|
||||||
|
CRT=""
|
||||||
|
KEY=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
|
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
|
||||||
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
|
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
|
||||||
|
if [ -n "$CRT" ] && [ -n "$KEY" ] \
|
||||||
|
&& [ -s "$CRT" ] && [ -s "$KEY" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
CRT=""
|
||||||
|
KEY=""
|
||||||
|
echo "Waiting for Caddy to issue the $MATRIX ACME certificate..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ -z "$CRT" ] || [ -z "$KEY" ]; then
|
||||||
|
echo "ERROR: Caddy ACME certificate for $MATRIX not available after retries; TURN will not be enabled for LiveKit." >&2
|
||||||
|
else
|
||||||
cp "$CRT" /var/lib/livekit/turn.crt
|
cp "$CRT" /var/lib/livekit/turn.crt
|
||||||
cp "$KEY" /var/lib/livekit/turn.key
|
cp "$KEY" /var/lib/livekit/turn.key
|
||||||
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
|
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
|
||||||
|
fi
|
||||||
|
|
||||||
# Detect the primary network interface from the IPv4 default route.
|
# Detect the primary network interface from the IPv4 default route.
|
||||||
# Restricting LiveKit to this single interface prevents it from
|
# Restricting LiveKit to this single interface prevents it from
|
||||||
@@ -158,6 +189,56 @@ EOF
|
|||||||
# rtc.interfaces.includes are only known at runtime, so they are
|
# rtc.interfaces.includes are only known at runtime, so they are
|
||||||
# substituted here. The cert/key paths point at the LoadCredential-staged
|
# substituted here. The cert/key paths point at the LoadCredential-staged
|
||||||
# copies under /run/credentials.
|
# copies under /run/credentials.
|
||||||
|
#
|
||||||
|
# Determine the public IPv4 to advertise in LiveKit ICE candidates.
|
||||||
|
# Remote peers must be able to reach this address, so it must be the
|
||||||
|
# server's public IP — or the router's WAN IP when the server is behind
|
||||||
|
# NAT with port-forwarding. It does not need to be assigned to this box,
|
||||||
|
# and it may be dynamic.
|
||||||
|
#
|
||||||
|
# Reuse the shared detector (/var/lib/sovran/public-ip.py — see
|
||||||
|
# modules/core/public-ip.nix) instead of running our own: one script,
|
||||||
|
# one cache, privacy-first (STUN -> DNS -> opt-in HTTPS echo). Priority:
|
||||||
|
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
||||||
|
# 2. /var/lib/secrets/external-ip (the shared cache)
|
||||||
|
# 3. run the detector now (it refreshes the cache)
|
||||||
|
# 4. STUN auto-detection (use_external_ip) as the fallback, with a
|
||||||
|
# warning — this is where broken installs used to silently end up
|
||||||
|
# advertising a private IP, causing "call connects but no video".
|
||||||
|
EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}'
|
||||||
|
|
||||||
|
PUBLIC_IP="$EXTERNAL_IP"
|
||||||
|
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
||||||
|
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
||||||
|
fi
|
||||||
|
if [ -z "$PUBLIC_IP" ] && [ -x /var/lib/sovran/public-ip.py ]; then
|
||||||
|
PUBLIC_IP=$(python3 /var/lib/sovran/public-ip.py check 2>/dev/null | head -n1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
||||||
|
# A detected/pinned address like this must never be advertised.
|
||||||
|
if [ -n "$PUBLIC_IP" ] && printf '%s' "$PUBLIC_IP" | grep -qE \
|
||||||
|
'^(0\.|127\.|10\.|100\.64\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
|
||||||
|
echo "WARNING: external IP '$PUBLIC_IP' is not routable; falling back to STUN auto-detection." >&2
|
||||||
|
PUBLIC_IP=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$PUBLIC_IP" ]; then
|
||||||
|
cat > /run/livekit/livekit.yaml <<EOF
|
||||||
|
port: 7880
|
||||||
|
rtc:
|
||||||
|
use_external_ip: false
|
||||||
|
node_ip: $PUBLIC_IP
|
||||||
|
tcp_port: 7881
|
||||||
|
udp_port: 7882
|
||||||
|
port_range_start: 30000
|
||||||
|
port_range_end: 40000
|
||||||
|
interfaces:
|
||||||
|
includes:
|
||||||
|
- $IFACE
|
||||||
|
EOF
|
||||||
|
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
||||||
|
else
|
||||||
cat > /run/livekit/livekit.yaml <<EOF
|
cat > /run/livekit/livekit.yaml <<EOF
|
||||||
port: 7880
|
port: 7880
|
||||||
rtc:
|
rtc:
|
||||||
@@ -170,6 +251,20 @@ rtc:
|
|||||||
interfaces:
|
interfaces:
|
||||||
includes:
|
includes:
|
||||||
- $IFACE
|
- $IFACE
|
||||||
|
EOF
|
||||||
|
echo "WARNING: could not determine a public IP for LiveKit; using STUN auto-detection. If calls connect without media, check STUN egress or set sovran_systemsOS.elementCalling.externalIP." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
||||||
|
# signature against the same key file it issues tokens with, and uses
|
||||||
|
# the events (participant_left / room_finished) to detect abruptly
|
||||||
|
# disconnected participants instead of waiting for the delayed-event
|
||||||
|
# timeout. The URL hits local Caddy via the /etc/hosts loopback
|
||||||
|
# override and is routed to the JWT service by the element-calling
|
||||||
|
# vhost (/livekit/jwt/sfu_webhook → 8073).
|
||||||
|
LK_KEY=$(cut -d: -f1 < ${livekitKeyFile} | tr -d '[:space:]')
|
||||||
|
|
||||||
|
cat >> /run/livekit/livekit.yaml <<EOF
|
||||||
room:
|
room:
|
||||||
auto_create: false
|
auto_create: false
|
||||||
turn:
|
turn:
|
||||||
@@ -179,6 +274,10 @@ turn:
|
|||||||
udp_port: 3478
|
udp_port: 3478
|
||||||
cert_file: /run/credentials/livekit.service/turn-cert
|
cert_file: /run/credentials/livekit.service/turn-cert
|
||||||
key_file: /run/credentials/livekit.service/turn-key
|
key_file: /run/credentials/livekit.service/turn-key
|
||||||
|
webhook:
|
||||||
|
api_key: $LK_KEY
|
||||||
|
urls:
|
||||||
|
- https://$ELEMENT_CALLING/livekit/jwt/sfu_webhook
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 644 /run/livekit/livekit.yaml
|
chmod 644 /run/livekit/livekit.yaml
|
||||||
@@ -186,24 +285,17 @@ EOF
|
|||||||
};
|
};
|
||||||
|
|
||||||
####### LIVEKIT SERVICE #######
|
####### LIVEKIT SERVICE #######
|
||||||
|
# NOTE: the runtime config (rtc ports, TURN, webhook, node_ip) is generated
|
||||||
|
# by livekit-turn-setup and delivered via LoadCredential; the upstream
|
||||||
|
# module's `settings` block is therefore intentionally NOT used (it would
|
||||||
|
# be dead config that silently diverges from what LiveKit actually loads).
|
||||||
|
# The firewall ports are opened explicitly below; openFirewall is left off
|
||||||
|
# so the upstream module does not also open 7880/tcp publicly (Caddy fronts
|
||||||
|
# the SFU on this host).
|
||||||
services.livekit = {
|
services.livekit = {
|
||||||
enable = true;
|
enable = true;
|
||||||
openFirewall = true;
|
openFirewall = false;
|
||||||
keyFile = livekitKeyFile;
|
keyFile = livekitKeyFile;
|
||||||
settings = {
|
|
||||||
rtc.use_external_ip = true;
|
|
||||||
rtc.skip_external_ip_validation = true;
|
|
||||||
rtc.tcp_port = 7881;
|
|
||||||
rtc.udp_port = 7882;
|
|
||||||
rtc.port_range_start = 30000;
|
|
||||||
rtc.port_range_end = 40000;
|
|
||||||
room.auto_create = false;
|
|
||||||
turn = {
|
|
||||||
enabled = true;
|
|
||||||
tls_port = 5349;
|
|
||||||
udp_port = 3478;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
# Override ExecStart to load the runtime-generated config (which carries the
|
# Override ExecStart to load the runtime-generated config (which carries the
|
||||||
@@ -247,12 +339,26 @@ EOF
|
|||||||
script = ''
|
script = ''
|
||||||
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
MATRIX=$(cat /var/lib/domains/matrix)
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
FULL_ACCESS_HOMESERVERS="$MATRIX"
|
||||||
|
|
||||||
|
# Federated peers may also be granted LiveKit room-creation (full access)
|
||||||
|
# on this SFU via sovran_systemsOS.elementCalling.fullAccessHomeservers.
|
||||||
|
# Without this, remote users can join existing calls but cannot be the
|
||||||
|
# first to start one on your SFU.
|
||||||
|
EXTRA_HS='${lib.concatStringsSep "," config.sovran_systemsOS.elementCalling.fullAccessHomeservers}'
|
||||||
|
if [ -n "$EXTRA_HS" ]; then
|
||||||
|
FULL_ACCESS_HOMESERVERS="$FULL_ACCESS_HOMESERVERS,$EXTRA_HS"
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p /run/lk-jwt-service
|
mkdir -p /run/lk-jwt-service
|
||||||
|
|
||||||
cat > /run/lk-jwt-service/env <<EOF
|
cat > /run/lk-jwt-service/env <<EOF
|
||||||
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
||||||
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX
|
LIVEKIT_FULL_ACCESS_HOMESERVERS=$FULL_ACCESS_HOMESERVERS
|
||||||
|
# Re-check, every 60s, that connected participants are still on the SFU;
|
||||||
|
# guards against missed SFU webhooks (e.g. an SFU restart) leaving stale
|
||||||
|
# call members in Matrix rooms.
|
||||||
|
LIVEKIT_SANITY_CHECK_INTERVAL_SECONDS=60
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 640 /run/lk-jwt-service/env
|
chmod 640 /run/lk-jwt-service/env
|
||||||
@@ -264,6 +370,9 @@ EOF
|
|||||||
enable = true;
|
enable = true;
|
||||||
port = 8073;
|
port = 8073;
|
||||||
keyFile = livekitKeyFile;
|
keyFile = livekitKeyFile;
|
||||||
|
# Required by the upstream module's option type, but overridden at runtime
|
||||||
|
# by EnvironmentFile (/run/lk-jwt-service/env, generated above from the
|
||||||
|
# element-calling domain). Kept as a harmless placeholder.
|
||||||
livekitUrl = "wss://placeholder.local";
|
livekitUrl = "wss://placeholder.local";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,6 +380,126 @@ EOF
|
|||||||
"/run/lk-jwt-service/env"
|
"/run/lk-jwt-service/env"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
|
||||||
|
# configs (new domains, externalIP, full-access list), mirroring the domain
|
||||||
|
# change flow.
|
||||||
|
# Re-run the config generator and restart LiveKit when a rebuild regenerates
|
||||||
|
# the runtime config, or when the Hub persists a new external IP (dynamic
|
||||||
|
# WAN IPs), so the advertised ICE candidate stays current without a manual
|
||||||
|
# restart. The trigger chain: external-ip change → livekit-turn-setup
|
||||||
|
# re-runs → rewrites livekit.yaml → livekit restarts with the new config.
|
||||||
|
systemd.services.livekit-turn-setup.restartTriggers = [ "/var/lib/secrets/external-ip" ];
|
||||||
|
systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
|
||||||
|
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
|
||||||
|
|
||||||
|
####### PUBLIC REACHABILITY SELF-CHECK #######
|
||||||
|
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
||||||
|
# classic "call connects but no media" setup errors at boot instead of at
|
||||||
|
# call time:
|
||||||
|
# * the element-calling domain having no public records (or resolving to
|
||||||
|
# loopback/link-local/CGNAT for remote peers),
|
||||||
|
# * the lk-jwt-service being unreachable through Caddy,
|
||||||
|
# * the MatrixRTC transports endpoint being absent (Element X cannot
|
||||||
|
# discover calling and shows MISSING_MATRIX_RTC_TRANSPORT).
|
||||||
|
# The check queries only the operator's own DNS provider (the domain's
|
||||||
|
# authoritative nameservers, resolved via the local resolver) plus the
|
||||||
|
# server's own Caddy and public IP — no third-party resolver or service is
|
||||||
|
# contacted. dig queries resolvers directly, so the /etc/hosts loopback
|
||||||
|
# overrides (modules/core/local-domain-loopback.nix) do not influence the
|
||||||
|
# result.
|
||||||
|
systemd.services.element-calling-public-check = {
|
||||||
|
description = "Verify Element Calling domain, JWT service and MatrixRTC transports endpoint are publicly reachable";
|
||||||
|
after = [ "network-online.target" "caddy.service" "livekit.service" "lk-jwt-service.service" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
};
|
||||||
|
unitConfig = {
|
||||||
|
ConditionPathExists = "/var/lib/domains/element-calling";
|
||||||
|
};
|
||||||
|
path = [ pkgs.coreutils pkgs.gawk pkgs.dnsutils pkgs.curl ];
|
||||||
|
script = ''
|
||||||
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
echo "── Element Calling public reachability self-check ──"
|
||||||
|
|
||||||
|
# 1) Authoritative DNS view (bypassing the /etc/hosts loopback
|
||||||
|
# overrides). Resolve the domain's own nameservers via the local
|
||||||
|
# resolver, then query those nameservers directly — the only party
|
||||||
|
# that sees the query is the DNS provider the operator already uses
|
||||||
|
# for the domain.
|
||||||
|
NS_LIST=$(dig +short NS "$ELEMENT_CALLING" 2>/dev/null | tr '\n' ' ')
|
||||||
|
if [ -n "$NS_LIST" ]; then
|
||||||
|
IPS=""
|
||||||
|
for NSRV in $NS_LIST; do
|
||||||
|
IPS=$( { dig +short A "$ELEMENT_CALLING" "@$NSRV" 2>/dev/null; dig +short AAAA "$ELEMENT_CALLING" "@$NSRV" 2>/dev/null; } | tr '\n' ' ' )
|
||||||
|
[ -n "$IPS" ] && break
|
||||||
|
done
|
||||||
|
echo "Authoritative nameservers for $ELEMENT_CALLING: $NS_LIST"
|
||||||
|
else
|
||||||
|
echo "WARNING: could not resolve nameservers for $ELEMENT_CALLING via the local resolver; using the local resolver's answer instead." >&2
|
||||||
|
IPS=$( { dig +short A "$ELEMENT_CALLING" 2>/dev/null; dig +short AAAA "$ELEMENT_CALLING" 2>/dev/null; } | tr '\n' ' ' )
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$IPS" ]; then
|
||||||
|
echo "ERROR: no A/AAAA records for $ELEMENT_CALLING at its authoritative nameservers. Remote peers cannot reach this LiveKit; calls will connect without media." >&2
|
||||||
|
FAIL=1
|
||||||
|
else
|
||||||
|
echo "Public DNS for $ELEMENT_CALLING: $IPS"
|
||||||
|
for IP in $IPS; do
|
||||||
|
case "$IP" in
|
||||||
|
0.*|127.*|169.254.*|100.64.*|::1|fe80:*|fc*:*|fd*:*)
|
||||||
|
echo "ERROR: $ELEMENT_CALLING resolves to $IP (loopback/link-local/CGNAT). Remote peers cannot reach it." >&2
|
||||||
|
FAIL=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) lk-jwt-service healthz through Caddy (validates the proxy chain).
|
||||||
|
if curl -fsS --max-time 10 "https://$ELEMENT_CALLING/livekit/jwt/healthz" >/dev/null 2>&1; then
|
||||||
|
echo "OK: https://$ELEMENT_CALLING/livekit/jwt/healthz responds"
|
||||||
|
else
|
||||||
|
echo "ERROR: https://$ELEMENT_CALLING/livekit/jwt/healthz not reachable through Caddy." >&2
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3) Same healthz via the first public IP (tests the full NAT path).
|
||||||
|
# NOTE: if this box is behind the same NAT you are testing through,
|
||||||
|
# routers without hairpin NAT will fail this step — the warning is
|
||||||
|
# then expected and harmless; verify from an external device instead.
|
||||||
|
if [ -n "$IPS" ]; then
|
||||||
|
PUBIP=$(echo "$IPS" | awk '{print $1}')
|
||||||
|
if curl -fsS --max-time 15 --resolve "$ELEMENT_CALLING:443:$PUBIP" "https://$ELEMENT_CALLING/livekit/jwt/healthz" >/dev/null 2>&1; then
|
||||||
|
echo "OK: healthz reachable via public IP $PUBIP (NAT path works)"
|
||||||
|
else
|
||||||
|
echo "WARNING: healthz NOT reachable via public IP $PUBIP — check router port-forwarding (443/TCP) and NAT hairpin. Expected if the router lacks hairpin NAT; verify from an external device." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4) MatrixRTC transports registry (MSC4519) — required by Element X.
|
||||||
|
CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "https://$MATRIX/_matrix/client/unstable/org.matrix.msc4143/rtc/transports")
|
||||||
|
case "$CODE" in
|
||||||
|
401|200)
|
||||||
|
echo "OK: MatrixRTC transports endpoint present (HTTP $CODE; auth required is expected)" ;;
|
||||||
|
404)
|
||||||
|
echo "ERROR: /_matrix/client/unstable/org.matrix.msc4143/rtc/transports missing (HTTP 404) — Element X cannot discover calling. Enable msc4143_enabled and matrix_rtc.transports in Synapse." >&2
|
||||||
|
FAIL=1 ;;
|
||||||
|
*)
|
||||||
|
echo "WARNING: transports endpoint returned HTTP $CODE" >&2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$FAIL" -eq 1 ]; then
|
||||||
|
echo "── Element Calling self-check FAILED — see errors above ──" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "── Element Calling self-check passed ──"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
####### SYNAPSE RUNTIME CONFIG (element-calling additions) #######
|
####### SYNAPSE RUNTIME CONFIG (element-calling additions) #######
|
||||||
systemd.services.element-calling-synapse-config = {
|
systemd.services.element-calling-synapse-config = {
|
||||||
description = "Generate Synapse runtime config for Element Calling";
|
description = "Generate Synapse runtime config for Element Calling";
|
||||||
@@ -287,6 +516,7 @@ EOF
|
|||||||
path = [ pkgs.coreutils ];
|
path = [ pkgs.coreutils ];
|
||||||
script = ''
|
script = ''
|
||||||
MATRIX=$(cat /var/lib/domains/matrix)
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
|
|
||||||
mkdir -p /run/matrix-synapse
|
mkdir -p /run/matrix-synapse
|
||||||
|
|
||||||
@@ -296,7 +526,17 @@ public_baseurl: "https://$MATRIX"
|
|||||||
serve_server_wellknown: true
|
serve_server_wellknown: true
|
||||||
experimental_features:
|
experimental_features:
|
||||||
msc3266_enabled: true
|
msc3266_enabled: true
|
||||||
|
# MSC4143: enables the MatrixRTC transports registry endpoint
|
||||||
|
# (/_matrix/client/unstable/org.matrix.msc4143/rtc/transports, MSC4519).
|
||||||
|
# Element X requires this endpoint to discover the LiveKit focus; without it
|
||||||
|
# mobile clients fail with MISSING_MATRIX_RTC_TRANSPORT / cannot start calls.
|
||||||
|
msc4143_enabled: true
|
||||||
msc4222_enabled: true
|
msc4222_enabled: true
|
||||||
|
# MSC4519: advertise this site's LiveKit focus via the transports registry.
|
||||||
|
matrix_rtc:
|
||||||
|
transports:
|
||||||
|
- type: livekit
|
||||||
|
livekit_service_url: "https://$ELEMENT_CALLING/livekit/jwt"
|
||||||
max_event_delay_duration: "24h"
|
max_event_delay_duration: "24h"
|
||||||
rc_message:
|
rc_message:
|
||||||
per_second: 0.5
|
per_second: 0.5
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
lib.mkIf config.sovran_systemsOS.features.mempool {
|
|
||||||
|
|
||||||
services.mempool = {
|
|
||||||
enable = true;
|
|
||||||
frontend.enable = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.mysql.package = lib.mkForce pkgs.mariadb;
|
|
||||||
|
|
||||||
nix-bitcoin.onionServices.mempool-frontend.enable = true;
|
|
||||||
|
|
||||||
}
|
|
||||||
+1
-4
@@ -17,6 +17,7 @@
|
|||||||
./core/no-sleep.nix
|
./core/no-sleep.nix
|
||||||
./core/cpu-performance.nix
|
./core/cpu-performance.nix
|
||||||
./core/local-domain-loopback.nix
|
./core/local-domain-loopback.nix
|
||||||
|
./core/public-ip.nix
|
||||||
|
|
||||||
# ── Always on (no flag) ───────────────────────────────────
|
# ── Always on (no flag) ───────────────────────────────────
|
||||||
./php.nix
|
./php.nix
|
||||||
@@ -27,15 +28,11 @@
|
|||||||
./wordpress.nix
|
./wordpress.nix
|
||||||
./nextcloud.nix
|
./nextcloud.nix
|
||||||
./vaultwarden.nix
|
./vaultwarden.nix
|
||||||
./bitcoinecosystem.nix
|
|
||||||
./wallet-autoconnect.nix
|
./wallet-autoconnect.nix
|
||||||
|
|
||||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||||
./haven.nix
|
./haven.nix
|
||||||
./nwc-wallets.nix
|
|
||||||
./element-calling.nix
|
./element-calling.nix
|
||||||
./mempool.nix
|
|
||||||
./bitcoin-core.nix
|
|
||||||
./rdp.nix
|
./rdp.nix
|
||||||
./sshd.nix
|
./sshd.nix
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
let
|
|
||||||
albyHubPort = 18080;
|
|
||||||
albyHubApiBase = "http://127.0.0.1:${toString albyHubPort}";
|
|
||||||
patchedAlbyHub = pkgs.albyhub.overrideAttrs (old: {
|
|
||||||
patches = (old.patches or []) ++ [
|
|
||||||
../packages/albyhub/0001-private-route-hints.patch
|
|
||||||
../packages/albyhub/0002-isolated-invoice-app-id.patch
|
|
||||||
../packages/albyhub/0003-loopback-bind-host.patch
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
lndRpcAddress = lib.attrByPath [ "services" "lnd" "rpcAddress" ] "127.0.0.1" config;
|
|
||||||
lndRpcPort = toString (lib.attrByPath [ "services" "lnd" "rpcPort" ] 10009 config);
|
|
||||||
lndCertPath = config.services.lnd.certPath;
|
|
||||||
pythonManagerEnvironment = {
|
|
||||||
NWC_ALBY_HUB_API_BASE = albyHubApiBase;
|
|
||||||
NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
|
|
||||||
NWC_LND_CERT_FILE = lndCertPath;
|
|
||||||
NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
|
||||||
NWC_RELAY =
|
|
||||||
if config.sovran_systemsOS.features.haven
|
|
||||||
then "wss://haven.${config.networking.domain}/nostr"
|
|
||||||
else "wss://relay.getalby.com,wss://relay2.getalby.com";
|
|
||||||
};
|
|
||||||
wrappedNwcWallet = lib.hiPrio (pkgs.writeShellScriptBin "nwc-wallet" ''
|
|
||||||
export NWC_ALBY_HUB_API_BASE='${pythonManagerEnvironment.NWC_ALBY_HUB_API_BASE}'
|
|
||||||
export NWC_LND_ADDRESS='${pythonManagerEnvironment.NWC_LND_ADDRESS}'
|
|
||||||
export NWC_LND_CERT_FILE='${pythonManagerEnvironment.NWC_LND_CERT_FILE}'
|
|
||||||
export NWC_LND_MACAROON_FILE='${pythonManagerEnvironment.NWC_LND_MACAROON_FILE}'
|
|
||||||
export NWC_RELAY='${pythonManagerEnvironment.NWC_RELAY}'
|
|
||||||
exec ${config.services.sovranHub.webPackage}/bin/nwc-wallet "$@"
|
|
||||||
'');
|
|
||||||
|
|
||||||
albyhubWrapper = pkgs.writeShellScript "albyhub-wrapper" ''
|
|
||||||
set -euo pipefail
|
|
||||||
password_file="/var/lib/albyhub/unlock-password"
|
|
||||||
if [ ! -s "$password_file" ]; then
|
|
||||||
umask 077
|
|
||||||
${pkgs.openssl}/bin/openssl rand -hex 32 > "$password_file"
|
|
||||||
fi
|
|
||||||
export AUTO_UNLOCK_PASSWORD="$(cat "$password_file")"
|
|
||||||
exec ${lib.getExe patchedAlbyHub}
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
|
||||||
assertions = [
|
|
||||||
{
|
|
||||||
assertion = config.services.lnd.enable;
|
|
||||||
message = "Lightning Wallet Connections requires services.lnd.enable = true.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = !(lib.attrByPath [ "nix-bitcoin" "netns-isolation" "enable" ] false config);
|
|
||||||
message = "Lightning Wallet Connections requires nix-bitcoin.netns-isolation.enable = false.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = albyHubPort != config.services.lnd.restPort;
|
|
||||||
message = "Alby Hub and LND REST must use different ports.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = albyHubPort != 8181;
|
|
||||||
message = "Alby Hub and the public LNURL service must use different ports.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = !(lib.elem albyHubPort config.networking.firewall.allowedTCPPorts);
|
|
||||||
message = "Alby Hub management port must not be opened on the public TCP firewall.";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
users.groups.albyhub = { };
|
|
||||||
users.users.albyhub = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = "albyhub";
|
|
||||||
home = "/var/lib/albyhub";
|
|
||||||
createHome = false;
|
|
||||||
extraGroups = [ ];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d /var/lib/albyhub 0700 albyhub albyhub -"
|
|
||||||
];
|
|
||||||
|
|
||||||
services.lnd.macaroons.albyhub = {
|
|
||||||
user = "albyhub";
|
|
||||||
permissions = lib.concatStringsSep "," [
|
|
||||||
''{"entity":"info","action":"read"}''
|
|
||||||
''{"entity":"offchain","action":"read"}''
|
|
||||||
''{"entity":"offchain","action":"write"}''
|
|
||||||
''{"entity":"invoices","action":"read"}''
|
|
||||||
''{"entity":"invoices","action":"write"}''
|
|
||||||
''{"entity":"onchain","action":"read"}''
|
|
||||||
''{"entity":"address","action":"read"}''
|
|
||||||
''{"entity":"message","action":"read"}''
|
|
||||||
''{"entity":"message","action":"write"}''
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.albyhub = {
|
|
||||||
description = "Alby Hub — NWC wallet server";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
after = [ "network.target" "lnd.service" ];
|
|
||||||
requires = [ "lnd.service" ];
|
|
||||||
|
|
||||||
environment = {
|
|
||||||
HOME = "/var/lib/albyhub";
|
|
||||||
HOST = "127.0.0.1";
|
|
||||||
LN_BACKEND_TYPE = "LND";
|
|
||||||
ENABLE_ADVANCED_SETUP = "false";
|
|
||||||
LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
|
|
||||||
LND_CERT_FILE = lndCertPath;
|
|
||||||
LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
|
||||||
WORK_DIR = "/var/lib/albyhub";
|
|
||||||
DATABASE_URI = "/var/lib/albyhub/nwc.db";
|
|
||||||
PORT = toString albyHubPort;
|
|
||||||
# Use private Nostr relay if Haven is enabled, otherwise default to Alby's public relays
|
|
||||||
RELAY =
|
|
||||||
if config.sovran_systemsOS.features.haven
|
|
||||||
then "wss://haven.${config.networking.domain}/nostr"
|
|
||||||
else "wss://relay.getalby.com,wss://relay2.getalby.com";
|
|
||||||
AUTO_LINK_ALBY_ACCOUNT = "false";
|
|
||||||
SEND_EVENTS_TO_ALBY = "false";
|
|
||||||
LOG_TO_FILE = "false";
|
|
||||||
HIDE_UPDATE_BANNER = "true";
|
|
||||||
};
|
|
||||||
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "simple";
|
|
||||||
User = "albyhub";
|
|
||||||
Group = "albyhub";
|
|
||||||
WorkingDirectory = "/var/lib/albyhub";
|
|
||||||
ExecStart = albyhubWrapper;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
UMask = "0077";
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
PrivateTmp = true;
|
|
||||||
ProtectHome = true;
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
ReadWritePaths = [ "/var/lib/albyhub" ];
|
|
||||||
ReadOnlyPaths = [ lndCertPath "/run/lnd" ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.nwc-lnurl = {
|
|
||||||
description = "Lightning Wallet Connections public LNURL service";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
after = [ "albyhub.service" "sovran-hub-web.service" ];
|
|
||||||
wants = [ "albyhub.service" ];
|
|
||||||
environment = pythonManagerEnvironment;
|
|
||||||
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "simple";
|
|
||||||
User = "albyhub";
|
|
||||||
Group = "albyhub";
|
|
||||||
ExecStart = "${config.services.sovranHub.webPackage}/bin/nwc-lnurl";
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
UMask = "0027";
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
PrivateTmp = true;
|
|
||||||
ProtectHome = true;
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
ReadOnlyPaths = [
|
|
||||||
"/var/lib/domains/lightning"
|
|
||||||
"/var/lib/albyhub/unlock-password"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.sovran-hub-web.environment = pythonManagerEnvironment;
|
|
||||||
environment.systemPackages = lib.mkBefore [ wrappedNwcWallet ];
|
|
||||||
|
|
||||||
sovran_systemsOS.domainRequirements = [
|
|
||||||
{
|
|
||||||
name = "lightning";
|
|
||||||
label = "Lightning Address Domain";
|
|
||||||
example = "pay.yourdomain.com";
|
|
||||||
needsDDNS = true;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Sovran Bitcoin integration layer — bridges Sovran_SystemsOS options to the
|
||||||
|
# Sovran_Bitcoin flake module.
|
||||||
|
#
|
||||||
|
# This is the ONLY place where OS-specific Bitcoin customizations live.
|
||||||
|
# All bitcoin service modules (bitcoind, electrs, lnd, rtl, btcpayserver,
|
||||||
|
# mempool, albyhub, lnurl) and vendored packages come from the Sovran_Bitcoin
|
||||||
|
# flake input.
|
||||||
|
#
|
||||||
|
# What this file does:
|
||||||
|
# 1. Maps sovran_systemsOS.services.bitcoin → sovran-bitcoin.enable
|
||||||
|
# 2. Maps sovran_systemsOS.features.* → sovran-bitcoin.features.*
|
||||||
|
# 3. Applies OS-specific overrides (Second_Drive paths, operator "free",
|
||||||
|
# forced wallet enable, Hub firewall port, domain requirements)
|
||||||
|
# 4. Wires the Sovran Hub's NWC environment to the Alby Hub service
|
||||||
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.sovran_systemsOS;
|
||||||
|
|
||||||
|
# ── NWC environment for the Sovran Hub web app ─────────────────
|
||||||
|
# The Hub's web package provides nwc-wallet and nwc-lnurl binaries that
|
||||||
|
# need to know where Alby Hub and LND are. Sovran_Bitcoin's modules
|
||||||
|
# handle the base Alby Hub service; this layers on the Hub-specific
|
||||||
|
# tooling environment.
|
||||||
|
lndRpcAddress = config.services.lnd.rpcAddress or "127.0.0.1";
|
||||||
|
lndRpcPort = toString (config.services.lnd.rpcPort or 10009);
|
||||||
|
lndCertPath = config.services.lnd.certPath or "/var/lib/lnd/tls.cert";
|
||||||
|
|
||||||
|
hubNwcEnvironment = {
|
||||||
|
NWC_ALBY_HUB_API_BASE = "http://127.0.0.1:18080";
|
||||||
|
NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
|
||||||
|
NWC_LND_CERT_FILE = lndCertPath;
|
||||||
|
NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
||||||
|
NWC_RELAY =
|
||||||
|
if cfg.features.haven
|
||||||
|
then "wss://haven.${config.networking.domain}/nostr"
|
||||||
|
else "wss://relay.getalby.com,wss://relay2.getalby.com";
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
# ── 1. Map OS options → Sovran_Bitcoin options ─────────────────
|
||||||
|
sovran-bitcoin = lib.mkIf cfg.services.bitcoin {
|
||||||
|
enable = true;
|
||||||
|
operatorName = "free";
|
||||||
|
bitcoindTorGossip = cfg.features.bitcoin-tor-gossip;
|
||||||
|
|
||||||
|
features = {
|
||||||
|
electrs = true;
|
||||||
|
lnd = true;
|
||||||
|
rtl = true;
|
||||||
|
btcpayserver = cfg.web.btcpayserver;
|
||||||
|
mempool = cfg.features.mempool;
|
||||||
|
nwc = cfg.features."nwc-wallets";
|
||||||
|
lnurl = cfg.features."nwc-wallets";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 2. Second_Drive data paths (OS-specific) ──────────────────
|
||||||
|
services.bitcoind = lib.mkIf cfg.services.bitcoin {
|
||||||
|
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||||
|
# The OS always enables the bitcoind wallet — the Hub and BTCPay need it.
|
||||||
|
disablewallet = lib.mkForce false;
|
||||||
|
};
|
||||||
|
|
||||||
|
services.electrs = lib.mkIf cfg.services.bitcoin {
|
||||||
|
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data";
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 3. Second_Drive mount dependencies ─────────────────────────
|
||||||
|
systemd.services.bitcoind = lib.mkIf cfg.services.bitcoin {
|
||||||
|
requires = [ "run-media-Second_Drive.mount" ];
|
||||||
|
after = [ "run-media-Second_Drive.mount" ];
|
||||||
|
serviceConfig.PrivateUsers = lib.mkForce false;
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.electrs = lib.mkIf cfg.services.bitcoin {
|
||||||
|
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
|
||||||
|
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
|
||||||
|
wants = [ "bitcoind.service" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.lnd = lib.mkIf cfg.services.bitcoin {
|
||||||
|
wants = [ "bitcoind.service" ];
|
||||||
|
# nix-bitcoin sets `requires = [ "bitcoind.service" ]`; the OS removes it
|
||||||
|
# so LND can start even if bitcoind is temporarily down.
|
||||||
|
requires = lib.mkForce [ ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 4. Permission fixup for Second_Drive ───────────────────────
|
||||||
|
systemd.services.sovran-btc-permissions = lib.mkIf cfg.services.bitcoin {
|
||||||
|
description = "Fix Bitcoin/Electrs data directory ownership on second drive";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "run-media-Second_Drive.mount" ];
|
||||||
|
before = [ "bitcoind.service" "electrs.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node ]; then
|
||||||
|
chown -R bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
|
||||||
|
fi
|
||||||
|
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data ]; then
|
||||||
|
chown -R electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 5. Firewall — Hub management port ──────────────────────────
|
||||||
|
networking.firewall.allowedTCPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
||||||
|
networking.firewall.allowedUDPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
||||||
|
|
||||||
|
# ── 6. NWC / LNURL — Sovran Hub integration ───────────────────
|
||||||
|
# Sovran_Bitcoin's albyhub.nix and lnurl.nix handle the base services.
|
||||||
|
# This section wires the Hub's web app environment so the Hub can
|
||||||
|
# display NWC status and the nwc-wallet CLI works from the Hub shell.
|
||||||
|
|
||||||
|
services.sovran-lnurl = lib.mkIf cfg.features."nwc-wallets" {
|
||||||
|
# The Hub writes the Lightning Address domain to this file.
|
||||||
|
domainFile = "/var/lib/domains/lightning";
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.sovran-hub-web.environment = lib.mkIf cfg.features."nwc-wallets"
|
||||||
|
hubNwcEnvironment;
|
||||||
|
|
||||||
|
# ── 7. Domain requirements ─────────────────────────────────────
|
||||||
|
sovran_systemsOS.domainRequirements = lib.mkIf cfg.services.bitcoin (
|
||||||
|
[
|
||||||
|
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
|
||||||
|
]
|
||||||
|
++ lib.optionals cfg.features."nwc-wallets" [
|
||||||
|
{ name = "lightning"; label = "Lightning Address Domain"; example = "pay.yourdomain.com"; needsDDNS = true; }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,29 +47,44 @@ EOF
|
|||||||
systemd.services.zeus-connect-setup = {
|
systemd.services.zeus-connect-setup = {
|
||||||
description = "Save Zeus lndconnect URL";
|
description = "Save Zeus lndconnect URL";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
after = [ "lnd.service" ];
|
after = [ "lnd.service" "onion-addresses.service" ];
|
||||||
|
wants = [ "lnd.service" "onion-addresses.service" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
};
|
};
|
||||||
path = [ pkgs.coreutils "/run/current-system/sw" ];
|
# sudo is required: the lndconnect wrapper re-execs as the lnd user so it
|
||||||
|
# can read admin.macaroon (not group-readable).
|
||||||
|
path = [ pkgs.coreutils pkgs.gnugrep pkgs.sudo "/run/current-system/sw" ];
|
||||||
script = ''
|
script = ''
|
||||||
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
|
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
|
||||||
mkdir -p /var/lib/secrets
|
mkdir -p /var/lib/secrets
|
||||||
|
|
||||||
|
# LND may still be creating the wallet / macaroon, and the dedicated
|
||||||
|
# lnd-rest onion hostname is published by onion-addresses.service.
|
||||||
URL=""
|
URL=""
|
||||||
|
ATTEMPTS=0
|
||||||
|
while [ "$ATTEMPTS" -lt 60 ]; do
|
||||||
if command -v lndconnect >/dev/null 2>&1; then
|
if command -v lndconnect >/dev/null 2>&1; then
|
||||||
URL=$(lndconnect --url 2>/dev/null || true)
|
URL=$(lndconnect --url 2>/dev/null | tr -d '\r' | tail -n 1 || true)
|
||||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
|
||||||
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
|
|
||||||
fi
|
fi
|
||||||
|
# Zeus LND REST over Tor: lndconnect://<v3-onion>:8080?macaroon=...
|
||||||
|
if echo "$URL" | grep -q '^lndconnect://' \
|
||||||
|
&& echo "$URL" | grep -q '\.onion' \
|
||||||
|
&& echo "$URL" | grep -q 'macaroon='; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
URL=""
|
||||||
|
ATTEMPTS=$((ATTEMPTS + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
if [ -n "$URL" ]; then
|
if [ -n "$URL" ]; then
|
||||||
echo "$URL" > "$SECRET_FILE"
|
printf '%s\n' "$URL" > "$SECRET_FILE"
|
||||||
chmod 600 "$SECRET_FILE"
|
chmod 600 "$SECRET_FILE"
|
||||||
echo "Zeus connect URL saved."
|
echo "Zeus connect URL saved."
|
||||||
else
|
else
|
||||||
echo "No lndconnect URL available yet."
|
echo "No valid lndconnect URL available yet."
|
||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go
|
|
||||||
index bad532f..ba35b7b 100644
|
|
||||||
--- a/lnclient/lnd/lnd.go
|
|
||||||
+++ b/lnclient/lnd/lnd.go
|
|
||||||
@@ -708,7 +708,7 @@ func (svc *LNDService) MakeInvoice(ctx context.Context, amountMsat int64, descri
|
|
||||||
DescriptionHash: descriptionHashBytes,
|
|
||||||
Expiry: expiry,
|
|
||||||
RouteHints: hints,
|
|
||||||
- Private: !hasPublicChannels, // use private channel hints in the invoice
|
|
||||||
+ Private: true, // always include private channel hints in the invoice
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := svc.client.AddInvoice(ctx, addInvoiceRequest)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
diff --git a/api/models.go b/api/models.go
|
|
||||||
index 28bd117..dceb79a 100644
|
|
||||||
--- a/api/models.go
|
|
||||||
+++ b/api/models.go
|
|
||||||
@@ -46,7 +46,7 @@ type API interface {
|
|
||||||
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error)
|
|
||||||
ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error)
|
|
||||||
SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, fromAppId *uint) (*SendPaymentResponse, error)
|
|
||||||
- CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error)
|
|
||||||
+ CreateInvoice(ctx context.Context, amountMsat uint64, description string, appId *uint) (*MakeInvoiceResponse, error)
|
|
||||||
LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error)
|
|
||||||
SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error
|
|
||||||
RequestMempoolApi(ctx context.Context, endpoint string) (interface{}, error)
|
|
||||||
@@ -586,6 +586,7 @@ type MakeInvoiceRequest struct {
|
|
||||||
AmountSat *uint64 `json:"amountSat"`
|
|
||||||
AmountMsat *uint64 `json:"amountMsat"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
+ AppId *uint `json:"appId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResetRouterRequest struct {
|
|
||||||
diff --git a/api/transactions.go b/api/transactions.go
|
|
||||||
index aaf9380..3f9850a 100644
|
|
||||||
--- a/api/transactions.go
|
|
||||||
+++ b/api/transactions.go
|
|
||||||
@@ -12,12 +12,12 @@ import (
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
-func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error) {
|
|
||||||
+func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string, appId *uint) (*MakeInvoiceResponse, error) {
|
|
||||||
lnClient := api.svc.GetLNClient()
|
|
||||||
if lnClient == nil {
|
|
||||||
return nil, ErrLNClientNotStarted
|
|
||||||
}
|
|
||||||
- transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, nil, nil, nil)
|
|
||||||
+ transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, appId, nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
diff --git a/http/http_service.go b/http/http_service.go
|
|
||||||
index bf650b9..cf61250 100644
|
|
||||||
--- a/http/http_service.go
|
|
||||||
+++ b/http/http_service.go
|
|
||||||
@@ -687,7 +687,12 @@ func (httpSvc *HttpService) makeInvoiceHandler(c echo.Context) error {
|
|
||||||
amountMsat = *resolvedAmountMsat
|
|
||||||
}
|
|
||||||
|
|
||||||
- invoice, err := httpSvc.api.CreateInvoice(c.Request().Context(), amountMsat, makeInvoiceRequest.Description)
|
|
||||||
+ invoice, err := httpSvc.api.CreateInvoice(
|
|
||||||
+ c.Request().Context(),
|
|
||||||
+ amountMsat,
|
|
||||||
+ makeInvoiceRequest.Description,
|
|
||||||
+ makeInvoiceRequest.AppId,
|
|
||||||
+ )
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
|
||||||
diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go
|
|
||||||
index d25259b..c9fe4e8 100644
|
|
||||||
--- a/wails/wails_handlers.go
|
|
||||||
+++ b/wails/wails_handlers.go
|
|
||||||
@@ -600,7 +600,7 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
|
|
||||||
if resolvedAmountMsat != nil {
|
|
||||||
amountMsat = *resolvedAmountMsat
|
|
||||||
}
|
|
||||||
- invoice, err := app.api.CreateInvoice(ctx, amountMsat, makeInvoiceRequest.Description)
|
|
||||||
+ invoice, err := app.api.CreateInvoice(ctx, amountMsat, makeInvoiceRequest.Description, nil)
|
|
||||||
if err != nil {
|
|
||||||
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
diff --git a/cmd/http/main.go b/cmd/http/main.go
|
|
||||||
index ed31f96..4d31212 100644
|
|
||||||
--- a/cmd/http/main.go
|
|
||||||
+++ b/cmd/http/main.go
|
|
||||||
@@ -2,8 +2,8 @@ package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
- "fmt"
|
|
||||||
nethttp "net/http"
|
|
||||||
+ "net"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
@@ -51,8 +51,9 @@ func main() {
|
|
||||||
httpSvc := http.NewHttpService(svc, svc.GetEventPublisher())
|
|
||||||
httpSvc.RegisterSharedRoutes(e)
|
|
||||||
//start Echo server
|
|
||||||
+ bindAddress := net.JoinHostPort(svc.GetConfig().GetEnv().Host, svc.GetConfig().GetEnv().Port)
|
|
||||||
go func() {
|
|
||||||
- if err := e.Start(fmt.Sprintf(":%v", svc.GetConfig().GetEnv().Port)); err != nil && err != nethttp.ErrServerClosed {
|
|
||||||
+ if err := e.Start(bindAddress); err != nil && err != nethttp.ErrServerClosed {
|
|
||||||
logger.Logger.WithError(err).Error("echo server failed to start")
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
diff --git a/config/models.go b/config/models.go
|
|
||||||
index 0fb8870..dc11afd 100644
|
|
||||||
--- a/config/models.go
|
|
||||||
+++ b/config/models.go
|
|
||||||
@@ -24,6 +24,7 @@ type AppConfig struct {
|
|
||||||
LNDAddress string `envconfig:"LND_ADDRESS"`
|
|
||||||
LNDCertFile string `envconfig:"LND_CERT_FILE"`
|
|
||||||
LNDMacaroonFile string `envconfig:"LND_MACAROON_FILE"`
|
|
||||||
+ Host string `envconfig:"HOST" default:"127.0.0.1"`
|
|
||||||
Workdir string `envconfig:"WORK_DIR"`
|
|
||||||
Port string `envconfig:"PORT" default:"8080"`
|
|
||||||
DatabaseUri string `envconfig:"DATABASE_URI" default:"nwc.db"`
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Vendored from fort-nix/nix-bitcoin commit 360e30fee.
|
|
||||||
# This local copy does not fetch or import nix-bitcoin.
|
|
||||||
# This is a modified version of
|
|
||||||
# https://github.com/NixOS/nixpkgs/pull/128749
|
|
||||||
|
|
||||||
{ lib, stdenvNoCC, makeWrapper, nodejs, cacert }:
|
|
||||||
|
|
||||||
{ src
|
|
||||||
, hash ? ""
|
|
||||||
, runScripts ? false
|
|
||||||
, preferLocalBuild ? true
|
|
||||||
, npmFlags ? ""
|
|
||||||
, ...
|
|
||||||
} @ args:
|
|
||||||
stdenvNoCC.mkDerivation ({
|
|
||||||
inherit src preferLocalBuild;
|
|
||||||
|
|
||||||
name = "${src.name}-node_modules";
|
|
||||||
nativeBuildInputs = [
|
|
||||||
makeWrapper
|
|
||||||
(if args ? nodejs then args.nodejs else nodejs)
|
|
||||||
];
|
|
||||||
|
|
||||||
outputHashMode = "recursive";
|
|
||||||
|
|
||||||
impureEnvVars = lib.fetchers.proxyImpureEnvVars;
|
|
||||||
|
|
||||||
phases = "unpackPhase patchPhase buildPhase installPhase";
|
|
||||||
|
|
||||||
# npm doesn't support var `SSL_CERT_FILE`.
|
|
||||||
NODE_EXTRA_CA_CERTS = "${cacert}/etc/ssl/certs/ca-bundle.crt";
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
|
|
||||||
if [[ ! -f package.json ]]; then
|
|
||||||
echo "Error: file `package.json` doesn't exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ ! -f package-lock.json ]]; then
|
|
||||||
echo "Error: file `package-lock.json` doesn't exist"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
export SOURCE_DATE_EPOCH=1
|
|
||||||
export npm_config_cache=/tmp
|
|
||||||
NPM_FLAGS="--omit=dev --omit=optional --no-update-notifier $npmFlags"
|
|
||||||
# Scripts may result in non-deterministic behavior.
|
|
||||||
# Some packages (e.g., Puppeteer) use postinstall scripts to download extra data.
|
|
||||||
if [[ ! $runScripts ]]; then
|
|
||||||
NPM_FLAGS+=" --ignore-scripts"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Running npm ci $NPM_FLAGS"
|
|
||||||
npm ci $NPM_FLAGS
|
|
||||||
|
|
||||||
cp package.json \
|
|
||||||
package-lock.json node_modules/
|
|
||||||
rm -f node_modules/.package-lock.json
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
runHook preInstall
|
|
||||||
|
|
||||||
mkdir -p $out/lib
|
|
||||||
cp -r node_modules $out/lib
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
} // (
|
|
||||||
if hash == "" then {
|
|
||||||
outputHashAlgo = "sha256";
|
|
||||||
outputHash = "";
|
|
||||||
} else {
|
|
||||||
outputHash = hash;
|
|
||||||
}
|
|
||||||
) // (builtins.removeAttrs args [ "hash" ]))
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
From e4b3ebaf0451c1bddbd7dcf8527c296938ebb607 Mon Sep 17 00:00:00 2001
|
|
||||||
From: Erik Arvstedt <erik.arvstedt@gmail.com>
|
|
||||||
Date: Sun, 1 Jun 2025 11:17:22 +0200
|
|
||||||
Subject: [PATCH] allow disabling mining pool fetching in offline environments
|
|
||||||
|
|
||||||
Previously, Mempool strictly required fetching mining pool data from
|
|
||||||
Github and failed when this was not possible, e.g. in offline
|
|
||||||
environments.
|
|
||||||
|
|
||||||
This patch allows disabling pool fetching.
|
|
||||||
When disabled, empty pool data is inserted into the DB, which
|
|
||||||
effectively turns off block pool classification.
|
|
||||||
---
|
|
||||||
backend/src/tasks/pools-updater.ts | 5 ++++-
|
|
||||||
1 file changed, 4 insertions(+), 1 deletion(-)
|
|
||||||
|
|
||||||
diff --git a/backend/src/tasks/pools-updater.ts b/backend/src/tasks/pools-updater.ts
|
|
||||||
index 6b0520dfc..a74259b95 100644
|
|
||||||
--- a/backend/src/tasks/pools-updater.ts
|
|
||||||
+++ b/backend/src/tasks/pools-updater.ts
|
|
||||||
@@ -75,7 +75,7 @@ class PoolsUpdater {
|
|
||||||
} else {
|
|
||||||
logger.warn(`pools-v2.json is outdated, fetching latest from ${this.poolsUrl} over ${network}`, this.tag);
|
|
||||||
}
|
|
||||||
- const poolsJson = await this.query(this.poolsUrl);
|
|
||||||
+ const poolsJson = (githubSha == "disable-pool-fetching") ? [] : await this.query(this.poolsUrl);
|
|
||||||
if (poolsJson === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
@@ -136,6 +136,9 @@ class PoolsUpdater {
|
|
||||||
* Fetch our latest pools-v2.json sha from github
|
|
||||||
*/
|
|
||||||
private async fetchPoolsSha(): Promise<string | null> {
|
|
||||||
+ if (this.poolsUrl == "disable-pool-fetching") {
|
|
||||||
+ return "disable-pool-fetching";
|
|
||||||
+ }
|
|
||||||
const response = await this.query(this.treeUrl);
|
|
||||||
|
|
||||||
if (response !== undefined) {
|
|
||||||
--
|
|
||||||
2.47.2
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
|
||||||
# This local copy does not fetch or import nix-bitcoin.
|
|
||||||
{ lib
|
|
||||||
, stdenvNoCC
|
|
||||||
, nodejs_22
|
|
||||||
, nodejs-slim_22
|
|
||||||
, fetchFromGitHub
|
|
||||||
, fetchNodeModules
|
|
||||||
, runCommand
|
|
||||||
, makeWrapper
|
|
||||||
, curl
|
|
||||||
, cacert
|
|
||||||
, rsync
|
|
||||||
# for rust-gbt (backend module)
|
|
||||||
, cargo
|
|
||||||
, rustc
|
|
||||||
, rustPlatform
|
|
||||||
, napi-rs-cli
|
|
||||||
}:
|
|
||||||
rec {
|
|
||||||
nodejs = nodejs_22;
|
|
||||||
nodejsRuntime = nodejs-slim_22;
|
|
||||||
|
|
||||||
version = "3.2.1";
|
|
||||||
|
|
||||||
src = fetchFromGitHub {
|
|
||||||
owner = "mempool";
|
|
||||||
repo = "mempool";
|
|
||||||
tag = "v${version}";
|
|
||||||
hash = "sha256-O2XPD1/BXQnzuOP/vMVyRfmFZEgjA85r+PShWne0vqU=";
|
|
||||||
};
|
|
||||||
|
|
||||||
nodeModules = {
|
|
||||||
frontend = fetchNodeModules {
|
|
||||||
inherit src nodejs;
|
|
||||||
sourceRoot = "source/frontend";
|
|
||||||
hash = "sha256-+jfgsAkDdYvgso8uSHaBj/sQL3fC/ABQWzVTXfdZcU0=";
|
|
||||||
};
|
|
||||||
backend = fetchNodeModules {
|
|
||||||
inherit src nodejs;
|
|
||||||
sourceRoot = "source/backend";
|
|
||||||
hash = "sha256-y5l2SYZYK9SKSy6g0+mtTWD6JFkkdQHHBboECpEvWZ4=";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
frontendAssets = fetchFiles {
|
|
||||||
name = "mempool-frontend-assets";
|
|
||||||
hash = "sha256-r6GfOY8Pdh15o2OQMk8syfvWMV6WMCReToAEkQm7tqQ=";
|
|
||||||
fetcher = ./frontend-assets-fetch.sh;
|
|
||||||
};
|
|
||||||
|
|
||||||
mempool-backend = mkDerivationMempool {
|
|
||||||
pname = "mempool-backend";
|
|
||||||
|
|
||||||
patches = [ ./0001-allow-disabling-mining-pool-fetching.patch ];
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
cd backend
|
|
||||||
${sync} --chmod=+w ${nodeModules.backend}/lib/node_modules .
|
|
||||||
patchShebangs node_modules
|
|
||||||
|
|
||||||
${sync} ${mempool-rust-gbt}/ rust-gbt
|
|
||||||
npm run package
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/lib/mempool-backend
|
|
||||||
${sync} package/ $out/lib/mempool-backend
|
|
||||||
|
|
||||||
makeWrapper ${nodejsRuntime}/bin/node $out/bin/mempool-backend \
|
|
||||||
--add-flags $out/lib/mempool-backend/index.js
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
passthru = {
|
|
||||||
inherit nodejs nodejsRuntime;
|
|
||||||
nodeModules = nodeModules.backend;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
mempool-frontend = mkFrontend {};
|
|
||||||
|
|
||||||
# Argument `config` (type: attrset) defines the mempool frontend config.
|
|
||||||
# If `{}`, the default config is used.
|
|
||||||
# See here for available options:
|
|
||||||
# https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
|
|
||||||
# (`interface Env` and `defaultEnv`)
|
|
||||||
mkFrontend = config: mkDerivationMempool {
|
|
||||||
pname = "mempool-frontend";
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
cd frontend
|
|
||||||
|
|
||||||
${sync} --chmod=+w ${nodeModules.frontend}/lib/node_modules .
|
|
||||||
patchShebangs node_modules
|
|
||||||
|
|
||||||
# sync-assets.js is called during `npm run build` and downloads assets from the
|
|
||||||
# internet. Disable this script and instead add the assets manually after building.
|
|
||||||
: > sync-assets.js
|
|
||||||
|
|
||||||
${lib.optionalString (config != {}) ''
|
|
||||||
ln -s ${builtins.toFile "mempool-frontend-config" (builtins.toJSON config)} mempool-frontend-config.json
|
|
||||||
''}
|
|
||||||
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Add assets that would otherwise be downloaded by sync-assets.js
|
|
||||||
${sync} ${frontendAssets}/ dist/mempool/browser/resources
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
${sync} dist/mempool/browser/ $out
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
passthru = {
|
|
||||||
withConfig = mkFrontend;
|
|
||||||
assets = frontendAssets;
|
|
||||||
nodeModules = nodeModules.frontend;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
mempool-rust-gbt = stdenvNoCC.mkDerivation rec {
|
|
||||||
pname = "mempool-rust-gbt";
|
|
||||||
inherit version src meta;
|
|
||||||
|
|
||||||
sourceRoot = "source/rust/gbt";
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
rustPlatform.cargoSetupHook
|
|
||||||
cargo
|
|
||||||
rustc
|
|
||||||
napi-rs-cli
|
|
||||||
];
|
|
||||||
|
|
||||||
cargoDeps = rustPlatform.fetchCargoVendor {
|
|
||||||
inherit src;
|
|
||||||
name = "${pname}-${version}";
|
|
||||||
inherit sourceRoot;
|
|
||||||
hash = "sha256-eox/K3ipjAqNyFt87lZnxaU/okQLF/KIhqXrX86n+qw=";
|
|
||||||
};
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
# napi doesn't accept an absolute path as dest dir, so we can't directly write to $out
|
|
||||||
napi build --platform --release --strip out
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
mv out $out
|
|
||||||
cp package.json $out
|
|
||||||
'';
|
|
||||||
|
|
||||||
passthru = { inherit cargoDeps; };
|
|
||||||
};
|
|
||||||
|
|
||||||
mempool-nginx-conf = runCommand "mempool-nginx-conf" {} ''
|
|
||||||
${sync} --chmod=u+w ${./nginx-conf}/ $out
|
|
||||||
${sync} ${src}/production/nginx/http-language.conf $out
|
|
||||||
'';
|
|
||||||
|
|
||||||
sync = "${rsync}/bin/rsync -a --inplace";
|
|
||||||
|
|
||||||
mkDerivationMempool = args: stdenvNoCC.mkDerivation ({
|
|
||||||
inherit version src meta;
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
makeWrapper
|
|
||||||
nodejs
|
|
||||||
rsync
|
|
||||||
];
|
|
||||||
|
|
||||||
phases = "unpackPhase patchPhase buildPhase installPhase";
|
|
||||||
} // args);
|
|
||||||
|
|
||||||
fetchFiles = { name, hash, fetcher }: stdenvNoCC.mkDerivation {
|
|
||||||
inherit name;
|
|
||||||
outputHashMode = "recursive";
|
|
||||||
outputHashAlgo = "sha256";
|
|
||||||
outputHash = hash;
|
|
||||||
nativeBuildInputs = [ curl cacert ];
|
|
||||||
buildCommand = ''
|
|
||||||
mkdir $out
|
|
||||||
cd $out
|
|
||||||
${builtins.readFile fetcher}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
meta = with lib; {
|
|
||||||
description = "Bitcoin blockchain and mempool explorer";
|
|
||||||
homepage = "https://github.com/mempool/mempool/";
|
|
||||||
license = licenses.agpl3Plus;
|
|
||||||
maintainers = with maintainers; [ erikarvstedt ];
|
|
||||||
platforms = platforms.unix;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Fetch hash-locked versions of assets that are dynamically fetched via
|
|
||||||
# https://github.com/mempool/mempool/blob/master/frontend/sync-assets.js
|
|
||||||
# when running `npm run build` in the frontend.
|
|
||||||
#
|
|
||||||
# This file is updated by ./frontend-assets-update.sh
|
|
||||||
|
|
||||||
declare -A revs=(
|
|
||||||
["mempool/mining-pool-logos"]=53972ebbd08373cf4910cbb3e6421a1f3bba4563
|
|
||||||
)
|
|
||||||
|
|
||||||
fetchFile() {
|
|
||||||
repo=$1
|
|
||||||
file=$2
|
|
||||||
rev=${revs["$repo"]}
|
|
||||||
curl -fsS "https://raw.githubusercontent.com/$repo/$rev/$file"
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchRepo() {
|
|
||||||
repo=$1
|
|
||||||
rev=${revs["$repo"]}
|
|
||||||
curl -fsSL "https://github.com/$repo/archive/$rev.tar.gz"
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdir mining-pools
|
|
||||||
fetchRepo "mempool/mining-pool-logos" | tar xz --strip-components=1 -C mining-pools
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Settings adapted from
|
|
||||||
# https://github.com/mempool/mempool/blob/v3.2.1/production/nginx/server-common.conf
|
|
||||||
|
|
||||||
# see order of nginx location rules
|
|
||||||
# https://stackoverflow.com/questions/5238377/nginx-location-priority
|
|
||||||
|
|
||||||
# for exact / requests, redirect based on $lang
|
|
||||||
# cache redirect for 5 minutes
|
|
||||||
location = / {
|
|
||||||
if ($lang != '') {
|
|
||||||
return 302 $scheme://$host/$lang/;
|
|
||||||
}
|
|
||||||
try_files /en-US/index.html =404;
|
|
||||||
expires 5m;
|
|
||||||
}
|
|
||||||
|
|
||||||
# cache /<lang>/main.f40e91d908a068a2.js forever since they never change
|
|
||||||
location ~ ^/([a-z][a-z])/(.+\..+\.(js|css))$ {
|
|
||||||
try_files $uri =404;
|
|
||||||
expires 1y;
|
|
||||||
}
|
|
||||||
# cache everything else for 5 minutes
|
|
||||||
location ~ ^/([a-z][a-z])$ {
|
|
||||||
try_files $uri /$1/index.html /en-US/index.html =404;
|
|
||||||
expires 5m;
|
|
||||||
}
|
|
||||||
location ~ ^/([a-z][a-z])/ {
|
|
||||||
try_files $uri /$1/index.html /en-US/index.html =404;
|
|
||||||
expires 5m;
|
|
||||||
}
|
|
||||||
|
|
||||||
# cache /resources/** for 1 week since they don't change often
|
|
||||||
location /resources {
|
|
||||||
try_files $uri /en-US/index.html;
|
|
||||||
expires 1w;
|
|
||||||
}
|
|
||||||
# cache /main.f40e91d908a068a2.js forever since they never change
|
|
||||||
location ~* ^/.+\..+\.(js|css)$ {
|
|
||||||
try_files /$lang/$uri /en-US/$uri =404;
|
|
||||||
expires 1y;
|
|
||||||
}
|
|
||||||
# catch-all for all URLs i.e. /address/foo /tx/foo /block/000
|
|
||||||
# cache 5 minutes since they change frequently
|
|
||||||
location / {
|
|
||||||
try_files /$lang/$uri $uri /en-US/$uri /en-US/index.html =404;
|
|
||||||
expires 5m;
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
|
||||||
# This local copy does not fetch or import nix-bitcoin.
|
|
||||||
{ lib
|
|
||||||
, stdenvNoCC
|
|
||||||
, nodejs_22
|
|
||||||
, nodejs-slim_22
|
|
||||||
, fetchNodeModules
|
|
||||||
, fetchurl
|
|
||||||
, makeWrapper
|
|
||||||
}:
|
|
||||||
let self = stdenvNoCC.mkDerivation {
|
|
||||||
pname = "rtl";
|
|
||||||
version = "0.15.8";
|
|
||||||
|
|
||||||
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=";
|
|
||||||
};
|
|
||||||
|
|
||||||
passthru = {
|
|
||||||
nodejs = nodejs_22;
|
|
||||||
nodejsRuntime = nodejs-slim_22;
|
|
||||||
|
|
||||||
nodeModules = fetchNodeModules {
|
|
||||||
inherit (self) src nodejs;
|
|
||||||
# TODO-EXTERNAL: Remove `npmFlags` when no longer required
|
|
||||||
# See: https://github.com/Ride-The-Lightning/RTL/issues/1182
|
|
||||||
npmFlags = "--legacy-peer-deps";
|
|
||||||
hash = "sha256-oMqd6nLzS6iQ9w4z2yzpR2unA5qhOq5YdvfoS8IgYLY=";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
makeWrapper
|
|
||||||
];
|
|
||||||
|
|
||||||
phases = "unpackPhase patchPhase installPhase";
|
|
||||||
|
|
||||||
# `src` already contains the precompiled frontend and backend.
|
|
||||||
# Copy all files required for packaging, like in
|
|
||||||
# https://github.com/Ride-The-Lightning/RTL/blob/master/dockerfiles/Dockerfile
|
|
||||||
installPhase = ''
|
|
||||||
dest=$out/lib/node_modules/rtl
|
|
||||||
mkdir -p $dest
|
|
||||||
cp -r \
|
|
||||||
rtl.js \
|
|
||||||
package.json \
|
|
||||||
frontend \
|
|
||||||
backend \
|
|
||||||
${self.nodeModules}/lib/node_modules \
|
|
||||||
$dest
|
|
||||||
|
|
||||||
makeWrapper ${self.nodejsRuntime}/bin/node "$out/bin/rtl" \
|
|
||||||
--add-flags "$dest/rtl.js"
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
meta = with lib; {
|
|
||||||
description = "A web interface for LND, c-lightning and Eclair";
|
|
||||||
homepage = "https://github.com/Ride-The-Lightning/RTL";
|
|
||||||
license = licenses.mit;
|
|
||||||
maintainers = with maintainers; [ nixbitcoin erikarvstedt ];
|
|
||||||
platforms = platforms.unix;
|
|
||||||
};
|
|
||||||
}; in self
|
|
||||||
+259
-175
@@ -20,6 +20,11 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Always operate from the repository root, regardless of the caller's cwd.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
@@ -62,13 +67,28 @@ echo
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Helper: Get latest tag
|
# Helper: Get latest tag
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# GitHub release tags are fetched into a private namespace instead of
|
||||||
|
# refs/tags/. GitHub and Gitea contain some same-named historical tags that
|
||||||
|
# point to different objects; sharing refs/tags/ would make either fetch fail
|
||||||
|
# with "would clobber existing tag".
|
||||||
|
GITHUB_TAG_NAMESPACE="refs/release-tags/github"
|
||||||
|
|
||||||
get_latest_tag() {
|
get_latest_tag() {
|
||||||
local exclude="${1:-}"
|
local exclude="${1:-}"
|
||||||
if [[ -n "$exclude" ]]; then
|
local tag
|
||||||
git tag --list 'v*' --sort=-version:refname | grep -v -x -E "v?${exclude#v}|${exclude}" | head -1 || echo ""
|
|
||||||
else
|
while IFS= read -r tag; do
|
||||||
git tag --list 'v*' --sort=-version:refname | head -1 || echo ""
|
if [[ -n "$exclude" && "$tag" == "v${exclude#v}" ]]; then
|
||||||
|
continue
|
||||||
fi
|
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}"
|
echo -e "${BLUE}Step 0: Running release preflight...${NC}"
|
||||||
git fetch --all --tags 2>/dev/null || true
|
|
||||||
|
# A release must start from an exact, committed, reproducible tree. This also
|
||||||
|
# prevents release metadata generated below from being mixed with local work.
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo -e "${RED}Error: the working tree is not clean. Commit, stash, or remove local changes first.${NC}" >&2
|
||||||
|
git status --short >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for remote in "$GITHUB_REMOTE" "$GITEA_REMOTE"; do
|
||||||
|
if ! git remote | grep -q -x "$remote"; then
|
||||||
|
echo -e "${RED}Error: required remote '$remote' is not configured.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
for command_name in gh curl; do
|
||||||
|
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: required command '$command_name' is not installed.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if ! gh auth status >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: GitHub CLI is not authenticated. Run 'gh auth login' first.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||||
|
echo
|
||||||
|
read -rsp "Enter your Gitea API token (input hidden): " GITEA_TOKEN
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||||
|
echo -e "${RED}Error: a GITEA_TOKEN with write:repository scope is required.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e " Fetching GitHub (${GITHUB_REMOTE}) and Gitea (${GITEA_REMOTE})..."
|
||||||
|
# Never fetch either host's tags into refs/tags/. Historical tags with the same
|
||||||
|
# name differ between the hosts, and Git correctly refuses to clobber them.
|
||||||
|
git fetch "$GITHUB_REMOTE" --prune --no-tags
|
||||||
|
git fetch "$GITEA_REMOTE" --prune --no-tags
|
||||||
|
git fetch "$GITHUB_REMOTE" --prune --no-tags \
|
||||||
|
"+refs/tags/*:${GITHUB_TAG_NAMESPACE}/*"
|
||||||
|
|
||||||
|
GITHUB_MAIN_REF="refs/remotes/${GITHUB_REMOTE}/main"
|
||||||
|
GITEA_STAGING_REF="refs/remotes/${GITEA_REMOTE}/staging-dev"
|
||||||
|
|
||||||
|
if ! git rev-parse --verify "$GITHUB_MAIN_REF" >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: cannot resolve GitHub main at ${GITHUB_MAIN_REF}.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! git rev-parse --verify "$GITEA_STAGING_REF" >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: cannot resolve Gitea staging-dev at ${GITEA_STAGING_REF}.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HEAD_COMMIT=$(git rev-parse HEAD)
|
||||||
|
GITHUB_MAIN_COMMIT=$(git rev-parse "$GITHUB_MAIN_REF")
|
||||||
|
GITEA_STAGING_COMMIT=$(git rev-parse "$GITEA_STAGING_REF")
|
||||||
|
|
||||||
|
if [[ "$GITHUB_MAIN_COMMIT" != "$GITEA_STAGING_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: GitHub main and Gitea staging-dev are not synchronized.${NC}" >&2
|
||||||
|
echo " GitHub main : $GITHUB_MAIN_COMMIT" >&2
|
||||||
|
echo " Gitea staging-dev : $GITEA_STAGING_COMMIT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$HEAD_COMMIT" != "$GITHUB_MAIN_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: local HEAD is not the synchronized release candidate.${NC}" >&2
|
||||||
|
echo " Local HEAD : $HEAD_COMMIT" >&2
|
||||||
|
echo " Remote HEAD : $GITHUB_MAIN_COMMIT" >&2
|
||||||
|
echo "Update/check out the synchronized commit, then run the script again." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} Clean tree; GitHub main and Gitea staging-dev match local HEAD"
|
||||||
|
|
||||||
LATEST_TAG=$(get_latest_tag)
|
LATEST_TAG=$(get_latest_tag)
|
||||||
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
|
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
|
||||||
@@ -117,6 +211,23 @@ fi
|
|||||||
VERSION="${VERSION#v}"
|
VERSION="${VERSION#v}"
|
||||||
TAG="v${VERSION}"
|
TAG="v${VERSION}"
|
||||||
|
|
||||||
|
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo -e "${RED}Error: version must use MAJOR.MINOR.PATCH format (for example, 1.1.1).${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
remote_has_tag() {
|
||||||
|
local remote="$1"
|
||||||
|
[[ -n "$(git ls-remote --tags "$remote" "refs/tags/${TAG}" "refs/tags/${TAG}^{}")" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/tags/${TAG}" || \
|
||||||
|
git show-ref --verify --quiet "${GITHUB_TAG_NAMESPACE}/${TAG}" || \
|
||||||
|
remote_has_tag "$GITHUB_REMOTE" || remote_has_tag "$GITEA_REMOTE"; then
|
||||||
|
echo -e "${RED}Error: tag ${TAG} already exists locally or on a remote.${NC}" >&2
|
||||||
|
echo "Refusing to move or overwrite an existing release tag." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Get release message
|
# Get release message
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -193,20 +304,26 @@ generate_release_notes() {
|
|||||||
printf '%s' "$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")
|
PREV_TAG=$(get_latest_tag "$TAG")
|
||||||
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG" >/dev/null; then
|
PREV_TAG_REF="${GITHUB_TAG_NAMESPACE}/${PREV_TAG}"
|
||||||
COMMIT_RANGE="${PREV_TAG}..HEAD"
|
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
|
else
|
||||||
COMMIT_RANGE="HEAD"
|
COMMIT_RANGE="HEAD"
|
||||||
|
COMMIT_RANGE_DISPLAY="HEAD"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
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")"
|
RELEASE_NOTES="$(generate_release_notes "$COMMIT_RANGE")"
|
||||||
|
|
||||||
# Let the user review/edit the generated notes before publishing
|
# Let the user review/edit the generated notes before publishing
|
||||||
NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")"
|
NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")"
|
||||||
|
trap 'rm -f "$NOTES_FILE"' EXIT
|
||||||
{
|
{
|
||||||
echo "## Sovran_SystemsOS ${TAG}"
|
echo "## Sovran_SystemsOS ${TAG}"
|
||||||
echo
|
echo
|
||||||
@@ -246,95 +363,45 @@ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Step 1: Push tested code to Gitea (stable & staging-dev)
|
# Step 1: Prepare and commit all release metadata
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Step 1: Pushing tested code to Gitea (stable & staging-dev)...${NC}"
|
echo -e "${BLUE}Step 1: Preparing release metadata for ${TAG}...${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
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# VERSION drives ISO naming.
|
||||||
# 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
|
|
||||||
echo "${VERSION}" > VERSION
|
echo "${VERSION}" > VERSION
|
||||||
git add VERSION
|
|
||||||
git commit -m "chore: bump VERSION to ${TAG} for ISO naming" || true
|
|
||||||
echo -e " ${GREEN}✓${NC} VERSION file updated to ${VERSION}"
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# 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"
|
README_FILE="README.md"
|
||||||
if [ -f "$README_FILE" ]; then
|
if [[ ! -f "$README_FILE" ]]; then
|
||||||
# Extract the version currently used in the README's ISO filenames,
|
echo -e "${RED}Error: ${README_FILE} not found.${NC}" >&2
|
||||||
# e.g. "Sovran_SystemsOS-1.0.6.iso" -> "1.0.6"
|
exit 1
|
||||||
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" | head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
fi
|
||||||
|
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" \
|
||||||
if [ -n "$OLD_ISO_VER" ] && [ "$OLD_ISO_VER" != "$VERSION" ]; then
|
| head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
||||||
# Portable in-place edit (works with GNU sed and BSD/macOS sed)
|
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" \
|
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
|
||||||
"$README_FILE" > "$README_FILE.tmp" && mv "$README_FILE.tmp" "$README_FILE"
|
"$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"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# Add the changelog entry before tagging so the tag and stable branch contain it.
|
||||||
# Step 3: Auto-update CHANGELOG.md
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
echo
|
|
||||||
echo -e "${BLUE}Step 3: Updating ${CHANGELOG_FILE}...${NC}"
|
|
||||||
|
|
||||||
TODAY=$(date +%Y-%m-%d)
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
# Create new changelog entry from the generated notes (no placeholders)
|
|
||||||
NEW_ENTRY="## [${VERSION}] - ${TODAY}
|
NEW_ENTRY="## [${VERSION}] - ${TODAY}
|
||||||
|
|
||||||
${RELEASE_NOTES}
|
${RELEASE_NOTES}
|
||||||
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
|
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
|
||||||
"
|
"
|
||||||
|
|
||||||
# Prepend to changelog (after the header)
|
if [[ ! -f "$CHANGELOG_FILE" ]]; then
|
||||||
if [ -f "$CHANGELOG_FILE" ]; then
|
echo -e "${RED}Error: ${CHANGELOG_FILE} not found.${NC}" >&2
|
||||||
# Backup
|
exit 1
|
||||||
cp "$CHANGELOG_FILE" "${CHANGELOG_FILE}.bak"
|
fi
|
||||||
|
awk -v new_entry="$NEW_ENTRY" '
|
||||||
# Insert new section after the first --- line
|
|
||||||
awk -v new_entry="$NEW_ENTRY" '
|
|
||||||
BEGIN { printed=0 }
|
BEGIN { printed=0 }
|
||||||
/^---$/ && !printed {
|
/^---$/ && !printed {
|
||||||
print
|
print
|
||||||
@@ -344,116 +411,134 @@ if [ -f "$CHANGELOG_FILE" ]; then
|
|||||||
next
|
next
|
||||||
}
|
}
|
||||||
{ print }
|
{ print }
|
||||||
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" && mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
END { if (!printed) exit 2 }
|
||||||
rm -f "${CHANGELOG_FILE}.bak"
|
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" || {
|
||||||
|
rm -f "${CHANGELOG_FILE}.tmp"
|
||||||
|
echo -e "${RED}Error: could not find the changelog insertion marker.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
||||||
|
|
||||||
echo -e " ${GREEN}✓${NC} CHANGELOG.md updated with new section for ${TAG}"
|
git add VERSION "$README_FILE" "$CHANGELOG_FILE"
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} CHANGELOG.md not found — skipping"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Commit the changelog update
|
|
||||||
git add "$CHANGELOG_FILE"
|
|
||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
echo " (No changes to commit in changelog)"
|
echo -e "${RED}Error: release preparation produced no changes.${NC}" >&2
|
||||||
else
|
exit 1
|
||||||
git commit -m "docs: update CHANGELOG.md for ${TAG}"
|
|
||||||
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
|
|
||||||
fi
|
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
|
||||||
echo -e "${BLUE}Step 4: Creating GitHub Release...${NC}"
|
echo -e "${BLUE}Step 2: Publishing the final release commit...${NC}"
|
||||||
|
|
||||||
if command -v gh &>/dev/null; then
|
# GitHub main and Gitea staging-dev were verified equal during preflight, so
|
||||||
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
# these are normal fast-forward pushes. Stable is an intentional promotion and
|
||||||
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
# uses a lease to prevent overwriting a branch changed since the fetch.
|
||||||
if gh release create "${TAG}" \
|
git push "$GITHUB_REMOTE" HEAD:main
|
||||||
|
git push "$GITEA_REMOTE" HEAD:staging-dev
|
||||||
|
git push "$GITEA_REMOTE" HEAD:stable --force-with-lease
|
||||||
|
|
||||||
|
# Verify all three branch tips before creating an immutable release tag.
|
||||||
|
remote_branch_commit() {
|
||||||
|
local remote="$1"
|
||||||
|
local branch="$2"
|
||||||
|
git ls-remote "$remote" "refs/heads/${branch}" | awk 'NR == 1 { print $1 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
PUBLISHED_GITHUB=$(remote_branch_commit "$GITHUB_REMOTE" main)
|
||||||
|
PUBLISHED_STAGING=$(remote_branch_commit "$GITEA_REMOTE" staging-dev)
|
||||||
|
PUBLISHED_STABLE=$(remote_branch_commit "$GITEA_REMOTE" stable)
|
||||||
|
if [[ "$PUBLISHED_GITHUB" != "$RELEASE_COMMIT" || \
|
||||||
|
"$PUBLISHED_STAGING" != "$RELEASE_COMMIT" || \
|
||||||
|
"$PUBLISHED_STABLE" != "$RELEASE_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: post-push verification failed; no release tag was created.${NC}" >&2
|
||||||
|
echo " Expected : $RELEASE_COMMIT" >&2
|
||||||
|
echo " GitHub main : ${PUBLISHED_GITHUB:-missing}" >&2
|
||||||
|
echo " Gitea staging-dev : ${PUBLISHED_STAGING:-missing}" >&2
|
||||||
|
echo " Gitea stable : ${PUBLISHED_STABLE:-missing}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e " ${GREEN}✓${NC} GitHub main, Gitea staging-dev, and Gitea stable all match"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 3: Tag the final release commit and publish the tag
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 3: Creating annotated tag ${TAG} on ${RELEASE_COMMIT}...${NC}"
|
||||||
|
git tag -a "$TAG" -m "${RELEASE_MESSAGE}
|
||||||
|
|
||||||
|
- Stable release of Sovran_SystemsOS
|
||||||
|
- See CHANGELOG.md for full details" "$RELEASE_COMMIT"
|
||||||
|
|
||||||
|
git push "$GITHUB_REMOTE" "$TAG"
|
||||||
|
git push "$GITEA_REMOTE" "$TAG"
|
||||||
|
|
||||||
|
# For an annotated tag, ^{} resolves the commit referenced by the tag object.
|
||||||
|
remote_tag_commit() {
|
||||||
|
local remote="$1"
|
||||||
|
git ls-remote "$remote" "refs/tags/${TAG}^{}" | awk 'NR == 1 { print $1 }'
|
||||||
|
}
|
||||||
|
GITHUB_TAG_COMMIT=$(remote_tag_commit "$GITHUB_REMOTE")
|
||||||
|
GITEA_TAG_COMMIT=$(remote_tag_commit "$GITEA_REMOTE")
|
||||||
|
if [[ "$GITHUB_TAG_COMMIT" != "$RELEASE_COMMIT" || "$GITEA_TAG_COMMIT" != "$RELEASE_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: published tag verification failed.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e " ${GREEN}✓${NC} ${TAG} points to the final release commit on GitHub and Gitea"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 4: Create GitHub release via gh CLI
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 4: Creating GitHub release...${NC}"
|
||||||
|
|
||||||
|
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
||||||
|
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
||||||
|
gh release create "$TAG" \
|
||||||
--repo naturallaw777/Sovran_SystemsOS \
|
--repo naturallaw777/Sovran_SystemsOS \
|
||||||
--title "${TAG} — ${title_suffix}" \
|
--title "${TAG} — ${title_suffix}" \
|
||||||
--notes-file "${NOTES_FILE}"; then
|
--notes-file "$NOTES_FILE"
|
||||||
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
|
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
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Step 5: Create Gitea Release via API
|
# Step 5: Create Gitea release via API
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Step 5: Creating Gitea Release via API...${NC}"
|
echo -e "${BLUE}Step 5: Creating Gitea release via API...${NC}"
|
||||||
|
|
||||||
# ── Gitea Token Handling ─────────────────────────────────────────────────────
|
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
||||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
|
||||||
echo
|
# Build JSON safely because release notes may contain quotes and newlines.
|
||||||
echo -e "${YELLOW}GITEA_TOKEN is not set.${NC}"
|
if command -v jq >/dev/null 2>&1; then
|
||||||
read -rsp "Enter your Gitea API token (input will be hidden): " GITEA_TOKEN
|
PAYLOAD=$(jq -n \
|
||||||
echo
|
--arg tag "$TAG" \
|
||||||
if [[ -z "$GITEA_TOKEN" ]]; then
|
--arg name "$TAG — Stable Release" \
|
||||||
echo -e " ${YELLOW}⚠${NC} No token provided — skipping Gitea release"
|
--arg body "$RELEASE_BODY" \
|
||||||
GITEA_TOKEN=""
|
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||||
fi
|
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
|
fi
|
||||||
|
|
||||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
RESPONSE_FILE=$(mktemp "/tmp/gitea-release-${TAG}.XXXXXX.json")
|
||||||
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' -X POST \
|
||||||
|
|
||||||
# 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 \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "${PAYLOAD}" \
|
-d "$PAYLOAD" \
|
||||||
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases" 2>/dev/null || echo "")
|
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases")
|
||||||
|
|
||||||
if echo "$RESPONSE" | grep -q '"id"'; then
|
if [[ "$HTTP_STATUS" != "201" ]]; then
|
||||||
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
echo -e "${RED}Error: Gitea release creation failed (HTTP ${HTTP_STATUS}).${NC}" >&2
|
||||||
elif echo "$RESPONSE" | grep -q "write:repository"; then
|
cat "$RESPONSE_FILE" >&2
|
||||||
echo -e " ${YELLOW}⚠${NC} Gitea token scope issue: your token requires the 'write:repository' scope (currently has write:package)."
|
rm -f "$RESPONSE_FILE"
|
||||||
echo " To fix: In Gitea, navigate to Settings → Applications → Manage Access Tokens and generate a token with 'write:repository'."
|
exit 1
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} Gitea release creation failed or already exists"
|
|
||||||
echo " Response: $RESPONSE"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
rm -f "$RESPONSE_FILE"
|
||||||
|
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Final Summary
|
# Final Summary
|
||||||
@@ -468,8 +553,7 @@ echo " • Build the installer ISO:"
|
|||||||
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
|
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
|
||||||
echo " • Package, verify, and upload ISO to CDN:"
|
echo " • Package, verify, and upload ISO to CDN:"
|
||||||
echo " ./scripts/upload-cdn.sh --upload"
|
echo " ./scripts/upload-cdn.sh --upload"
|
||||||
echo " • Review and enhance the new section in CHANGELOG.md"
|
echo " • Verify the ISO download and checksum from the public CDN"
|
||||||
echo " • Push changes: git push ${GITHUB_REMOTE} HEAD:main && git push ${GITEA_REMOTE} HEAD:staging-dev"
|
|
||||||
echo " • Verify releases on both GitHub and Gitea"
|
echo " • Verify releases on both GitHub and Gitea"
|
||||||
echo
|
echo
|
||||||
echo -e "${CYAN}Tag created: ${TAG}${NC}"
|
echo -e "${CYAN}Tag created: ${TAG}${NC}"
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
{ nixpkgs, overlay-stable, system ? "x86_64-linux" }:
|
|
||||||
|
|
||||||
let
|
|
||||||
lib = nixpkgs.lib;
|
|
||||||
pkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
overlays = [ overlay-stable ];
|
|
||||||
};
|
|
||||||
|
|
||||||
normalize = s:
|
|
||||||
lib.replaceStrings [ "\n" "\\" " " ] [ " " "" " " ] s;
|
|
||||||
|
|
||||||
extractAfter = prefix: str:
|
|
||||||
let
|
|
||||||
match = builtins.match ".*${prefix} ([^ ]+).*" (normalize str);
|
|
||||||
in
|
|
||||||
if match == null then
|
|
||||||
throw "Unable to extract ${prefix} from: ${normalize str}"
|
|
||||||
else
|
|
||||||
builtins.head match;
|
|
||||||
|
|
||||||
extractFlagValue = flag: str:
|
|
||||||
let
|
|
||||||
match = builtins.match ".*${flag}=([^ ]+).*" (normalize str);
|
|
||||||
in
|
|
||||||
if match == null then
|
|
||||||
throw "Unable to extract ${flag} from: ${normalize str}"
|
|
||||||
else
|
|
||||||
builtins.head match;
|
|
||||||
|
|
||||||
config = (lib.nixosSystem {
|
|
||||||
inherit system;
|
|
||||||
modules = [
|
|
||||||
{ nixpkgs.hostPlatform = system; nixpkgs.overlays = [ overlay-stable ]; }
|
|
||||||
../modules/bitcoin
|
|
||||||
{
|
|
||||||
nix-bitcoin.generateSecrets = true;
|
|
||||||
nix-bitcoin.secretsDir = "/build/secrets";
|
|
||||||
|
|
||||||
services.btcpayserver.enable = true;
|
|
||||||
services.btcpayserver.lightningBackend = "lnd";
|
|
||||||
services.nbxplorer.dataDir = "/build/nbxplorer";
|
|
||||||
services.btcpayserver.dataDir = "/build/btcpayserver";
|
|
||||||
services.lnd.dataDir = "/build/lnd";
|
|
||||||
services.bitcoind.dataDir = "/build/bitcoind";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}).config;
|
|
||||||
|
|
||||||
nbxplorerPreStart = config.systemd.services.nbxplorer.preStart;
|
|
||||||
bitcoindPreStart = config.systemd.services.bitcoind.preStart;
|
|
||||||
btcpayExecStart = config.systemd.services.btcpayserver.serviceConfig.ExecStart;
|
|
||||||
btcpayWorkingDir = config.systemd.services.btcpayserver.serviceConfig.WorkingDirectory;
|
|
||||||
|
|
||||||
nbxplorerConfigPath = extractAfter "install -m 600" nbxplorerPreStart;
|
|
||||||
btcpayConfigPath = extractFlagValue "--conf" btcpayExecStart;
|
|
||||||
|
|
||||||
nbxplorerConfig = builtins.readFile nbxplorerConfigPath;
|
|
||||||
btcpayConfig = builtins.readFile btcpayConfigPath;
|
|
||||||
in
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.users.users.${config.services.btcpayserver.user}.home == config.services.btcpayserver.dataDir)
|
|
||||||
"btcpayserver user home must match btcpayserver dataDir";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.users.users.${config.services.nbxplorer.user}.home == config.services.nbxplorer.dataDir)
|
|
||||||
"nbxplorer user home must match nbxplorer dataDir";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.nix-bitcoin.secrets.bitcoin-HMAC-btcpayserver.user == config.services.bitcoind.user)
|
|
||||||
"bitcoin-HMAC-btcpayserver must be owned by bitcoind";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.user == config.services.bitcoind.user)
|
|
||||||
"bitcoin-rpcpassword-btcpayserver must be owned by bitcoind";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.group == config.services.nbxplorer.group)
|
|
||||||
"bitcoin-rpcpassword-btcpayserver must be group-readable by nbxplorer";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(!(lib.elem config.services.nbxplorer.group config.users.users.${config.services.btcpayserver.user}.extraGroups))
|
|
||||||
"btcpayserver must not receive the nbxplorer group";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.elem "nix-bitcoin-secrets.target" config.systemd.services.nbxplorer.after)
|
|
||||||
"nbxplorer must wait for nix-bitcoin-secrets.target";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.systemd.services.nbxplorer.serviceConfig.MemoryDenyWriteExecute == false)
|
|
||||||
"nbxplorer needs MemoryDenyWriteExecute = false";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.systemd.services.btcpayserver.serviceConfig.MemoryDenyWriteExecute == false)
|
|
||||||
"btcpayserver needs MemoryDenyWriteExecute = false";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasInfix "network=mainnet" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "btcrpcuser=btcpayserver" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "btcnodeendpoint=127.0.0.1:8335" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "bind=127.0.0.1" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "port=24444" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" nbxplorerConfig)
|
|
||||||
"nbxplorer base config must contain the expected non-secret settings";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
|
|
||||||
&& lib.hasInfix "btcexplorercookiefile=/build/nbxplorer/Main/.cookie" btcpayConfig)
|
|
||||||
"btcpayserver config must contain btcexplorerurl and btcexplorercookiefile";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasSuffix "/lib/btcpayserver" btcpayWorkingDir)
|
|
||||||
"btcpayserver WorkingDirectory must end with /lib/btcpayserver";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(!lib.hasInfix "/build/btcpayserver/settings.config" btcpayExecStart
|
|
||||||
&& lib.hasInfix "--datadir='/build/btcpayserver'" btcpayExecStart)
|
|
||||||
"btcpayserver must use a deterministic config file plus --datadir";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasInfix "network=mainnet" btcpayConfig
|
|
||||||
&& lib.hasInfix "bind=127.0.0.1" btcpayConfig
|
|
||||||
&& lib.hasInfix "port=23000" btcpayConfig
|
|
||||||
&& lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
|
|
||||||
&& lib.hasInfix "explorer.postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" btcpayConfig
|
|
||||||
&& lib.hasInfix "postgres=User ID=btcpayserver;Host=/run/postgresql;Database=btcpayserver" btcpayConfig
|
|
||||||
&& lib.hasInfix "btclightning=type=lnd-rest;server=https://127.0.0.1:8080/;macaroonfilepath=/run/lnd/btcpayserver.macaroon;certfilepath=/build/secrets/lnd-cert" btcpayConfig)
|
|
||||||
"btcpayserver config must preserve BTCPay, NBXplorer, database, and LND settings";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasInfix "readValidatedRpcHmac()" bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''if [[ ! -e "$hmacFile" ]]; then'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''if [[ ! -r "$hmacFile" ]]; then'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''if [[ -z "$hmacPayload" ]]; then'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''^[[:xdigit:]]+\$[[:xdigit:]]+$'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''Bitcoin RPC HMAC file has invalid format'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''hmacPayload="$(readValidatedRpcHmac '/build/secrets/bitcoin-HMAC-btcpayserver')" || exit 1'' bitcoindPreStart)
|
|
||||||
"bitcoind preStart must validate missing, unreadable, empty, and malformed HMAC files";
|
|
||||||
pkgs.runCommand "bitcoin-btcpay-hardening" {} ''
|
|
||||||
mkdir -p /build/secrets /build/nbxplorer
|
|
||||||
|
|
||||||
printf '%s' 'first-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
|
|
||||||
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
|
|
||||||
|
|
||||||
test "$(stat -c '%a' /build/nbxplorer/settings.config)" = "600"
|
|
||||||
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
|
|
||||||
printf '%s' 'rotated-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
|
|
||||||
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
|
|
||||||
|
|
||||||
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
! grep -q 'first-password' /build/nbxplorer/settings.config
|
|
||||||
grep -q 'rotated-password' /build/nbxplorer/settings.config
|
|
||||||
|
|
||||||
touch "$out"
|
|
||||||
''
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Regression tests for the Bitcoin Core Tor IBD gossip Hub option.
|
||||||
|
|
||||||
|
These tests intentionally avoid importing the FastAPI application so they can run
|
||||||
|
in the repository's lightweight test environment without NixOS service access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(*parts: str) -> str:
|
||||||
|
with open(os.path.join(_REPO_ROOT, *parts), encoding="utf-8") as source:
|
||||||
|
return source.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _literal_assignment(source: str, name: str):
|
||||||
|
tree = ast.parse(source)
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.Assign):
|
||||||
|
if any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
|
||||||
|
return ast.literal_eval(node.value)
|
||||||
|
raise AssertionError(f"Assignment {name} was not found")
|
||||||
|
|
||||||
|
|
||||||
|
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
|
||||||
|
def test_integration_layer_maps_tor_gossip_option(self):
|
||||||
|
"""The integration layer must bridge sovran_systemsOS.features.bitcoin-tor-gossip
|
||||||
|
to sovran-bitcoin.bitcoindTorGossip."""
|
||||||
|
integration = _read("modules", "sovran-bitcoin-integration.nix")
|
||||||
|
self.assertIn("bitcoindTorGossip", integration)
|
||||||
|
self.assertIn("bitcoin-tor-gossip", integration)
|
||||||
|
|
||||||
|
def test_hub_option_and_evaluated_state_are_declared(self):
|
||||||
|
roles = _read("modules", "core", "roles.nix")
|
||||||
|
hub = _read("modules", "core", "sovran-hub.nix")
|
||||||
|
self.assertIn("bitcoin-tor-gossip = lib.mkEnableOption", roles)
|
||||||
|
self.assertIn("bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;", hub)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBitcoinTorGossipHubWiring(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.server_source = _read("app", "sovran_systemsos_web", "server.py")
|
||||||
|
cls.registry = _literal_assignment(cls.server_source, "FEATURE_REGISTRY")
|
||||||
|
|
||||||
|
def test_feature_is_modal_only_and_explains_risk(self):
|
||||||
|
feature = next(item for item in self.registry if item["id"] == "bitcoin-tor-gossip")
|
||||||
|
self.assertTrue(feature["modal_only"])
|
||||||
|
self.assertIn("bitcoin-service", feature["requires"])
|
||||||
|
self.assertTrue(any("clearnet" in detail for detail in feature["details"]))
|
||||||
|
self.assertTrue(any("bandwidth" in detail for detail in feature["details"]))
|
||||||
|
|
||||||
|
def test_backend_rejects_gossip_without_bitcoin_service(self):
|
||||||
|
self.assertIn(
|
||||||
|
"Enable the Bitcoin service before advertising its Tor IBD service.",
|
||||||
|
self.server_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_modal_renders_and_controls_the_option(self):
|
||||||
|
frontend = _read(
|
||||||
|
"app", "sovran_systemsos_web", "static", "js", "service-detail.js"
|
||||||
|
)
|
||||||
|
self.assertIn("Tor IBD Service Advertising", frontend)
|
||||||
|
self.assertIn("svc-detail-related-feature-btn", frontend)
|
||||||
|
self.assertIn("handleFeatureToggle(relatedFeat", frontend)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -14,6 +14,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
# Add the app package to the path so we can import without the full FastAPI tree.
|
# 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,
|
_validate_ssh_pubkey,
|
||||||
_DDNS_ALLOWED_HOSTNAMES,
|
_DDNS_ALLOWED_HOSTNAMES,
|
||||||
_bech32_decode,
|
_bech32_decode,
|
||||||
|
load_session_store,
|
||||||
|
save_session_store,
|
||||||
)
|
)
|
||||||
from sovran_systemsos_web import support_ops # noqa: E402
|
from sovran_systemsos_web import support_ops # noqa: E402
|
||||||
|
|
||||||
@@ -362,6 +365,190 @@ class TestAuthExemptPaths(unittest.TestCase):
|
|||||||
self.assertIn("/api/ping", self._get_exempt_paths())
|
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
|
# tech-support.nix validation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Regression tests for the Hub Zeus Connect QR.
|
||||||
|
|
||||||
|
The Zeus Connect setup service (modules/wallet-autoconnect.nix) and the Hub QR
|
||||||
|
encoding are tested here. The lndconnect wrapper itself is now part of the
|
||||||
|
Sovran_Bitcoin flake — its contract tests live in that repository.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(relpath: str) -> str:
|
||||||
|
with open(os.path.join(_REPO_ROOT, relpath), encoding="utf-8") as fh:
|
||||||
|
return fh.read()
|
||||||
|
|
||||||
|
|
||||||
|
class TestZeusConnectSetup(unittest.TestCase):
|
||||||
|
"""zeus-connect-setup must wait for the REST onion and validate the URI."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.src = _read("modules/wallet-autoconnect.nix")
|
||||||
|
|
||||||
|
def test_waits_for_onion_addresses(self):
|
||||||
|
self.assertIn("onion-addresses.service", self.src)
|
||||||
|
|
||||||
|
def test_rejects_non_lndconnect_output(self):
|
||||||
|
self.assertIn("^lndconnect://", self.src)
|
||||||
|
self.assertIn("\\.onion", self.src)
|
||||||
|
self.assertIn("macaroon=", self.src)
|
||||||
|
|
||||||
|
def test_no_clightning_fallback(self):
|
||||||
|
self.assertNotIn("lnconnect-clnrest", self.src)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHubQrFallback(unittest.TestCase):
|
||||||
|
"""Hub QR encoding must not die on a payload that is too large for ECC H."""
|
||||||
|
|
||||||
|
def test_qrencode_falls_back_to_lower_ecc(self):
|
||||||
|
src = _read("app/sovran_systemsos_web/server.py")
|
||||||
|
self.assertIn('for ecc in ("H", "Q", "L")', src)
|
||||||
|
|
||||||
|
def test_qronly_does_not_hide_uri_when_qr_fails(self):
|
||||||
|
src = _read("app/sovran_systemsos_web/server.py")
|
||||||
|
self.assertIn("Don't hide the URI if we could not render a scannable QR.", src)
|
||||||
|
|
||||||
|
def test_zeus_guide_mentions_use_tor(self):
|
||||||
|
src = _read("app/sovran_systemsos_web/static/js/service-detail.js")
|
||||||
|
self.assertIn("Use Tor", src)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user