Compare commits
150
Commits
v1.0.6
..
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 | ||
|
|
9ce169c4f0 | ||
|
|
61287dfece | ||
|
|
f48f210209 | ||
|
|
4193c56397 | ||
|
|
f85f1a2c9f | ||
|
|
802474fe6d | ||
|
|
679c7a039f | ||
|
|
1b43a34e9c | ||
|
|
3f233beea0 | ||
|
|
947c04834d | ||
|
|
894707a87c | ||
|
|
43fc01d350 | ||
|
|
1f0a1ab865 | ||
|
|
79f3e8efd8 | ||
|
|
ff6abf3b8e | ||
|
|
a111de1ece | ||
|
|
9b77b04741 | ||
|
|
f2ad9c1f17 | ||
|
|
590ed134b7 | ||
|
|
cc438aa524 | ||
|
|
0d0a1888f9 | ||
|
|
14b8258420 | ||
|
|
09b34997e4 | ||
|
|
a372b88488 | ||
|
|
7716c950cd | ||
|
|
2c223d1166 | ||
|
|
503bd2467d | ||
|
|
57070644cc | ||
|
|
1b6b1ade46 | ||
|
|
a7b149f58a | ||
|
|
90f89f14ba | ||
|
|
dcfee1fb32 | ||
|
|
05a42bcc4b | ||
|
|
9befcd06e6 | ||
|
|
c9169164b5 | ||
|
|
cb84ca839f | ||
|
|
6d59441b13 | ||
|
|
5475e33d08 | ||
|
|
88896f00c4 | ||
|
|
cb2d6de0ef | ||
|
|
c2dce6cf94 | ||
|
|
7879a2771d | ||
|
|
820d850d00 | ||
|
|
d733187ec5 | ||
|
|
f7b1f42e0e | ||
|
|
03fb7791eb | ||
|
|
953cacc72c | ||
|
|
6021691b3f | ||
|
|
4d8bb06d41 | ||
|
|
b96d2bcb24 | ||
|
|
228a48af03 | ||
|
|
0df4cde9b8 | ||
|
|
a37b1a91bf | ||
|
|
ebcc086a7e | ||
|
|
dcc9e4904e | ||
|
|
fd209254a2 | ||
|
|
a68130625e | ||
|
|
7b04adbbe6 | ||
|
|
2c6ef7b60e | ||
|
|
179fc501f9 | ||
|
|
8bcabe2aa1 | ||
|
|
5d5e369411 | ||
|
|
425a1845b4 | ||
|
|
546dacf396 | ||
|
|
17c0db8290 | ||
|
|
dac733de9a | ||
|
|
3baf504c60 | ||
|
|
f220b93b12 | ||
|
|
b2a5233653 | ||
|
|
2bb5c50196 | ||
|
|
80c4b052a0 | ||
|
|
2accada021 | ||
|
|
a6296b9473 | ||
|
|
5173fb3df6 | ||
|
|
147afcc4ac | ||
|
|
2e35539ae6 | ||
|
|
31cd5f96f2 | ||
|
|
90262e672e | ||
|
|
1fbeafd02f | ||
|
|
953cedce3f | ||
|
|
58d7e21d28 | ||
|
|
9f7dcfa6de | ||
|
|
6c2c69d18a | ||
|
|
6b9c68e3b2 | ||
|
|
4b511348ae | ||
|
|
81946895db | ||
|
|
a10ef95b5c | ||
|
|
278d480653 | ||
|
|
b64061135a | ||
|
|
825c3fbb4b |
+204
-45
@@ -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
|
||||||
@@ -51,50 +253,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Clarify GitHub is the dev mirror of the Gitea stable repo
|
- Clarify GitHub is the dev mirror of the Gitea stable repo
|
||||||
[1.0.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.6
|
[1.0.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.6
|
||||||
|
|
||||||
|
|
||||||
## [1.0.6] - 2026-08-07
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Iso: fix replaceStrings length mismatch in cleanVersion
|
|
||||||
- Promote virtual machine trial option in README
|
|
||||||
- Improve installer VM compatibility
|
|
||||||
- Trim redundant dev-vs-stable Gitea explanations in README
|
|
||||||
- Add Arean.Ai to AI tools used for development
|
|
||||||
- Prefix OS version badge with 'v' (v1.0.5)
|
|
||||||
- Match OS version badge styling to service modal version badges
|
|
||||||
- Place OS version badge inline right after the Hub title
|
|
||||||
- Center OS version badge under the Hub title
|
|
||||||
- Strengthen entropy section: add DYOR emphasis and hardware wallet verification note
|
|
||||||
- Add 'About Bitcoin wallet entropy' section to README
|
|
||||||
- README: balance intro between Bitcoin sovereignty and sovereign computing
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Removed temp patch file
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Harden error handling and sanitize exception details across security endpoints (CWE-209)
|
|
||||||
- Sanitize exception handling in verify-integrity and security-reset (CWE-209)
|
|
||||||
- Sanitize api_security_reset errors to prevent exception information exposure (CWE-209)
|
|
||||||
- Use canonical prefix containment check for CodeQL path-injection
|
|
||||||
- Pass sanitized abs_path to os.chown to resolve CodeQL path injection at 4338
|
|
||||||
- Add CodeQL-recognized path sanitization for domain_name
|
|
||||||
- Remove domain substring check for CodeQL incomplete-url
|
|
||||||
- Use sentinel for njalla header check (CodeQL incomplete-url-substring)
|
|
||||||
- Prevent reflected XSS in lnurl-qr print endpoint
|
|
||||||
- Hash root password instead of storing in clear text (CWE-312)
|
|
||||||
- Separate web auth hash from system password file
|
|
||||||
- Fix Matrix Hub admin API credentials
|
|
||||||
- Fix CWE-78: replace subprocess call with Synapse Admin API in create-user endpoint
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- Add SECURITY.md detailing security policy and best practices
|
|
||||||
- Versioned CDN downloads + add CDN upload script
|
|
||||||
- Correct active development workflow
|
|
||||||
- Make repo references mirror-neutral for Gitea readers
|
|
||||||
- Clarify GitHub is the dev mirror of the Gitea stable repo
|
|
||||||
[1.0.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.6
|
|
||||||
|
|
||||||
|
|
||||||
## [1.0.5] - 2026-08-04
|
## [1.0.5] - 2026-08-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -123,6 +281,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Report the version of the configured BTCPay Server package in the Hub instead of the different package version from unstable nixpkgs.
|
||||||
- Installer VM compatibility: legacy BIOS VM boots now install with GRUB, UEFI VM installs avoid depending on NVRAM boot-entry writes, VM users get clearer disk/resource guidance, and the internet check falls back to HTTPS when ICMP is blocked.
|
- Installer VM compatibility: legacy BIOS VM boots now install with GRUB, UEFI VM installs avoid depending on NVRAM boot-entry writes, VM users get clearer disk/resource guidance, and the internet check falls back to HTTPS when ICMP is blocked.
|
||||||
- **Manual Backup now matches the system role**: on the Desktop Only role the Hub's
|
- **Manual Backup now matches the system role**: on the Desktop Only role the Hub's
|
||||||
"What gets backed up" list no longer shows Node / Server + Desktop items
|
"What gets backed up" list no longer shows Node / Server + Desktop items
|
||||||
@@ -203,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
|
||||||
|
|||||||
+8
-1
@@ -30,7 +30,7 @@ The workflow is:
|
|||||||
Please note: GitHub `main` and Gitea `staging-dev` may contain new features and
|
Please note: GitHub `main` and Gitea `staging-dev` may contain new features and
|
||||||
code not yet in `stable`, and that code may not be fully tested.
|
code not yet in `stable`, and that code may not be fully tested.
|
||||||
|
|
||||||
Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used Copilot and Arean.Ai to work through significant coding challenges and troubleshooting hurdles. We will continue to use AI to help keep Sovran_SystemsOS stable and maintained.
|
Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used Copilot and Arena.ai to work through significant coding challenges and troubleshooting hurdles. We will continue to use AI to help keep Sovran_SystemsOS stable and maintained.
|
||||||
|
|
||||||
## How Can I Contribute?
|
## How Can I Contribute?
|
||||||
|
|
||||||
@@ -92,6 +92,13 @@ git push origin feature/your-feature-name
|
|||||||
- **Do not push directly to `main`.** Always use a feature branch and open a PR.
|
- **Do not push directly to `main`.** Always use a feature branch and open a PR.
|
||||||
- **Be patient.** PRs will be reviewed as soon as possible.
|
- **Be patient.** PRs will be reviewed as soon as possible.
|
||||||
|
|
||||||
|
## Security-Sensitive Changes
|
||||||
|
|
||||||
|
- Report vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md).
|
||||||
|
- Pin upstream revisions and hashes; do not use floating source references.
|
||||||
|
- Update [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) when importing code.
|
||||||
|
- Add regression tests and describe any threat-model change in the pull request.
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
- Follow the existing patterns in the codebase
|
- Follow the existing patterns in the codebase
|
||||||
|
|||||||
@@ -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,14 +510,15 @@ setup, supported hardware, and Royal Membership.
|
|||||||
|
|
||||||
## For developers
|
## For developers
|
||||||
|
|
||||||
Sovran_SystemsOS combines the reproducibility of [NixOS](https://nixos.org),
|
Sovran_SystemsOS combines [NixOS](https://nixos.org), the standalone
|
||||||
the Bitcoin service modules of
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) flake for
|
||||||
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), the desktop Bitcoin
|
Bitcoin and Lightning, desktop packages from
|
||||||
packages provided by
|
|
||||||
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
||||||
Sovran Hub into a complete Bitcoin operating system. The operating system
|
Sovran Hub. The Bitcoin and Lightning modules live in the Sovran_Bitcoin
|
||||||
configuration, installer, Hub, desktop integration, Bitcoin services, and
|
repository and are consumed as a flake input. OS-specific Bitcoin customizations
|
||||||
optional self-hosting services are all maintained in this repository.
|
(Second_Drive paths, operator user, Hub integration) are bridged by
|
||||||
|
`modules/sovran-bitcoin-integration.nix`. Legacy `nix-bitcoin.*` option names
|
||||||
|
and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||||
|
|
||||||
### Development workflow
|
### Development workflow
|
||||||
|
|
||||||
@@ -533,9 +537,10 @@ optional self-hosting services are all maintained in this repository.
|
|||||||
### Technology
|
### Technology
|
||||||
|
|
||||||
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
||||||
for reproducible system configuration
|
for declarative, pinned system configuration
|
||||||
- [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) for declarative
|
- [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) for the
|
||||||
Bitcoin and Lightning services
|
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 +630,13 @@ sudo nixos-rebuild switch --rollback
|
|||||||
| `flake.nix` | Declares flake inputs, the running system, and installer outputs |
|
| `flake.nix` | Declares flake inputs, the running system, and installer outputs |
|
||||||
| `flake.lock` | Pins dependencies for reproducible builds |
|
| `flake.lock` | Pins dependencies for reproducible builds |
|
||||||
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
||||||
| `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features |
|
| `modules/` | Core modules, self-hosted services, and optional features |
|
||||||
|
| `modules/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/` | Custom package sources and patches (for example, Alby Hub) |
|
| `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 |
|
||||||
|
|
||||||
@@ -685,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.
|
||||||
@@ -704,65 +707,35 @@ production environment.
|
|||||||
|
|
||||||
## About Bitcoin wallet entropy
|
## About Bitcoin wallet entropy
|
||||||
|
|
||||||
When you create a Bitcoin wallet in Sparrow Wallet, you generate a **24-word
|
Wallet recovery words control the funds. Never share them with a website,
|
||||||
seed phrase**. You should also apply a **128-bit BIP39 passphrase**. These are
|
support technician, cloud service, or chat application.
|
||||||
**two independent entropy sources** — both are required to recover your wallet,
|
|
||||||
and neither one alone is sufficient.
|
|
||||||
|
|
||||||
**Create each entropy source on different hardware.**
|
For meaningful balances, prefer a well-reviewed hardware signer and follow its
|
||||||
|
verified backup process. A BIP39 passphrase is optional, advanced protection;
|
||||||
|
it is not a replacement for the recovery words. If you use one, back it up
|
||||||
|
separately—losing either item can make the wallet unrecoverable.
|
||||||
|
|
||||||
The seed phrase is generated by Sparrow Wallet on your Sovran_SystemsOS machine.
|
Keep durable offline backups in separate secure locations. Test recovery before
|
||||||
The 128-bit passphrase should be generated on a separate device — for example:
|
relying on a wallet, and begin with a small amount.
|
||||||
|
|
||||||
- A **GrapheneOS** phone using Bitwarden's passphrase generator
|
|
||||||
- A different computer running the **Diceware** program
|
|
||||||
|
|
||||||
This way, no single device ever produces or holds both sources of randomness.
|
|
||||||
If one device is ever compromised, the other entropy source remains unknown to
|
|
||||||
an attacker. Your wallet cannot be recovered without **both**.
|
|
||||||
|
|
||||||
This is not a recommendation against hardware wallets, dice, or any other
|
|
||||||
well-tested method of creating entropy. Those are established and valuable
|
|
||||||
approaches. If you use a hardware wallet, verify for yourself that its entropy
|
|
||||||
generation — both the software and the hardware — is sound and follows best
|
|
||||||
practices.
|
|
||||||
|
|
||||||
The takeaway is simply this: **how your entropy is created matters, and
|
|
||||||
generating your two entropy sources on two physically separate devices is a
|
|
||||||
strong practice to understand before setting up a Bitcoin wallet** with the
|
|
||||||
software included in Sovran_SystemsOS.
|
|
||||||
|
|
||||||
**You must do your own research.** The information above is basic educational
|
|
||||||
context to help you build your own best practices — not financial advice and not
|
|
||||||
a substitute for your own due diligence.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security approach
|
## Security approach
|
||||||
|
|
||||||
Sovran_SystemsOS is designed around local ownership and explicit control.
|
Sovran_SystemsOS uses layered controls:
|
||||||
|
|
||||||
Its security foundations include:
|
- Pinned flake inputs and hash-pinned source archives
|
||||||
|
- Bitcoin and Lightning modules maintained in this repository
|
||||||
|
- Firewall enabled; public SSH and remote desktop disabled by default
|
||||||
|
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
||||||
|
- Tor enforcement for supported Bitcoin services
|
||||||
|
- Restricted, time-limited support access with scoped `sudo`
|
||||||
|
- Operator-controlled public service exposure
|
||||||
|
|
||||||
- Reproducible builds from pinned flake inputs
|
See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
|
||||||
- Firewall enabled by default
|
and operator guidance. No operating system can protect funds after recovery
|
||||||
- Public SSH disabled by default
|
words, administrator credentials, or the root account are compromised. Apply
|
||||||
- Remote desktop disabled by default
|
updates and keep tested offline backups.
|
||||||
- Hub authentication
|
|
||||||
- Local-network Hub access through `sovransystemsos.local`
|
|
||||||
- Tor integration for the Bitcoin stack
|
|
||||||
- User-controlled service exposure
|
|
||||||
- Declarative system configuration
|
|
||||||
- Restricted technical-support access
|
|
||||||
- Auditable open-source code
|
|
||||||
|
|
||||||
No operating system can guarantee complete security. Users should still apply
|
|
||||||
updates, protect credentials, maintain backups, secure their local network,
|
|
||||||
and review any services they choose to expose publicly.
|
|
||||||
|
|
||||||
Bitcoin users must also securely back up wallet seed phrases, descriptors,
|
|
||||||
channel backups, and other recovery information. Never store your only wallet
|
|
||||||
backup on the same computer that holds the wallet.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -780,11 +753,15 @@ Sovran_SystemsOS would not have the same reliability, transparency, or reproduci
|
|||||||
|
|
||||||
### nix-bitcoin
|
### nix-bitcoin
|
||||||
|
|
||||||
Special thanks go to the [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) project and its [contributors](https://github.com/fort-nix/nix-bitcoin/graphs/contributors).
|
The in-repository Bitcoin stack began with code adapted from
|
||||||
|
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), primarily from commit
|
||||||
|
[`360e30f`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||||
|
It has since been narrowed to Sovran's supported services and is maintained in
|
||||||
|
this repository. nix-bitcoin is no longer a flake input or build dependency.
|
||||||
|
|
||||||
nix-bitcoin provides the declarative foundation for building and operating Bitcoin and Lightning services on NixOS. Its work makes it possible to configure complex Bitcoin infrastructure—including nodes, Electrs, Lightning, Tor integration, and related services—in a reproducible and auditable way.
|
We remain grateful to the nix-bitcoin contributors for the declarative and
|
||||||
|
security-focused foundation. Its MIT notice is retained in
|
||||||
Sovran_SystemsOS builds upon that foundation to make this infrastructure approachable through an integrated desktop, installer, and Sovran Hub.
|
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||||
|
|
||||||
### Emmanuel Rosa and btc-clients-nix
|
### Emmanuel Rosa and btc-clients-nix
|
||||||
|
|
||||||
@@ -802,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)
|
||||||
@@ -859,9 +835,10 @@ license.
|
|||||||
> Individual upstream applications, packages, artwork, fonts, and other
|
> Individual upstream applications, packages, artwork, fonts, and other
|
||||||
> components included with or built by Sovran_SystemsOS may have their own
|
> components included with or built by Sovran_SystemsOS may have their own
|
||||||
> licenses and copyright holders. The AGPL-3.0 license for this repository does
|
> licenses and copyright holders. The AGPL-3.0 license for this repository does
|
||||||
> not replace the licenses of independent upstream projects.
|
> not replace those licenses.
|
||||||
|
|
||||||
Read the complete license terms in [`LICENSE`](LICENSE).
|
Read [`LICENSE`](LICENSE) and
|
||||||
|
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -879,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>
|
||||||
|
|||||||
+74
-75
@@ -1,104 +1,103 @@
|
|||||||
# Security Policy (SECURITY.md)
|
# Security Policy
|
||||||
|
|
||||||
This document outlines the security policy, vulnerability disclosure process, threat model, and security best practices for **Sovran_SystemsOS**.
|
## Supported versions
|
||||||
|
|
||||||
---
|
| Release | Supported |
|
||||||
|
|---|:---:|
|
||||||
|
| Latest `1.0.x` stable release | Yes |
|
||||||
|
| `main` / `staging-dev` | Development only |
|
||||||
|
| Older than `1.0.0` | No |
|
||||||
|
|
||||||
## 1. Supported Versions
|
Install the newest stable point release to receive security fixes.
|
||||||
|
|
||||||
We actively monitor and patch security vulnerabilities in Sovran_SystemsOS. Because the operating system is built on [NixOS](https://nixos.org), stable releases receive security updates backported from upstream Nixpkgs as well as our custom software updates.
|
## Report a vulnerability
|
||||||
|
|
||||||
| Version | Supported | Notes |
|
**Do not open a public issue or pull request.** Report privately through:
|
||||||
|:---|:---:|:---|
|
|
||||||
| **1.0.x** (Stable) | Yes | Active stable release line. Patches are backported regularly. |
|
|
||||||
| **staging-dev** | Yes | Development line; updated frequently but may contain untested code. |
|
|
||||||
| **Legacy (< 1.0.0)** | No | Please upgrade to the latest stable release to ensure you have active security updates. |
|
|
||||||
|
|
||||||
---
|
- [GitHub Private Vulnerability Reporting](https://github.com/naturallaw777/Sovran_SystemsOS/security/advisories/new)
|
||||||
|
- Email: [support@sovransystems.com](mailto:support@sovransystems.com)
|
||||||
|
|
||||||
## 2. Reporting a Vulnerability
|
Include the affected version, impact, reproduction steps, and a minimal proof of
|
||||||
|
concept. Never send wallet recovery words, private keys, or live credentials.
|
||||||
|
|
||||||
We take the security of self-custody systems, private clouds, and communications servers extremely seriously. If you discover a vulnerability, **please do not open a public issue, forum thread, or submit a public pull request.**
|
We aim to acknowledge reports within two business days. Please allow reasonable
|
||||||
|
time for a fix and coordinated disclosure.
|
||||||
|
|
||||||
Instead, report vulnerabilities privately through one of the following secure channels:
|
## Security model
|
||||||
|
|
||||||
1. **Email:** Send an encrypted or plain-text email to [support@sovransystems.com](mailto:support@sovransystems.com).
|
### Local-first operation
|
||||||
- If sending sensitive information, please contact us first to establish a secure PGP-encrypted communication channel.
|
|
||||||
2. **GitHub Private Vulnerability Reporting:** If you are viewing this on GitHub, you can report vulnerabilities privately via the **Security** tab of this repository under **Vulnerability reporting**.
|
|
||||||
|
|
||||||
### What to Include in a Report:
|
The Hub and core data run on operator-owned hardware. The Hub is intended for a
|
||||||
To help us triage and resolve the issue quickly, please include:
|
trusted local network and must not be port-forwarded to the internet. Public
|
||||||
- A clear description of the vulnerability and its potential impact.
|
services, DDNS, software updates, and optional third-party relays require
|
||||||
- Step-by-step instructions to reproduce the issue (including any scripts or configuration snippets).
|
external networks and are outside a “fully offline” model.
|
||||||
- The version of Sovran_SystemsOS you tested (found in the Hub header or `/etc/nixos/VERSION`).
|
|
||||||
- Any potential remediation steps or suggestions you may have.
|
|
||||||
|
|
||||||
### Our Response and Disclosure Process:
|
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
||||||
1. **Acknowledgment:** We will acknowledge receipt of your report within **24–48 hours** and provide a tracking reference.
|
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
||||||
2. **Triage:** We will investigate and verify the vulnerability privately. We may reach out to you for clarifying details.
|
|
||||||
3. **Remediation:** If verified, we will develop a patch on a private security branch.
|
|
||||||
4. **Release:** We will coordinate a release date with you and publish the patch to Gitea `stable` and GitHub `main`.
|
|
||||||
5. **Advisory:** A public security advisory will be published, giving full credit to you for the discovery (unless you request anonymity).
|
|
||||||
|
|
||||||
We ask that you practice **coordinated vulnerability disclosure**, giving us reasonable time to patch the vulnerability before disclosing it publicly to protect other operators' funds and data.
|
### Bitcoin stack
|
||||||
|
|
||||||
---
|
Bitcoin and Lightning modules are maintained in the standalone
|
||||||
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||||
|
and consumed as a flake input. OS-specific customizations (Second_Drive paths,
|
||||||
|
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.
|
||||||
|
|
||||||
## 3. The Sovran_SystemsOS Security Architecture
|
### Supply chain and integrity
|
||||||
|
|
||||||
Understanding our underlying threat model and architectural decisions will help you evaluate the system's security.
|
`flake.lock` pins flake inputs, and fetched source archives use fixed hashes.
|
||||||
|
Builds still depend on pinned Nixpkgs, NixVim, btc-clients-nix, upstream source
|
||||||
|
archives, and any configured binary cache. Keeping the Bitcoin modules in this
|
||||||
|
repository reduces an external dependency; it does not remove supply-chain
|
||||||
|
risk.
|
||||||
|
|
||||||
### A. Local-First Threat Model
|
The Hub integrity check verifies Nix store contents and compares the running
|
||||||
- **No Cloud Dependency:** Your data, keys, and credentials live entirely on your physical hardware.
|
system with a build from local `/etc/nixos`. It does not authenticate the release
|
||||||
- **Local network Hub Access:** The Sovran Hub is served directly on your local network (`sovransystemsos.local`). It is protected by authentication and **never** automatically exposed to the public internet.
|
publisher or protect against an attacker who already controls root and can
|
||||||
- **Localhost Auto-Login:** Auto-login to the Hub is restricted strictly to local connections from `127.0.0.1` or `::1` (e.g., when accessing it directly from the local GNOME desktop). LAN and WAN clients must authenticate via password.
|
change both the system and local configuration.
|
||||||
|
|
||||||
### B. Declarative & Reproducible NixOS Foundation
|
### Access and service isolation
|
||||||
- **No Supply Chain Drift:** Dependencies, packages, and system components are strictly pinned using Nix flakes (`flake.lock`).
|
|
||||||
- **Reproducible Integrity Checks:** Operators can verify system integrity at any time via the Hub (`/api/security/verify-integrity`), which runs `nix store verify --all` to check binary consistency and rebuilds the system state from local files to guarantee it matches the expected configuration.
|
|
||||||
- **Default-Hardened Base:** Firewalls are enabled by default (`networking.firewall.enable = true`), public SSH and remote desktop are disabled by default, and legacy kernel modules like `rxrpc` are blacklisted to reduce the kernel's attack surface.
|
|
||||||
|
|
||||||
### C. Restricted Tech Support ("Zero-Trust Support")
|
- Firewall enabled by default
|
||||||
To prevent support staff or malicious actors from accessing your private wallets, the operating system employs a unique **"Zero-Trust Support"** architecture:
|
- Public SSH and remote desktop disabled by default
|
||||||
- **Restricted System Account:** Support sessions use a restricted, non-root account (`sovran-support`) with heavily scoped shell access and limited `sudo` privileges (restricted strictly to viewing logs, editing configuration, and rebuilding or restarting services).
|
- Separate service users and systemd sandboxing where supported
|
||||||
- **POSIX ACL Wallet Lockout:** Enabling support immediately triggers POSIX ACLs (`setfacl -m u:sovran-support:---`) to block the support user from reading, writing, or traversing sensitive directories (such as `/etc/nix-bitcoin-secrets`, `/var/lib/bitcoind`, `/var/lib/lnd`, and `/home`).
|
- Administrative service ports bound to loopback where practical
|
||||||
- **User-Controlled Timed Unlock:** Support staff can *only* access wallet files if the operator explicitly grants a time-limited unlock from the Hub interface. This unlock automatically expires, re-locking the directories.
|
- Tor enforced for supported Bitcoin traffic and onion services
|
||||||
- **Tamper-Evident Auditing:** All support actions (enabling, disabling, wallet unlocking, or locking) are logged in a tamper-evident audit trail (`/var/log/sovran-support-audit.log`) readable directly by the operator.
|
- Public web services exposed only when enabled by the operator
|
||||||
|
|
||||||
### D. Native Cryptographic Isolation
|
Tor reduces network exposure for configured Bitcoin services. It is not a
|
||||||
- **Tor Network Integration:** Upstream Bitcoin services (Knots/Core, Electrs, LND) utilize native Tor integration, preventing your home IP address from leaking to the public network or third-party node providers.
|
guarantee against every IP leak, application bug, or traffic-analysis attack.
|
||||||
- **Isolated Lightning Wallets:** Nostr Wallet Connect (NWC) uses isolated, sandboxed Lightning wallets with specific spending limits and access presets (e.g., receive-only), keeping your main node funds completely isolated from individual apps.
|
|
||||||
|
|
||||||
---
|
### Restricted support access
|
||||||
|
|
||||||
## 4. Security Best Practices for Operators (Users)
|
Support uses a per-session SSH key on the non-root `sovran-support` account.
|
||||||
|
Sessions expire after 24 hours and have a small allowlist of `sudo` commands.
|
||||||
|
Wallet paths receive deny ACLs unless the operator explicitly removes them.
|
||||||
|
Disabling support removes the key and reapplies the ACLs.
|
||||||
|
|
||||||
No software can provide absolute security. You are the ultimate sovereign of your machine. Follow these best practices to keep your system and assets secure:
|
Support events are written to `/var/log/sovran-support-audit.log`. This is a
|
||||||
|
local audit log, not a cryptographically tamper-evident record.
|
||||||
|
|
||||||
### 🔑 1. Split Wallet Entropy
|
## Out of scope
|
||||||
When setting up a Bitcoin wallet, **generate your two entropy sources on separate, unconnected hardware**:
|
|
||||||
- **Seed Phrase:** Generated by Sparrow Wallet on your Sovran_SystemsOS machine.
|
|
||||||
- **BIP-39 Passphrase (128-bit):** Generated on a separate device (e.g., a GrapheneOS phone using a trusted password generator or offline Diceware dice).
|
|
||||||
- *Why?* If either device is compromised, your wallet remains secure because an attacker needs **both** independent sources of randomness to recover it.
|
|
||||||
|
|
||||||
### 💾 2. Implement a Robust Backup Strategy
|
Sovran_SystemsOS cannot protect against:
|
||||||
- **Off-Machine Backups:** Never store your only wallet backups, seed phrases, or descriptor backups on the same computer as your running wallet.
|
|
||||||
- **Descriptor & Channel Backups:** If using Lightning (LND), regularly back up your Static Channel Backups (SCB) and store them off-site. A node hardware failure can permanently lock up channel funds without a fresh SCB.
|
|
||||||
- **Ext4 Backups:** If using the Hub's Manual Backup utility, ensure you are backing up to a dedicated **ext4-formatted** external drive. This preserves Linux-native security metadata, permissions, and POSIX ACLs.
|
|
||||||
|
|
||||||
### 🛡️ 3. Maintain Network and Access Hygiene
|
- Compromised root or administrator credentials
|
||||||
- **Public SSH Off:** Keep SSH remote access disabled in the Hub unless you actively need it. If enabled, Fail2Ban is active, but you should still enforce a strong system password.
|
- Stolen recovery words, private keys, or backups
|
||||||
- **Strong Diceware Passwords:** Ensure the system `free` and `root` users have strong passphrases. Avoid re-using passwords across services.
|
- Malicious or compromised hardware, firmware, or build infrastructure
|
||||||
- **Guest Networks:** Do not access the Sovran Hub over public or guest Wi-Fi networks. These networks often lack isolation, exposing local network traffic to other devices.
|
- Services the operator deliberately exposes or weakens
|
||||||
- **Disable RDP When Done:** Turn off Remote Desktop (RDP) once your administrative session is finished.
|
- Physical access without appropriate disk and firmware protections
|
||||||
|
|
||||||
### 🧑💻 4. Hardening Support Sessions
|
## Operator basics
|
||||||
If you require technical support from Sovran Systems:
|
|
||||||
- **Enable SSH and Support on-demand:** Only enable SSH Remote Access and Support sessions when actively working with a technician, and **disable** them immediately when the session is over.
|
|
||||||
- **Confirm Removal:** The Hub's support page includes a "Verify Removal" feature. Always use it to ensure public keys have been fully stripped from the system.
|
|
||||||
- **Restrict Wallet Access:** Only unlock your wallet directories if absolutely necessary for the support session, and choose the shortest possible duration. Re-lock manually as soon as the troubleshooting steps are complete.
|
|
||||||
- **Review Audit Logs:** Periodically inspect the support audit log via the Hub interface to review exactly when support sessions were toggled or unlocked.
|
|
||||||
|
|
||||||
---
|
- Verify downloads and stop if the checksum does not match.
|
||||||
|
- Apply stable security updates promptly.
|
||||||
|
- Use unique passwords and keep SSH/RDP off when not needed.
|
||||||
|
- Prefer a well-reviewed hardware signer for meaningful Bitcoin balances.
|
||||||
|
- Keep tested, offline backups in separate secure locations.
|
||||||
|
- Never share recovery words or private keys with support.
|
||||||
|
- Disable support access when the session ends and review the audit log.
|
||||||
|
|
||||||
Thank you for helping us maintain a highly secure, private, and sovereign computing operating system!
|
No software can provide absolute security. Review your configuration and threat
|
||||||
|
model before storing important funds or data.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Third-Party Notices
|
||||||
|
|
||||||
|
## nix-bitcoin
|
||||||
|
|
||||||
|
The Bitcoin and Lightning modules were originally adapted from
|
||||||
|
[fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
||||||
|
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||||
|
They now live in the standalone
|
||||||
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||||
|
and are consumed by Sovran_SystemsOS as a flake input. The current Sovran build
|
||||||
|
does not import or fetch nix-bitcoin.
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2019 nix-bitcoin developers
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
Other packaged applications retain their own upstream licenses and copyright
|
||||||
|
notices.
|
||||||
@@ -1,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' \
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""Sovran Hub — pure security validation helpers.
|
||||||
|
|
||||||
|
This module contains the dependency-light security helper functions used by
|
||||||
|
the Hub server. Keeping them here allows tests to import and exercise the
|
||||||
|
exact production implementations rather than maintaining separate copies.
|
||||||
|
|
||||||
|
All functions in this module depend only on the Python standard library.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
# ── Nix string escaping ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _nix_escape(value: str) -> str:
|
||||||
|
"""Escape *value* for use inside a Nix double-quoted string literal.
|
||||||
|
|
||||||
|
Handles backslashes, double-quotes, newlines, carriage returns, tabs, and
|
||||||
|
Nix-specific anti-quotation sequences (``${...}``). The returned value is
|
||||||
|
safe to embed as ``"<returned_value>"`` in generated Nix source.
|
||||||
|
"""
|
||||||
|
value = value.replace("\\", "\\\\")
|
||||||
|
value = value.replace('"', '\\"')
|
||||||
|
value = value.replace("\n", "\\n")
|
||||||
|
value = value.replace("\r", "\\r")
|
||||||
|
value = value.replace("\t", "\\t")
|
||||||
|
value = value.replace("${", "\\${")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# ── Nostr npub validation (NIP-19 / Bech32) ───────────────────────────────────
|
||||||
|
|
||||||
|
# Fast pre-filter: "npub1" followed by exactly 58 lower-case bech32 characters.
|
||||||
|
NPUB_RE = re.compile(r"^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
|
||||||
|
|
||||||
|
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
_BECH32_GENERATOR = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
|
||||||
|
|
||||||
|
|
||||||
|
def _bech32_polymod(values: list[int]) -> int:
|
||||||
|
chk = 1
|
||||||
|
for value in values:
|
||||||
|
top = chk >> 25
|
||||||
|
chk = ((chk & 0x1FFFFFF) << 5) ^ value
|
||||||
|
for i in range(5):
|
||||||
|
if (top >> i) & 1:
|
||||||
|
chk ^= _BECH32_GENERATOR[i]
|
||||||
|
return chk
|
||||||
|
|
||||||
|
|
||||||
|
def _bech32_hrp_expand(hrp: str) -> list[int]:
|
||||||
|
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
||||||
|
|
||||||
|
|
||||||
|
def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]:
|
||||||
|
values = _bech32_hrp_expand(hrp) + data
|
||||||
|
polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
|
||||||
|
return [(polymod >> (5 * (5 - i))) & 31 for i in range(6)]
|
||||||
|
|
||||||
|
|
||||||
|
def _bech32_convertbits_decode(data: list[int]) -> list[int] | None:
|
||||||
|
"""Convert a 5-bit integer sequence to 8-bit bytes, stripping padding."""
|
||||||
|
acc = 0
|
||||||
|
bits = 0
|
||||||
|
ret: list[int] = []
|
||||||
|
for value in data:
|
||||||
|
acc = (acc << 5) | value
|
||||||
|
bits += 5
|
||||||
|
while bits >= 8:
|
||||||
|
bits -= 8
|
||||||
|
ret.append((acc >> bits) & 0xFF)
|
||||||
|
if bits >= 5 or ((acc << (8 - bits)) & 0xFF):
|
||||||
|
return None # invalid padding
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def _bech32_decode(bech: str) -> tuple[str, bytes] | None:
|
||||||
|
"""Decode a bech32 string. Returns ``(hrp, payload_bytes)`` or ``None``.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Lowercase-only (mixed case rejected per BIP-173).
|
||||||
|
- Only valid bech32 charset characters.
|
||||||
|
- Valid checksum.
|
||||||
|
- Exactly one separator (``1``).
|
||||||
|
- Minimum data part length (≥ 8 chars = 6 checksum + ≥ 2 data).
|
||||||
|
"""
|
||||||
|
if bech != bech.lower():
|
||||||
|
return None # mixed case
|
||||||
|
sep = bech.rfind("1")
|
||||||
|
if sep < 1 or sep + 7 > len(bech):
|
||||||
|
return None
|
||||||
|
hrp = bech[:sep]
|
||||||
|
data_part = bech[sep + 1:]
|
||||||
|
if any(c not in _BECH32_CHARSET for c in data_part):
|
||||||
|
return None
|
||||||
|
decoded = [_BECH32_CHARSET.index(c) for c in data_part]
|
||||||
|
if _bech32_polymod(_bech32_hrp_expand(hrp) + decoded) != 1:
|
||||||
|
return None # bad checksum
|
||||||
|
converted = _bech32_convertbits_decode(decoded[:-6])
|
||||||
|
if converted is None:
|
||||||
|
return None
|
||||||
|
return hrp, bytes(converted)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_npub(value: str) -> bool:
|
||||||
|
"""Return ``True`` iff *value* is a valid NIP-19 Nostr npub.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- Lowercase ``npub`` HRP.
|
||||||
|
- Valid bech32 charset (no uppercase, no invalid chars).
|
||||||
|
- Valid bech32 checksum.
|
||||||
|
- Exactly 32 decoded payload bytes (256-bit public key).
|
||||||
|
- Retains the original regex as a fast pre-filter.
|
||||||
|
"""
|
||||||
|
if not NPUB_RE.fullmatch(value):
|
||||||
|
return False
|
||||||
|
result = _bech32_decode(value)
|
||||||
|
if result is None:
|
||||||
|
return False
|
||||||
|
hrp, payload = result
|
||||||
|
return hrp == "npub" and len(payload) == 32
|
||||||
|
|
||||||
|
|
||||||
|
# ── DDNS URL validation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_DDNS_URL_MAX_LEN = 2048
|
||||||
|
_DDNS_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||||
|
|
||||||
|
# Allowlist: only the official Njal.la provider hostnames are accepted for
|
||||||
|
# DDNS update URLs. Any other host would allow SSRF against the Hub's
|
||||||
|
# internal network.
|
||||||
|
_DDNS_ALLOWED_HOSTNAMES: frozenset[str] = frozenset(["njal.la", "www.njal.la"])
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ddns_url(url: str) -> str:
|
||||||
|
"""Validate *url* as a safe DDNS update URL and return it normalised.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Must be a valid URL parseable by urllib.parse.
|
||||||
|
- Scheme must be ``https`` (case-insensitive).
|
||||||
|
- No userinfo (credentials must not be embedded in the URL).
|
||||||
|
- No fragment.
|
||||||
|
- No control characters.
|
||||||
|
- Must not exceed ``_DDNS_URL_MAX_LEN`` bytes.
|
||||||
|
- Hostname must be the exact Njal.la provider hostname (njal.la or www.njal.la).
|
||||||
|
- Port must be absent or the default HTTPS port 443.
|
||||||
|
- No percent-encoded null bytes.
|
||||||
|
|
||||||
|
Raises ``ValueError`` with a safe (non-secret) message on failure.
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
raise ValueError("DDNS URL must not be empty")
|
||||||
|
if len(url) > _DDNS_URL_MAX_LEN:
|
||||||
|
raise ValueError("DDNS URL exceeds maximum length")
|
||||||
|
if _DDNS_CONTROL_RE.search(url):
|
||||||
|
raise ValueError("DDNS URL contains control characters")
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(url)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("DDNS URL could not be parsed")
|
||||||
|
if parsed.scheme.lower() != "https":
|
||||||
|
raise ValueError("DDNS URL must use the https scheme")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise ValueError("DDNS URL must not contain credentials")
|
||||||
|
if parsed.fragment:
|
||||||
|
raise ValueError("DDNS URL must not contain a fragment")
|
||||||
|
if parsed.port is not None and parsed.port != 443:
|
||||||
|
raise ValueError("DDNS URL must use the default HTTPS port")
|
||||||
|
hostname = parsed.hostname or ""
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError("DDNS URL must contain a hostname")
|
||||||
|
# Reject raw IP addresses
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(hostname)
|
||||||
|
raise ValueError("DDNS URL hostname must not be a raw IP address")
|
||||||
|
except ValueError as exc:
|
||||||
|
if "raw IP" in str(exc):
|
||||||
|
raise
|
||||||
|
# Allowlist: only Njal.la
|
||||||
|
if hostname.lower() not in _DDNS_ALLOWED_HOSTNAMES:
|
||||||
|
raise ValueError(
|
||||||
|
f"DDNS URL hostname is not an allowed Njal.la host "
|
||||||
|
f"(got {hostname!r})"
|
||||||
|
)
|
||||||
|
if "%00" in url.lower():
|
||||||
|
raise ValueError("DDNS URL must not contain encoded null bytes")
|
||||||
|
# Reject any remaining $ expressions — after ${IP} substitution there
|
||||||
|
# must be none. Callers that store ${IP} placeholder URLs must substitute
|
||||||
|
# before calling this function.
|
||||||
|
if "$" in url:
|
||||||
|
raise ValueError("DDNS URL must not contain $ expressions")
|
||||||
|
# Require the exact /update/ path used by Njal.la
|
||||||
|
if parsed.path != "/update/":
|
||||||
|
raise ValueError("DDNS URL path must be exactly /update/")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
# ── SSH public-key validation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SSH_PUBKEY_ALGORITHMS = frozenset([
|
||||||
|
"ssh-ed25519",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
"sk-ssh-ed25519@openssh.com",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ssh_pubkey(key: str) -> str:
|
||||||
|
"""Validate *key* as a single OpenSSH public key and return it normalised.
|
||||||
|
|
||||||
|
Accepts only single-line keys with a supported algorithm, valid base64
|
||||||
|
payload, and an optional comment. Rejects options, multiple lines,
|
||||||
|
control characters, and unsupported algorithms.
|
||||||
|
|
||||||
|
Raises ``ValueError`` with a safe message on failure.
|
||||||
|
"""
|
||||||
|
key = key.strip()
|
||||||
|
if not key:
|
||||||
|
raise ValueError("SSH public key must not be empty")
|
||||||
|
if _DDNS_CONTROL_RE.search(key):
|
||||||
|
raise ValueError("SSH public key contains control characters")
|
||||||
|
if "\n" in key or "\r" in key:
|
||||||
|
raise ValueError("SSH public key must be a single line")
|
||||||
|
parts = key.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
raise ValueError("SSH public key is malformed")
|
||||||
|
algo, b64 = parts[0], parts[1]
|
||||||
|
if algo not in _SSH_PUBKEY_ALGORITHMS:
|
||||||
|
raise ValueError(f"Unsupported SSH key algorithm: {algo!r}")
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(b64, validate=True)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("SSH public key payload is not valid base64")
|
||||||
|
if len(decoded) < 20:
|
||||||
|
raise ValueError("SSH public key payload is too short")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
# ── Persistent Hub session store ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_session_store(path: str) -> dict[str, float]:
|
||||||
|
"""Load persisted Hub sessions from *path*.
|
||||||
|
|
||||||
|
Returns a mapping of session token → expiry timestamp (epoch seconds).
|
||||||
|
Expired entries are discarded. A missing, unreadable or malformed file
|
||||||
|
yields an empty mapping — losing sessions is a UX inconvenience (the user
|
||||||
|
must log in again), never a fatal error.
|
||||||
|
|
||||||
|
Persistence exists so that authenticated sessions survive a restart of
|
||||||
|
the Hub service itself. ``nixos-rebuild switch`` restarts
|
||||||
|
``sovran-hub-web.service`` during activation (its unit definition changes
|
||||||
|
with every feature toggle), and without persistence the in-progress
|
||||||
|
rebuild/update status polling loses authentication and the UI hangs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
now = time.time()
|
||||||
|
sessions: dict[str, float] = {}
|
||||||
|
for token, expiry in data.items():
|
||||||
|
if not isinstance(token, str) or not token:
|
||||||
|
continue
|
||||||
|
if isinstance(expiry, bool) or not isinstance(expiry, (int, float)):
|
||||||
|
continue
|
||||||
|
if expiry > now:
|
||||||
|
sessions[token] = float(expiry)
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
|
def save_session_store(path: str, sessions: dict[str, float]) -> bool:
|
||||||
|
"""Atomically persist *sessions* (token → expiry) to *path* with mode 0600.
|
||||||
|
|
||||||
|
Writes to a temp file in the same directory and renames it into place so
|
||||||
|
the store is never left partially written. Returns True on success,
|
||||||
|
False otherwise (persistence is best-effort).
|
||||||
|
"""
|
||||||
|
directory = os.path.dirname(path) or "."
|
||||||
|
fd = None
|
||||||
|
tmp_path = None
|
||||||
|
try:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".hub_sessions_tmp")
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
fd = None # os.fdopen takes ownership of the descriptor
|
||||||
|
json.dump(sessions, f)
|
||||||
|
os.chmod(tmp_path, 0o600)
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
if fd is not None:
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if tmp_path is not None:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
+788
-452
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,28 @@
|
|||||||
/* ── Service tile card (status-only) ─────────────────────────────── */
|
/* ── Service tile card (status-only) ─────────────────────────────── */
|
||||||
|
|
||||||
|
.dashboard-loading {
|
||||||
|
min-height: 180px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-loading-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-top-color: var(--accent-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: dashboard-loading-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dashboard-loading-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.service-tile {
|
.service-tile {
|
||||||
width: 160px;
|
width: 160px;
|
||||||
min-height: 130px;
|
min-height: 130px;
|
||||||
@@ -155,68 +178,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── BIP-110 status badge (tile + detail modal) ───────────────────── */
|
|
||||||
|
|
||||||
.tile-bip110-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
font-size: 0.64rem;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
margin-top: 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-bip110-badge--active {
|
|
||||||
background: rgba(109, 191, 139, 0.18);
|
|
||||||
color: var(--green);
|
|
||||||
border: 1px solid rgba(109, 191, 139, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-bip110-badge--locked_in {
|
|
||||||
background: rgba(94, 173, 138, 0.15);
|
|
||||||
color: var(--accent-color);
|
|
||||||
border: 1px solid rgba(94, 173, 138, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-bip110-badge--signaling {
|
|
||||||
background: rgba(94, 173, 138, 0.12);
|
|
||||||
color: var(--accent-color);
|
|
||||||
border: 1px solid rgba(94, 173, 138, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-bip110-badge--not_signaling {
|
|
||||||
background: rgba(229, 165, 10, 0.12);
|
|
||||||
color: var(--yellow);
|
|
||||||
border: 1px solid rgba(229, 165, 10, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-bip110-badge--unsupported {
|
|
||||||
background: rgba(94, 122, 106, 0.12);
|
|
||||||
color: var(--grey);
|
|
||||||
border: 1px solid rgba(94, 122, 106, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile-bip110-badge--unknown {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-dim);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bip110-status-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bip110-source-label {
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Service detail modal sections ───────────────────────────────── */
|
/* ── Service detail modal sections ───────────────────────────────── */
|
||||||
|
|
||||||
@@ -404,6 +365,45 @@
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-card {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(94, 173, 138, 0.06);
|
||||||
|
border: 1px solid rgba(94, 173, 138, 0.24);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-list {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-list li {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-privacy {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-left: 3px solid var(--accent-color);
|
||||||
|
background: rgba(94, 173, 138, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-option-privacy strong {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.svc-detail-related-feature-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
.feature-conflict-warning {
|
.feature-conflict-warning {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
const POLL_INTERVAL_SERVICES = 5000;
|
const POLL_INTERVAL_SERVICES = 5000;
|
||||||
const POLL_INTERVAL_UPDATES = 1800000;
|
const POLL_INTERVAL_UPDATES = 1800000;
|
||||||
const UPDATE_POLL_INTERVAL = 2000;
|
const UPDATE_POLL_INTERVAL = 2000;
|
||||||
|
// A pending fetch never rejects by itself. Bound every status request so a
|
||||||
|
// wedged browser connection cannot leave the modal spinning forever.
|
||||||
|
const STATUS_POLL_FETCH_TIMEOUT = 15000;
|
||||||
|
// Eight timed-out requests plus the poll interval is a little over two minutes.
|
||||||
|
// A brief Hub restart or a heavily loaded Nix build remains well inside this.
|
||||||
|
const STATUS_POLL_MAX_FAILURES = 8;
|
||||||
|
// Keep verbose Nix output from making textContent updates quadratic and
|
||||||
|
// freezing the Hub renderer (especially noticeable over RDP).
|
||||||
|
const UPDATE_VISIBLE_LOG_MAX_CHARS = 250000;
|
||||||
|
const UPDATE_VISIBLE_LOG_TRIM_CHARS = 200000;
|
||||||
const REBOOT_CHECK_INTERVAL = 5000;
|
const REBOOT_CHECK_INTERVAL = 5000;
|
||||||
const REBOOT_FETCH_TIMEOUT = 12000;
|
const REBOOT_FETCH_TIMEOUT = 12000;
|
||||||
const REBOOT_REQUEST_TIMEOUT = 4000;
|
const REBOOT_REQUEST_TIMEOUT = 4000;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -187,30 +197,39 @@ function showSecurityBanner() {
|
|||||||
// ── Init ──────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
// Check onboarding status first — redirect to wizard if not complete
|
// These lightweight requests are independent. Running them together avoids
|
||||||
|
// making the dashboard wait through three serial network round-trips before
|
||||||
|
// it can even start loading the service tiles.
|
||||||
|
var onboardingStatus = null;
|
||||||
|
var bannerData = null;
|
||||||
|
var cfg;
|
||||||
try {
|
try {
|
||||||
var onboardingStatus = await apiFetch("/api/onboarding/status");
|
var startupResults = await Promise.all([
|
||||||
if (!onboardingStatus.complete) {
|
apiFetch("/api/onboarding/status").catch(function() { return null; }),
|
||||||
|
apiFetch("/api/security/banner-status").catch(function() { return null; }),
|
||||||
|
apiFetch("/api/config"),
|
||||||
|
]);
|
||||||
|
onboardingStatus = startupResults[0];
|
||||||
|
bannerData = startupResults[1];
|
||||||
|
cfg = startupResults[2];
|
||||||
|
} catch (_) {
|
||||||
|
// If config cannot be loaded, continue with the normal service fallback.
|
||||||
|
cfg = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onboardingStatus && !onboardingStatus.complete) {
|
||||||
window.location.href = "/onboarding";
|
window.location.href = "/onboarding";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
|
||||||
// If we can't reach the endpoint, continue to normal dashboard
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show first-login security banner only for machines that went through onboarding
|
// Show first-login security banner only for machines that went through onboarding
|
||||||
// (legacy machines without the onboarding flag will never see this)
|
// (legacy machines without the onboarding flag will never see this)
|
||||||
try {
|
|
||||||
var bannerData = await apiFetch("/api/security/banner-status");
|
|
||||||
if (bannerData && bannerData.show) {
|
if (bannerData && bannerData.show) {
|
||||||
showSecurityBanner();
|
showSecurityBanner();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
|
||||||
// Non-fatal — silently ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var cfg = await apiFetch("/api/config");
|
if (!cfg) throw new Error("Hub config unavailable");
|
||||||
_currentRole = cfg.role || "server_plus_desktop";
|
_currentRole = cfg.role || "server_plus_desktop";
|
||||||
if (cfg.category_order) {
|
if (cfg.category_order) {
|
||||||
for (var i = 0; i < cfg.category_order.length; i++) {
|
for (var i = 0; i < cfg.category_order.length; i++) {
|
||||||
@@ -239,6 +258,11 @@ async function init() {
|
|||||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||||
loadAutolaunchToggle();
|
loadAutolaunchToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the page was reloaded or the RDP/browser session resumed during an
|
||||||
|
// update, reopen the modal from the persisted backend state. This also
|
||||||
|
// surfaces a completed update that is waiting for its activation reboot.
|
||||||
|
await restoreUpdateModalIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -110,12 +110,26 @@ function renderSupportInactive() {
|
|||||||
'</div>',
|
'</div>',
|
||||||
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
|
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
|
||||||
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
|
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
|
||||||
'<li>Our SSH key is added only to that restricted account</li>',
|
'<li>Support\'s SSH key is added only to that restricted account — not to root</li>',
|
||||||
'<li>Wallet files are locked via access controls — not visible to support</li>',
|
'<li>Wallet files are locked via access controls — not visible to support</li>',
|
||||||
'<li>You control if and when wallet access is granted (time-limited)</li>',
|
'<li>You control if and when wallet access is granted (time-limited)</li>',
|
||||||
'<li>All session events are logged for your audit</li>',
|
'<li>All session events are logged for your audit</li>',
|
||||||
|
'<li>Access expires automatically after 24 hours</li>',
|
||||||
'</ol></div>',
|
'</ol></div>',
|
||||||
|
'<div class="support-key-section">',
|
||||||
|
'<label class="support-key-label" for="support-ssh-pubkey">',
|
||||||
|
'<strong>Paste the support SSH public key provided by Sovran Systems:</strong>',
|
||||||
|
'</label>',
|
||||||
|
'<textarea id="support-ssh-pubkey" class="support-key-input" rows="3" ',
|
||||||
|
'placeholder="ssh-ed25519 AAAA… support-session" ',
|
||||||
|
'spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>',
|
||||||
|
'<p class="support-key-hint">',
|
||||||
|
'The key must start with <code>ssh-ed25519</code> or <code>ecdsa-sha2-nistp256</code>. ',
|
||||||
|
'Do not paste your own private key — only paste the one-time public key sent by Sovran Systems support.',
|
||||||
|
'</p>',
|
||||||
|
'</div>',
|
||||||
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
|
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
|
||||||
|
'<p id="support-key-error" class="support-key-error" style="display:none;color:#c0392b;margin-top:8px;"></p>',
|
||||||
'<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>',
|
'<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>',
|
||||||
'</div>',
|
'</div>',
|
||||||
].join("");
|
].join("");
|
||||||
@@ -227,16 +241,43 @@ function renderSupportRemoved(verified) {
|
|||||||
|
|
||||||
async function enableSupport() {
|
async function enableSupport() {
|
||||||
var btn = document.getElementById("btn-support-enable");
|
var btn = document.getElementById("btn-support-enable");
|
||||||
|
var errEl = document.getElementById("support-key-error");
|
||||||
|
var textarea = document.getElementById("support-ssh-pubkey");
|
||||||
|
if (errEl) { errEl.style.display = "none"; errEl.textContent = ""; }
|
||||||
|
|
||||||
|
var sshKey = textarea ? textarea.value.trim() : "";
|
||||||
|
if (!sshKey) {
|
||||||
|
if (errEl) { errEl.textContent = "Please paste the SSH public key provided by Sovran Systems support."; errEl.style.display = "block"; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Client-side pre-validation: key must start with a known algorithm prefix
|
||||||
|
var validPrefixes = ["ssh-ed25519 ", "ecdsa-sha2-nistp256 ", "ecdsa-sha2-nistp384 ", "ecdsa-sha2-nistp521 ", "sk-ssh-ed25519@openssh.com "];
|
||||||
|
var hasValidPrefix = validPrefixes.some(function(p) { return sshKey.startsWith(p); });
|
||||||
|
if (!hasValidPrefix) {
|
||||||
|
if (errEl) { errEl.textContent = "Invalid key format. The key must start with ssh-ed25519 or ecdsa-sha2-nistp256. Do not paste a private key."; errEl.style.display = "block"; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sshKey.indexOf("\n") !== -1) {
|
||||||
|
if (errEl) { errEl.textContent = "The key must be a single line. Please check the pasted value."; errEl.style.display = "block"; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
|
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/support/enable", { method: "POST" });
|
await apiFetch("/api/support/enable", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ssh_public_key: sshKey }),
|
||||||
|
});
|
||||||
var status = await apiFetch("/api/support/status");
|
var status = await apiFetch("/api/support/status");
|
||||||
_supportStatus = status;
|
_supportStatus = status;
|
||||||
_supportEnabledAt = status.enabled_at;
|
_supportEnabledAt = status.enabled_at;
|
||||||
renderSupportActive(status);
|
renderSupportActive(status);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
||||||
alert("Failed to enable support access. Please try again.");
|
var detail = (err && err.detail) ? err.detail : "Failed to enable support access. Please check the key and try again.";
|
||||||
|
if (errEl) { errEl.textContent = detail; errEl.style.display = "block"; }
|
||||||
|
else { alert(detail); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"""Sovran Hub — injectable support session operations.
|
||||||
|
|
||||||
|
Functions here handle legacy migration, root-key removal, and support-session
|
||||||
|
expiry. All filesystem paths, clocks, and callback functions are injectable
|
||||||
|
so that the test suite can exercise the exact production implementations with
|
||||||
|
temporary files and mocks rather than maintaining separate copies.
|
||||||
|
|
||||||
|
All functions depend only on the Python standard library and the co-located
|
||||||
|
``security_helpers`` module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import time as _time_module
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
# ── Legacy Njalla curl line regex ─────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Matches both forms written by old Hub versions:
|
||||||
|
# curl [flags] https://njal.la/... (unquoted)
|
||||||
|
# curl [flags] "https://njal.la/..." (quoted — historical form)
|
||||||
|
#
|
||||||
|
# Optional flags (in order): --silent, --max-time N, --fail
|
||||||
|
#
|
||||||
|
# Rejected outright: semicolons, pipes, backticks, redirects, newlines,
|
||||||
|
# ${...} except the literal ${IP} placeholder, and any extra arguments.
|
||||||
|
_LEGACY_NJALLA_CURL_RE = re.compile(
|
||||||
|
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?'
|
||||||
|
r'(?:'
|
||||||
|
r'"(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)"' # group 1: quoted
|
||||||
|
r'|(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)' # group 2: unquoted
|
||||||
|
r')$'
|
||||||
|
)
|
||||||
|
|
||||||
|
# The exact base64 blob of the historical fleet-wide root support key that
|
||||||
|
# was shipped with old releases of Sovran_SystemsOS and must be removed from
|
||||||
|
# /root/.ssh/authorized_keys on upgrade.
|
||||||
|
LEGACY_ROOT_KEY_BLOB = (
|
||||||
|
"AAAAC3NzaC1lZDI1NTE5AAAAIPxPF2Qm11FQxC20wydKtlmn/Bo07YnDda3b9/CyXxQP"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_legacy_root_key(
|
||||||
|
authorized_keys_path: str,
|
||||||
|
target_blob: str,
|
||||||
|
*,
|
||||||
|
audit_fn: Callable[[str, str], None] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Remove the exact historical fleet-wide support key from an authorized_keys file.
|
||||||
|
|
||||||
|
Identifies the key by its exact base64 blob (``parts[1]``), regardless of
|
||||||
|
algorithm prefix or comment field. All other keys, blank lines, and comment
|
||||||
|
lines are preserved unchanged. The file is written back atomically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authorized_keys_path: Path to the authorized_keys file to modify.
|
||||||
|
target_blob: The exact base64 key blob to remove. Only lines whose
|
||||||
|
second whitespace-delimited field matches this value are removed;
|
||||||
|
no substring or comment matching is performed.
|
||||||
|
audit_fn: Optional callback ``(event: str, details: str)`` for audit
|
||||||
|
logging. The full key blob is **never** passed to this callback.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` if the file was modified (at least one line removed),
|
||||||
|
``False`` if unchanged or absent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _audit(event: str, details: str = "") -> None:
|
||||||
|
if audit_fn:
|
||||||
|
audit_fn(event, details)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(authorized_keys_path, "r") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
kept: list[str] = []
|
||||||
|
removed_count = 0
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.rstrip("\n")
|
||||||
|
parts = stripped.split()
|
||||||
|
# Key lines have at least two space-separated fields: algorithm + blob.
|
||||||
|
# Remove only lines whose blob (parts[1]) matches exactly — no
|
||||||
|
# substring matching, no comment matching.
|
||||||
|
if len(parts) >= 2 and parts[1] == target_blob:
|
||||||
|
removed_count += 1
|
||||||
|
# Audit without logging the key blob itself.
|
||||||
|
_audit("LEGACY_ROOT_KEY_REMOVED", "removed exact historical root support key")
|
||||||
|
else:
|
||||||
|
kept.append(line)
|
||||||
|
|
||||||
|
if removed_count == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Atomic write: mkstemp in same directory + os.replace
|
||||||
|
auth_dir = os.path.dirname(os.path.abspath(authorized_keys_path))
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=auth_dir, prefix=".authorized_keys_tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.writelines(kept)
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
os.replace(tmp, authorized_keys_path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
_audit(
|
||||||
|
"LEGACY_ROOT_KEY_CLEANUP_COMPLETE",
|
||||||
|
f"removed={removed_count} keys_retained={len(kept)}",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_legacy_njalla_script(
|
||||||
|
script_path: str,
|
||||||
|
validate_fn: Callable[[str], str],
|
||||||
|
save_fn: Callable[[list[str]], None],
|
||||||
|
load_fn: Callable[[], list[str]],
|
||||||
|
*,
|
||||||
|
audit_fn: Callable[[str, str], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Safely migrate legacy curl DDNS lines from a njalla.sh script to JSON store.
|
||||||
|
|
||||||
|
Reads the script **without** executing or sourcing it. Parses only the
|
||||||
|
exact narrow curl-pattern lines (quoted or unquoted) written by old Hub
|
||||||
|
versions. Any other line is silently discarded — never executed or logged
|
||||||
|
(it may contain secret tokens).
|
||||||
|
|
||||||
|
On successful persistence the script is archived with mode ``0o000`` so
|
||||||
|
it can no longer be executed. If persistence fails the script is left
|
||||||
|
**untouched**.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
script_path: Path to the legacy njalla.sh file.
|
||||||
|
validate_fn: URL validation function; raises ``ValueError`` on invalid
|
||||||
|
URLs. Callers must substitute the ``${IP}`` placeholder before
|
||||||
|
calling — this function passes ``url.replace("${IP}", "127.0.0.1")``
|
||||||
|
to the validator.
|
||||||
|
save_fn: Callable that atomically writes a ``list[str]`` URL list to
|
||||||
|
the persistent JSON store.
|
||||||
|
load_fn: Callable that returns the current ``list[str]`` URL list from
|
||||||
|
the persistent store.
|
||||||
|
audit_fn: Optional callback ``(event: str, details: str)`` for audit
|
||||||
|
logging. Token-bearing URLs are **never** passed to this callback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _audit(event: str, details: str = "") -> None:
|
||||||
|
if audit_fn:
|
||||||
|
audit_fn(event, details)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(script_path, "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_urls = load_fn()
|
||||||
|
|
||||||
|
new_urls: list[str] = []
|
||||||
|
for raw_line in content.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or line.startswith("IP=") or line.startswith("#!/"):
|
||||||
|
continue
|
||||||
|
m = _LEGACY_NJALLA_CURL_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
# Unrecognised line — discard silently, do NOT log (may contain tokens)
|
||||||
|
continue
|
||||||
|
# group(1) = quoted form, group(2) = unquoted form
|
||||||
|
raw_url = m.group(1) or m.group(2)
|
||||||
|
# Substitute placeholder so host/scheme/path validation works
|
||||||
|
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
|
||||||
|
try:
|
||||||
|
validate_fn(url_to_validate)
|
||||||
|
except ValueError:
|
||||||
|
continue # Silently discard invalid/non-Njal.la URLs
|
||||||
|
if raw_url not in existing_urls and raw_url not in new_urls:
|
||||||
|
new_urls.append(raw_url)
|
||||||
|
|
||||||
|
if new_urls:
|
||||||
|
combined = existing_urls + new_urls
|
||||||
|
try:
|
||||||
|
save_fn(combined)
|
||||||
|
except Exception:
|
||||||
|
# Persistence failed — leave the script untouched, return without
|
||||||
|
# archiving so the migration can be retried.
|
||||||
|
return
|
||||||
|
_audit("NJALLA_MIGRATION", f"migrated {len(new_urls)} DDNS URLs from legacy script")
|
||||||
|
|
||||||
|
# Archive: remove all permission bits so cron/any mechanism cannot run it
|
||||||
|
try:
|
||||||
|
os.chmod(script_path, 0o000)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def expire_if_stale(
|
||||||
|
status_file: str,
|
||||||
|
*,
|
||||||
|
clock_fn: Callable[[], float] | None = None,
|
||||||
|
disable_fn: Callable[[], bool] | None = None,
|
||||||
|
audit_fn: Callable[[str, str], None] | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
expected_expiry: float | None = None,
|
||||||
|
max_session_seconds: float = 86400.0,
|
||||||
|
) -> bool:
|
||||||
|
"""Expire a support session if its deadline has passed.
|
||||||
|
|
||||||
|
**Stale-timer guard:** when ``session_id`` and/or ``expected_expiry`` are
|
||||||
|
provided (used by the server-side timer callback), the stored session
|
||||||
|
metadata is compared field-by-field. A mismatch means a replacement
|
||||||
|
session has been started after this timer was scheduled; in that case the
|
||||||
|
function returns ``False`` without touching anything.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status_file: Path to the JSON session metadata file.
|
||||||
|
clock_fn: Callable returning current Unix time (default: ``time.time``).
|
||||||
|
disable_fn: Callable that performs the full disable sequence — removes
|
||||||
|
the support key, removes wallet-unlock metadata, restores deny
|
||||||
|
ACLs, clears session metadata, and audits the event. If ``None``,
|
||||||
|
expiry is detected but no action is taken (useful for tests that
|
||||||
|
want to inspect detection only).
|
||||||
|
audit_fn: Optional callback ``(event: str, details: str)`` for audit
|
||||||
|
logging.
|
||||||
|
session_id: If given, expiry is skipped unless the stored
|
||||||
|
``session_id`` field matches exactly.
|
||||||
|
expected_expiry: If given, expiry is skipped unless the stored
|
||||||
|
``expires_at`` field matches exactly.
|
||||||
|
max_session_seconds: Legacy fallback: maximum age (from ``enabled_at``)
|
||||||
|
when ``expires_at`` is absent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` if a session was expired, ``False`` otherwise.
|
||||||
|
"""
|
||||||
|
_now = clock_fn if clock_fn is not None else _time_module.time
|
||||||
|
|
||||||
|
def _audit(event: str, details: str = "") -> None:
|
||||||
|
if audit_fn:
|
||||||
|
audit_fn(event, details)
|
||||||
|
|
||||||
|
def _disable() -> bool:
|
||||||
|
return disable_fn() if disable_fn is not None else True
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(status_file, "r") as f:
|
||||||
|
info = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Stale-timer guard
|
||||||
|
if session_id is not None and info.get("session_id") != session_id:
|
||||||
|
return False
|
||||||
|
if expected_expiry is not None and info.get("expires_at") != expected_expiry:
|
||||||
|
return False
|
||||||
|
|
||||||
|
expires_at = info.get("expires_at")
|
||||||
|
now = _now()
|
||||||
|
|
||||||
|
if expires_at is None:
|
||||||
|
enabled_at = info.get("enabled_at", 0)
|
||||||
|
if enabled_at and (now - enabled_at) > max_session_seconds:
|
||||||
|
_audit("SUPPORT_EXPIRED", "legacy session without expires_at exceeded max duration")
|
||||||
|
_disable()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
if now >= expires_at:
|
||||||
|
_audit("SUPPORT_EXPIRED", f"session expired at {expires_at:.0f}")
|
||||||
|
_disable()
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -18,6 +18,45 @@ def is_active(unit: str, scope: Literal["system", "user"] = "system") -> str:
|
|||||||
return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown"
|
return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def active_states(units_by_scope: dict[str, list[str]], timeout: float = 5) -> dict[tuple[str, str], str]:
|
||||||
|
"""Return active states for many units with one systemctl call per scope.
|
||||||
|
|
||||||
|
The dashboard polls service state frequently. Spawning one ``systemctl``
|
||||||
|
process per tile makes a slow or unavailable system bus multiply the delay
|
||||||
|
(and can make the first dashboard render wait through many timeouts). The
|
||||||
|
``is-active`` command accepts multiple units, so batch them and cap the
|
||||||
|
whole batch at a single timeout.
|
||||||
|
"""
|
||||||
|
states: dict[tuple[str, str], str] = {}
|
||||||
|
valid_scopes = {"system", "user"}
|
||||||
|
|
||||||
|
for scope, units in units_by_scope.items():
|
||||||
|
unique_units = list(dict.fromkeys(unit for unit in units if unit))
|
||||||
|
if not unique_units:
|
||||||
|
continue
|
||||||
|
if scope not in valid_scopes:
|
||||||
|
for unit in unique_units:
|
||||||
|
states[(scope, unit)] = "unknown"
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", f"--{scope}", "is-active", "--", *unique_units],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
lines = result.stdout.splitlines()
|
||||||
|
for index, unit in enumerate(unique_units):
|
||||||
|
state = lines[index].strip() if index < len(lines) else ""
|
||||||
|
states[(scope, unit)] = state or "unknown"
|
||||||
|
except Exception:
|
||||||
|
for unit in unique_units:
|
||||||
|
states[(scope, unit)] = "unknown"
|
||||||
|
|
||||||
|
return states
|
||||||
|
|
||||||
|
|
||||||
def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str:
|
def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str:
|
||||||
return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown"
|
return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown"
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,12 @@
|
|||||||
<div id="sidebar-support"></div>
|
<div id="sidebar-support"></div>
|
||||||
<div id="sidebar-features"></div>
|
<div id="sidebar-features"></div>
|
||||||
</aside>
|
</aside>
|
||||||
<div id="tiles-area"></div>
|
<div id="tiles-area">
|
||||||
|
<div class="dashboard-loading" role="status" aria-live="polite">
|
||||||
|
<span class="dashboard-loading-spinner" aria-hidden="true"></span>
|
||||||
|
<span>Loading service status…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Update modal -->
|
<!-- Update modal -->
|
||||||
@@ -69,6 +74,7 @@
|
|||||||
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
||||||
|
<button class="btn btn-save" id="btn-retry-update-status" style="display:none">Retry Status</button>
|
||||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
||||||
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,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,10 +5,10 @@
|
|||||||
"bitcoind.service": "27.1.0",
|
"bitcoind.service": "27.1.0",
|
||||||
"electrs.service": "0.10.6",
|
"electrs.service": "0.10.6",
|
||||||
"lnd.service": "0.18.0",
|
"lnd.service": "0.18.0",
|
||||||
"rtl.service": "0.15.2",
|
"rtl.service": "0.15.10",
|
||||||
"btcpayserver.service": "2.0.0",
|
"btcpayserver.service": "2.4.2",
|
||||||
"albyhub.service": "1.8.0",
|
"albyhub.service": "1.24.0",
|
||||||
"mempool.service": "3.0.0",
|
"mempool.service": "3.2.1",
|
||||||
"matrix-synapse.service": "1.115.0",
|
"matrix-synapse.service": "1.115.0",
|
||||||
"livekit.service": "1.5.2",
|
"livekit.service": "1.5.2",
|
||||||
"vaultwarden.service": "1.32.0",
|
"vaultwarden.service": "1.32.0",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 442 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
+5
-7
@@ -145,12 +145,13 @@
|
|||||||
ranger fastfetch gedit openssl pwgen
|
ranger fastfetch gedit openssl pwgen
|
||||||
aspell aspellDicts.en lm_sensors
|
aspell aspellDicts.en lm_sensors
|
||||||
hunspell hunspellDicts.en_US
|
hunspell hunspellDicts.en_US
|
||||||
synadm brave dua
|
synadm brave-origin dua
|
||||||
gparted pv unzip parted screen zenity
|
gparted pv unzip parted screen zenity
|
||||||
libargon2 gnome-terminal libreoffice-fresh
|
libargon2 gnome-terminal libreoffice-fresh
|
||||||
dig firefox wp-cli axel
|
dig firefox wp-cli axel
|
||||||
lk-jwt-service livekit-libwebrtc livekit
|
lk-jwt-service livekit-libwebrtc livekit
|
||||||
matrix-synapse age onlyoffice-desktopeditors
|
matrix-synapse age onlyoffice-desktopeditors
|
||||||
|
tor-browser
|
||||||
];
|
];
|
||||||
|
|
||||||
# ── Shell ──────────────────────────────────────────────────
|
# ── Shell ──────────────────────────────────────────────────
|
||||||
@@ -192,12 +193,9 @@ backup /etc/nix-bitcoin-secrets/ localhost/
|
|||||||
};
|
};
|
||||||
|
|
||||||
# ── Cron ───────────────────────────────────────────────────
|
# ── Cron ───────────────────────────────────────────────────
|
||||||
services.cron = {
|
# The legacy njalla.sh root cron job has been replaced by the systemd timer
|
||||||
enable = true;
|
# defined in modules/core/njalla.nix (sovran-ddns-update.timer). Cron is
|
||||||
systemCronJobs = [
|
# retained so that rsnapshot and other module-defined cron jobs continue to run.
|
||||||
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
# ── Tor ────────────────────────────────────────────────────
|
# ── Tor ────────────────────────────────────────────────────
|
||||||
services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
|
services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
|
||||||
|
|||||||
Generated
+61
-139
@@ -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": {
|
||||||
@@ -18,32 +18,6 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extra-container": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": [
|
|
||||||
"nix-bitcoin",
|
|
||||||
"flake-utils"
|
|
||||||
],
|
|
||||||
"nixpkgs": [
|
|
||||||
"nix-bitcoin",
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1766155727,
|
|
||||||
"narHash": "sha256-XGp4HHH6D6ZKiO5RnMzqYJYnZB538EnEflvlTsOKpvo=",
|
|
||||||
"owner": "erikarvstedt",
|
|
||||||
"repo": "extra-container",
|
|
||||||
"rev": "b450bdb24fca1076973c852d87bcb49b8eb5fd49",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "erikarvstedt",
|
|
||||||
"ref": "0.14",
|
|
||||||
"repo": "extra-container",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-parts": {
|
"flake-parts": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs-lib": [
|
"nixpkgs-lib": [
|
||||||
@@ -52,11 +26,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782949081,
|
"lastModified": 1785627969,
|
||||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -65,47 +39,6 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nix-bitcoin": {
|
|
||||||
"inputs": {
|
|
||||||
"extra-container": "extra-container",
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs_2",
|
|
||||||
"nixpkgs-25_05": "nixpkgs-25_05",
|
|
||||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1785177868,
|
|
||||||
"narHash": "sha256-zlAZZkokXpeHHBP8Nt4MvLXOn1Js1qVi81VPjtVbRV8=",
|
|
||||||
"owner": "fort-nix",
|
|
||||||
"repo": "nix-bitcoin",
|
|
||||||
"rev": "360e30fee5ba32f9fecc89bc35628195d9d2dbbe",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "fort-nix",
|
|
||||||
"ref": "release",
|
|
||||||
"repo": "nix-bitcoin",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785590095,
|
"lastModified": 1785590095,
|
||||||
@@ -121,29 +54,13 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs-25_05": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1767313136,
|
|
||||||
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-25.05",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785734586,
|
"lastModified": 1787753485,
|
||||||
"narHash": "sha256-ODZkEK9Gy50yg6h98u7KkitZ3oc/uuTFK00bh1CRdNA=",
|
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "531670d871c0e29724a02f3cbcac170adc65b58c",
|
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -153,45 +70,29 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs-unstable": {
|
"nixpkgs-stable_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784453100,
|
"lastModified": 1787753485,
|
||||||
"narHash": "sha256-zpGZb8FYui+i8KLtC0nlcmb0voR2zB80Qn8pe7lzIbk=",
|
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
|
||||||
"owner": "NixOS",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "b471514bed69eff5255c8e63c1f80e5fe56c616f",
|
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "nixos",
|
||||||
"ref": "nixpkgs-unstable",
|
"ref": "nixos-26.05",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782847189,
|
"lastModified": 1787498568,
|
||||||
"narHash": "sha256-twXPFqFsrrY5r28Zh7Homgcp2gUMBgQ6WDS98Q/3xFI=",
|
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "b6018f87da91d19d0ab4cf979885689b469cdd41",
|
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188",
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-25.11",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs_3": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1785828668,
|
|
||||||
"narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb",
|
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -201,13 +102,13 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_4": {
|
"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": {
|
||||||
@@ -217,18 +118,34 @@
|
|||||||
"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",
|
||||||
"nixpkgs": "nixpkgs_4",
|
"nixpkgs": "nixpkgs_3",
|
||||||
"systems": "systems_2"
|
"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": {
|
||||||
@@ -240,28 +157,33 @@
|
|||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"btc-clients": "btc-clients",
|
"btc-clients": "btc-clients",
|
||||||
"nix-bitcoin": "nix-bitcoin",
|
"nixpkgs": "nixpkgs_2",
|
||||||
"nixpkgs": "nixpkgs_3",
|
|
||||||
"nixpkgs-stable": "nixpkgs-stable",
|
"nixpkgs-stable": "nixpkgs-stable",
|
||||||
"nixvim": "nixvim"
|
"nixvim": "nixvim",
|
||||||
|
"sovran-bitcoin": "sovran-bitcoin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"sovran-bitcoin": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs_4",
|
||||||
|
"nixpkgs-stable": "nixpkgs-stable_2"
|
||||||
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1681028828,
|
"lastModified": 1788225336,
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
"narHash": "sha256-ixw/ChkLWWrLOzoiSa9n1g2g5KDIVkedC6eYFCba+gA=",
|
||||||
"owner": "nix-systems",
|
"owner": "naturallaw777",
|
||||||
"repo": "default",
|
"repo": "Sovran_Bitcoin",
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
"rev": "37caecaa3f3666231c8ec82a9a9a0df53fa40910",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nix-systems",
|
"owner": "naturallaw777",
|
||||||
"repo": "default",
|
"repo": "Sovran_Bitcoin",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems_2": {
|
"systems": {
|
||||||
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774449309,
|
"lastModified": 1774449309,
|
||||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
|
|
||||||
nixvim.url = "github:nix-community/nixvim";
|
nixvim.url = "github:nix-community/nixvim";
|
||||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
|
|
||||||
|
# Bitcoin / Lightning stack — standalone flake, consumed as a module.
|
||||||
|
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, nix-bitcoin, 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: {
|
||||||
@@ -22,7 +24,7 @@
|
|||||||
{
|
{
|
||||||
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
|
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
|
||||||
modules = [
|
modules = [
|
||||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||||
self.nixosModules.Sovran_SystemsOS
|
self.nixosModules.Sovran_SystemsOS
|
||||||
./hardware-configuration.nix
|
./hardware-configuration.nix
|
||||||
./role-state.nix
|
./role-state.nix
|
||||||
@@ -32,10 +34,10 @@
|
|||||||
|
|
||||||
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
|
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
|
||||||
modules = [
|
modules = [
|
||||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||||
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
|
||||||
./iso/common.nix
|
./iso/common.nix
|
||||||
nix-bitcoin.nixosModules.default
|
sovran-bitcoin.nixosModules.default
|
||||||
|
./modules/sovran-bitcoin-integration.nix
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -46,7 +48,8 @@
|
|||||||
nixpkgs.overlays = [ overlay-stable ];
|
nixpkgs.overlays = [ overlay-stable ];
|
||||||
})
|
})
|
||||||
./configuration.nix
|
./configuration.nix
|
||||||
nix-bitcoin.nixosModules.default
|
sovran-bitcoin.nixosModules.default
|
||||||
|
./modules/sovran-bitcoin-integration.nix
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
config = {
|
config = {
|
||||||
@@ -58,13 +61,13 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
nixosTests.nwc-wallets-port-collision =
|
checks.x86_64-linux = let
|
||||||
import ./nix/tests/nwc-wallets-port-collision.nix {
|
pkgs = import nixpkgs {
|
||||||
inherit nixpkgs;
|
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
};
|
};
|
||||||
|
in {
|
||||||
checks.x86_64-linux.nwc-wallets-port-collision =
|
# Bitcoin hardening and package checks now live in the Sovran_Bitcoin flake.
|
||||||
self.nixosTests.nwc-wallets-port-collision;
|
# Run them with: nix build github:naturallaw777/Sovran_Bitcoin#checks.x86_64-linux
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+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 {
|
|
||||||
|
|
||||||
services.bitcoind.package = lib.mkForce config.nix-bitcoin.pkgs.bitcoind;
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,116 +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";
|
|
||||||
};
|
|
||||||
|
|
||||||
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"; }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
+122
-17
@@ -1,32 +1,137 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
{
|
{
|
||||||
# ── Ensure njalla directory and base script exist on every build ──
|
# ── Ensure njalla directory exists on every build ────────────────────────
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /var/lib/njalla 0750 root root -"
|
"d /var/lib/njalla 0750 root root -"
|
||||||
];
|
];
|
||||||
|
|
||||||
# ── Create base njalla.sh if it doesn't exist yet ────────────
|
# ── Install the shared validation helper so the DDNS runner can import it ─
|
||||||
systemd.services.njalla-init = {
|
# The exact same _validate_ddns_url() function used by the Hub web application
|
||||||
description = "Initialize Njal.la DDNS script if missing";
|
# is installed here as a read-only system file. The DDNS runner imports it
|
||||||
wantedBy = [ "multi-user.target" ];
|
# directly so the two code paths share one validator — no weaker inline copy.
|
||||||
|
environment.etc."sovran/security_helpers.py" = {
|
||||||
|
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
||||||
|
mode = "0444";
|
||||||
|
user = "root";
|
||||||
|
group = "root";
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── Safe DDNS update service ─────────────────────────────────────────────
|
||||||
|
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||||
|
# invokes curl directly — no shell interpolation, no script execution.
|
||||||
|
# Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh.
|
||||||
|
systemd.services.sovran-ddns-update = {
|
||||||
|
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
after = [ "network-online.target" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
User = "root";
|
||||||
|
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
|
||||||
|
# Harden the service — it only needs network access and read access to
|
||||||
|
# /var/lib/njalla/ddns_urls.json.
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
||||||
|
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||||
|
ProtectHome = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
||||||
};
|
};
|
||||||
unitConfig = {
|
|
||||||
ConditionPathExists = "!/var/lib/njalla/njalla.sh";
|
|
||||||
};
|
};
|
||||||
script = ''
|
|
||||||
cat > /var/lib/njalla/njalla.sh <<'SCRIPT'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)
|
|
||||||
|
|
||||||
## Add DDNS entries below — one curl per line
|
# Run the update every 15 minutes
|
||||||
## Managed via Sovran Hub web interface
|
systemd.timers.sovran-ddns-update = {
|
||||||
SCRIPT
|
description = "Sovran Njal.la DDNS update timer";
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
timerConfig = {
|
||||||
|
OnBootSec = "2min";
|
||||||
|
OnUnitActiveSec = "15min";
|
||||||
|
Persistent = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
chmod 700 /var/lib/njalla/njalla.sh
|
# Install the Python runner script at build time so the service can find it.
|
||||||
|
# The script is owned by root and not world-writable.
|
||||||
|
# Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same
|
||||||
|
# production validator used by the Hub API — before executing any curl call.
|
||||||
|
# No shell is used; no redirects; no script execution.
|
||||||
|
# ${IP} placeholder is preserved in stored URLs and substituted at runtime;
|
||||||
|
# the URL is validated after substitution so any remaining $ is rejected.
|
||||||
|
system.activationScripts.sovran-ddns-update-script = ''
|
||||||
|
install -d -m 0755 /var/lib/sovran
|
||||||
|
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sovran safe DDNS update runner.
|
||||||
|
|
||||||
|
Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder,
|
||||||
|
validates each URL using the production _validate_ddns_url() from
|
||||||
|
/etc/sovran/security_helpers.py, then calls curl per URL.
|
||||||
|
No shell interpolation. No redirects. No script execution.
|
||||||
|
"""
|
||||||
|
import ipaddress, json, os, subprocess, sys
|
||||||
|
|
||||||
|
sys.path.insert(0, '/etc/sovran')
|
||||||
|
try:
|
||||||
|
from security_helpers import _validate_ddns_url
|
||||||
|
except ImportError:
|
||||||
|
sys.exit(1) # validator missing — fail so systemd logs the misconfiguration
|
||||||
|
|
||||||
|
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(URLS_FILE) as f:
|
||||||
|
urls = json.load(f)
|
||||||
|
if not isinstance(urls, list):
|
||||||
|
raise ValueError("not a list")
|
||||||
|
except Exception:
|
||||||
|
sys.exit(0) # no URLs configured — nothing to do
|
||||||
|
|
||||||
|
# Resolve current public IP via the shared detector — one script, one cache
|
||||||
|
# (STUN -> DNS -> opt-in HTTPS echo; see /var/lib/sovran/public-ip.py).
|
||||||
|
# The detector refreshes /var/lib/secrets/external-ip, which the Hub and
|
||||||
|
# LiveKit read as well, so the whole system shares a single detected value.
|
||||||
|
public_ip = ""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
|
||||||
|
capture_output=True, text=True, timeout=20,
|
||||||
|
)
|
||||||
|
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
|
||||||
|
ipaddress.ip_address(raw) # validates — raises if not a real IP
|
||||||
|
public_ip = raw
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not public_ip:
|
||||||
|
# Last resort: the shared cache file, if the detector is unavailable.
|
||||||
|
try:
|
||||||
|
with open("/var/lib/secrets/external-ip") as f:
|
||||||
|
raw = f.read().strip()
|
||||||
|
ipaddress.ip_address(raw)
|
||||||
|
public_ip = raw
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not public_ip:
|
||||||
|
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
|
||||||
|
|
||||||
|
for raw_url in urls:
|
||||||
|
try:
|
||||||
|
# Substitute ''${IP} placeholder then validate through production validator.
|
||||||
|
# After substitution there must be no $ left; _validate_ddns_url rejects
|
||||||
|
# any remaining $ expression.
|
||||||
|
url = raw_url.replace("''${IP}", public_ip)
|
||||||
|
_validate_ddns_url(url)
|
||||||
|
subprocess.run(
|
||||||
|
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
||||||
|
timeout=20, check=False,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
PYEOF
|
||||||
|
chmod 0500 /var/lib/sovran/ddns-update.py
|
||||||
'';
|
'';
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -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,9 +3,9 @@
|
|||||||
{
|
{
|
||||||
config = lib.mkMerge [
|
config = lib.mkMerge [
|
||||||
|
|
||||||
# nix-bitcoin is globally imported by the flake (nixosModules.Sovran_SystemsOS).
|
# The Sovran_Bitcoin flake module is always imported (see flake.nix).
|
||||||
# This default satisfies nix-bitcoin's generateSecrets assertion so that Desktop
|
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
||||||
# Only systems can evaluate without enabling any Bitcoin services.
|
# without enabling any Bitcoin services.
|
||||||
{
|
{
|
||||||
nix-bitcoin.generateSecrets = lib.mkDefault true;
|
nix-bitcoin.generateSecrets = lib.mkDefault true;
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
haven = lib.mkForce false;
|
haven = lib.mkForce false;
|
||||||
mempool = lib.mkForce false;
|
mempool = lib.mkForce false;
|
||||||
element-calling = lib.mkForce false;
|
element-calling = lib.mkForce false;
|
||||||
bitcoin-core = lib.mkForce false;
|
bitcoin-tor-gossip = lib.mkForce false;
|
||||||
"nwc-wallets" = lib.mkForce false;
|
"nwc-wallets" = lib.mkForce false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+44
-22
@@ -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.
|
|
||||||
''
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-46
@@ -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,13 +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";
|
||||||
"rtl.service" = if pkgs ? clightning-rtl then pkgs.clightning-rtl.version else (if pkgs ? rtl then pkgs.rtl.version else "0.15.2");
|
# Vendored packages come from the Sovran_Bitcoin flake overlay
|
||||||
"btcpayserver.service" = if pkgs ? btcpayserver then pkgs.btcpayserver.version else "2.0.0";
|
# (pkgs.sovran-bitcoin.*). Evaluate them directly so the Hub shows
|
||||||
"albyhub.service" = if pkgs ? albyhub then pkgs.albyhub.version else "1.8.0";
|
# the exact versions this system ships.
|
||||||
"mempool.service" = if pkgs ? mempool then pkgs.mempool.version else "3.0.0";
|
"rtl.service" = pkgs.sovran-bitcoin.rtl.version;
|
||||||
|
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
||||||
|
"albyhub.service" = pkgs.sovran-bitcoin.albyhub.version;
|
||||||
|
"mempool.service" = pkgs.sovran-bitcoin.mempool-backend.version;
|
||||||
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
"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";
|
||||||
@@ -154,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
|
||||||
|
|
||||||
@@ -179,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
|
||||||
@@ -234,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)"
|
||||||
@@ -272,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 \
|
||||||
@@ -317,7 +346,8 @@ let
|
|||||||
|
|
||||||
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
|
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
|
||||||
pname = "sovran-systemsos-hub-web";
|
pname = "sovran-systemsos-hub-web";
|
||||||
version = "1.0.0";
|
# Keep the package metadata in lockstep with the version shown in the Hub.
|
||||||
|
version = sovranVersion;
|
||||||
format = "other";
|
format = "other";
|
||||||
|
|
||||||
src = ../../app;
|
src = ../../app;
|
||||||
@@ -375,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")
|
||||||
@@ -388,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
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sovran restricted journal helper.
|
||||||
|
|
||||||
|
A root-owned, non-user-writable diagnostic tool that wraps journalctl with
|
||||||
|
a strict allowlist of safe flags. Replaces the ``journalctl *`` sudo rule
|
||||||
|
in tech-support.nix.
|
||||||
|
|
||||||
|
Accepted flags:
|
||||||
|
--unit / -u <name> must be one of the explicitly approved service units
|
||||||
|
--lines / -n <N> positive integer (max 10000)
|
||||||
|
--priority / -p <level> 0-7 or emerg/alert/crit/err/warning/notice/info/debug
|
||||||
|
--since <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
|
||||||
|
--until <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
|
||||||
|
--output / -o <format> short | short-iso | cat | json | verbose
|
||||||
|
|
||||||
|
At least one ``--unit`` flag is required; whole-journal queries are rejected.
|
||||||
|
All other flags, paths, directories, roots, namespaces, and output
|
||||||
|
destinations are rejected with a non-zero exit code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# ── Allowlists ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Explicit approved units. Only these four services may be queried through
|
||||||
|
# the restricted journal helper. Any other unit is rejected.
|
||||||
|
_APPROVED_UNITS: frozenset[str] = frozenset([
|
||||||
|
"sovran-hub-web.service",
|
||||||
|
"caddy.service",
|
||||||
|
"bitcoind.service",
|
||||||
|
"lnd.service",
|
||||||
|
])
|
||||||
|
|
||||||
|
_ALLOWED_PRIORITIES = frozenset([
|
||||||
|
"0", "1", "2", "3", "4", "5", "6", "7",
|
||||||
|
"emerg", "alert", "crit", "err", "warning", "notice", "info", "debug",
|
||||||
|
])
|
||||||
|
|
||||||
|
_ALLOWED_OUTPUT_FORMATS = frozenset([
|
||||||
|
"short", "short-iso", "cat", "json", "verbose",
|
||||||
|
])
|
||||||
|
|
||||||
|
# ISO 8601 date or datetime: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (no paths)
|
||||||
|
_DATETIME_RE = re.compile(r'^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?$')
|
||||||
|
|
||||||
|
_MAX_LINES = 10000
|
||||||
|
|
||||||
|
# ── Argument parser ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _die(msg: str) -> None:
|
||||||
|
print(f"sovran-journal-helper: {msg}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_unit(val: str) -> str:
|
||||||
|
if val not in _APPROVED_UNITS:
|
||||||
|
_die(
|
||||||
|
f"rejected unit name: {val!r} "
|
||||||
|
f"(allowed: {', '.join(sorted(_APPROVED_UNITS))})"
|
||||||
|
)
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_lines(val: str) -> str:
|
||||||
|
try:
|
||||||
|
n = int(val)
|
||||||
|
except ValueError:
|
||||||
|
_die(f"rejected: --lines must be a positive integer, got {val!r}")
|
||||||
|
if n <= 0 or n > _MAX_LINES:
|
||||||
|
_die(f"rejected: --lines must be between 1 and {_MAX_LINES}, got {n}")
|
||||||
|
return str(n)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_priority(val: str) -> str:
|
||||||
|
if val not in _ALLOWED_PRIORITIES:
|
||||||
|
_die(f"rejected priority: {val!r}")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_datetime(val: str) -> str:
|
||||||
|
if not _DATETIME_RE.match(val):
|
||||||
|
_die(f"rejected: datetime {val!r} (must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_output(val: str) -> str:
|
||||||
|
if val not in _ALLOWED_OUTPUT_FORMATS:
|
||||||
|
_die(f"rejected output format: {val!r}")
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = sys.argv[1:]
|
||||||
|
cmd = ["journalctl"]
|
||||||
|
unit_count = 0
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(args):
|
||||||
|
arg = args[i]
|
||||||
|
|
||||||
|
if arg in ("--unit", "-u"):
|
||||||
|
i += 1
|
||||||
|
if i >= len(args):
|
||||||
|
_die("--unit requires a value")
|
||||||
|
cmd += ["--unit", _validate_unit(args[i])]
|
||||||
|
unit_count += 1
|
||||||
|
elif arg.startswith("--unit="):
|
||||||
|
cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
|
||||||
|
unit_count += 1
|
||||||
|
|
||||||
|
elif arg in ("--lines", "-n"):
|
||||||
|
i += 1
|
||||||
|
if i >= len(args):
|
||||||
|
_die("--lines requires a value")
|
||||||
|
cmd += ["--lines", _validate_lines(args[i])]
|
||||||
|
elif arg.startswith("--lines="):
|
||||||
|
cmd += ["--lines", _validate_lines(arg[len("--lines="):])]
|
||||||
|
elif re.match(r'^-n\d+$', arg):
|
||||||
|
cmd += ["--lines", _validate_lines(arg[2:])]
|
||||||
|
|
||||||
|
elif arg in ("--priority", "-p"):
|
||||||
|
i += 1
|
||||||
|
if i >= len(args):
|
||||||
|
_die("--priority requires a value")
|
||||||
|
cmd += ["--priority", _validate_priority(args[i])]
|
||||||
|
elif arg.startswith("--priority="):
|
||||||
|
cmd += ["--priority", _validate_priority(arg[len("--priority="):])]
|
||||||
|
|
||||||
|
elif arg == "--since":
|
||||||
|
i += 1
|
||||||
|
if i >= len(args):
|
||||||
|
_die("--since requires a value")
|
||||||
|
cmd += ["--since", _validate_datetime(args[i])]
|
||||||
|
elif arg.startswith("--since="):
|
||||||
|
cmd += ["--since", _validate_datetime(arg[len("--since="):])]
|
||||||
|
|
||||||
|
elif arg == "--until":
|
||||||
|
i += 1
|
||||||
|
if i >= len(args):
|
||||||
|
_die("--until requires a value")
|
||||||
|
cmd += ["--until", _validate_datetime(args[i])]
|
||||||
|
elif arg.startswith("--until="):
|
||||||
|
cmd += ["--until", _validate_datetime(arg[len("--until="):])]
|
||||||
|
|
||||||
|
elif arg in ("--output", "-o"):
|
||||||
|
i += 1
|
||||||
|
if i >= len(args):
|
||||||
|
_die("--output requires a value")
|
||||||
|
cmd += ["--output", _validate_output(args[i])]
|
||||||
|
elif arg.startswith("--output="):
|
||||||
|
cmd += ["--output", _validate_output(arg[len("--output="):])]
|
||||||
|
|
||||||
|
else:
|
||||||
|
_die(
|
||||||
|
f"rejected flag: {arg!r}. "
|
||||||
|
"Allowed flags: --unit, --lines, --priority, --since, --until, --output"
|
||||||
|
)
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if unit_count == 0:
|
||||||
|
_die(
|
||||||
|
"at least one --unit flag is required; "
|
||||||
|
f"allowed units: {', '.join(sorted(_APPROVED_UNITS))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = subprocess.run(cmd)
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -44,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";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,10 @@
|
|||||||
# (u:sovran-support:---) by the Hub API as soon as a session is started.
|
# (u:sovran-support:---) by the Hub API as soon as a session is started.
|
||||||
# • The Hub web UI lets the user grant time-limited access to wallet files
|
# • The Hub web UI lets the user grant time-limited access to wallet files
|
||||||
# and view a full audit log of every session event.
|
# and view a full audit log of every session event.
|
||||||
# • Scoped sudo rules allow support staff to edit custom.nix, trigger rebuilds,
|
# • Scoped sudo rules allow support staff to restart specific services and
|
||||||
# restart services, and read logs — without full root or wallet access.
|
# read logs — without full root, wallet access, Nix editing, or rebuilds.
|
||||||
#
|
# • journalctl access is provided only through the root-owned
|
||||||
# The `acl` package provides the `setfacl` / `getfacl` utilities required by
|
# sovran-journal-helper script (see below) with an allowlist of safe flags.
|
||||||
# the Hub's _apply_wallet_acls() and _revoke_wallet_acls() helpers.
|
|
||||||
{
|
{
|
||||||
# ── System packages ────────────────────────────────────────────────────────
|
# ── System packages ────────────────────────────────────────────────────────
|
||||||
environment.systemPackages = [ pkgs.acl ];
|
environment.systemPackages = [ pkgs.acl ];
|
||||||
@@ -42,18 +41,40 @@
|
|||||||
"d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -"
|
"d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# ── Restricted journal helper ─────────────────────────────────────────────
|
||||||
|
# The helper is root-owned, not writable by any user, and accepts only a
|
||||||
|
# narrow allowlist of safe journalctl flags. It is the sole mechanism by
|
||||||
|
# which the support user may read journal logs.
|
||||||
|
environment.etc."sovran/sovran-journal-helper.py" = {
|
||||||
|
source = ./sovran-journal-helper.py;
|
||||||
|
mode = "0500";
|
||||||
|
user = "root";
|
||||||
|
group = "root";
|
||||||
|
};
|
||||||
|
|
||||||
# ── Scoped sudo rules for support staff ───────────────────────────────────
|
# ── Scoped sudo rules for support staff ───────────────────────────────────
|
||||||
# Grants only the minimum privileges needed for a support session.
|
# Grants only the minimum privileges needed for diagnostic support.
|
||||||
# Support staff cannot stop/disable/mask services or access wallet files.
|
# Editing Nix configuration and running nixos-rebuild are intentionally
|
||||||
|
# excluded: combining those two permissions provides a trivial path to
|
||||||
|
# arbitrary root code execution. Systemctl access is limited to a small
|
||||||
|
# allowlist of named service restart operations. journalctl is available
|
||||||
|
# only through the restricted helper above.
|
||||||
security.sudo.extraRules = [
|
security.sudo.extraRules = [
|
||||||
{
|
{
|
||||||
users = [ "sovran-support" ];
|
users = [ "sovran-support" ];
|
||||||
commands = [
|
commands = [
|
||||||
{ command = "/run/current-system/sw/bin/nano /etc/nixos/custom.nix"; options = [ "NOPASSWD" ]; }
|
{ command = "/run/current-system/sw/bin/systemctl restart sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
|
||||||
{ command = "/run/current-system/sw/bin/nano /etc/nixos/configuration.nix"; options = [ "NOPASSWD" ]; }
|
{ command = "/run/current-system/sw/bin/systemctl restart caddy.service"; options = [ "NOPASSWD" ]; }
|
||||||
{ command = "/run/current-system/sw/bin/nixos-rebuild switch --flake /etc/nixos"; options = [ "NOPASSWD" ]; }
|
{ command = "/run/current-system/sw/bin/systemctl restart bitcoind.service"; options = [ "NOPASSWD" ]; }
|
||||||
{ command = "/run/current-system/sw/bin/systemctl restart *"; options = [ "NOPASSWD" ]; }
|
{ command = "/run/current-system/sw/bin/systemctl restart lnd.service"; options = [ "NOPASSWD" ]; }
|
||||||
{ command = "/run/current-system/sw/bin/journalctl *"; options = [ "NOPASSWD" ]; }
|
{ command = "/run/current-system/sw/bin/systemctl status sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
|
||||||
|
{ command = "/run/current-system/sw/bin/systemctl status caddy.service"; options = [ "NOPASSWD" ]; }
|
||||||
|
{ command = "/run/current-system/sw/bin/systemctl status bitcoind.service"; options = [ "NOPASSWD" ]; }
|
||||||
|
{ command = "/run/current-system/sw/bin/systemctl status lnd.service"; options = [ "NOPASSWD" ]; }
|
||||||
|
# Restricted journal helper: accepts only safe flags (--unit, --lines,
|
||||||
|
# --priority, --since, --until, --output). Rejects paths, directories,
|
||||||
|
# namespaces, roots, and arbitrary output destinations.
|
||||||
|
{ command = "/run/current-system/sw/bin/python3 /etc/sovran/sovran-journal-helper.py *"; options = [ "NOPASSWD" ]; }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
+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; }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -237,7 +237,7 @@ CREDS
|
|||||||
# idempotent without exposing this service credential in the Hub UI.
|
# idempotent without exposing this service credential in the Hub UI.
|
||||||
HUB_ADMIN_CREDS="/var/lib/secrets/matrix-hub-admin"
|
HUB_ADMIN_CREDS="/var/lib/secrets/matrix-hub-admin"
|
||||||
if [ ! -s "$HUB_ADMIN_CREDS" ]; then
|
if [ ! -s "$HUB_ADMIN_CREDS" ]; then
|
||||||
HUB_ADMIN_USER="sovran-hub-$(tr -dc 'a-z0-9' < /dev/urandom | head -c 20)"
|
HUB_ADMIN_USER="sovran-hub-$(pwgen -sA0 20 1)"
|
||||||
HUB_ADMIN_PASS=$(pwgen -s 32 1)
|
HUB_ADMIN_PASS=$(pwgen -s 32 1)
|
||||||
if register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
|
if register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
|
||||||
-u "$HUB_ADMIN_USER" -p "$HUB_ADMIN_PASS" -a http://localhost:8008; then
|
-u "$HUB_ADMIN_USER" -p "$HUB_ADMIN_PASS" -a http://localhost:8008; then
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
+253
-139
@@ -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,64 +363,44 @@ 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}"
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# Update every versioned ISO filename in README.md.
|
||||||
# Step 3: Auto-update CHANGELOG.md
|
README_FILE="README.md"
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
if [[ ! -f "$README_FILE" ]]; then
|
||||||
echo
|
echo -e "${RED}Error: ${README_FILE} not found.${NC}" >&2
|
||||||
echo -e "${BLUE}Step 3: Updating ${CHANGELOG_FILE}...${NC}"
|
exit 1
|
||||||
|
fi
|
||||||
|
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" \
|
||||||
|
| head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
||||||
|
if [[ -z "$OLD_ISO_VER" ]]; then
|
||||||
|
echo -e "${RED}Error: no versioned ISO reference found in ${README_FILE}.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$OLD_ISO_VER" != "$VERSION" ]]; then
|
||||||
|
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
|
||||||
|
"$README_FILE" > "$README_FILE.tmp"
|
||||||
|
mv "$README_FILE.tmp" "$README_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add the changelog entry before tagging so the tag and stable branch contain it.
|
||||||
TODAY=$(date +%Y-%m-%d)
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
# Create new changelog entry 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
|
||||||
|
|
||||||
# Insert new section after the first --- line
|
|
||||||
awk -v new_entry="$NEW_ENTRY" '
|
awk -v new_entry="$NEW_ENTRY" '
|
||||||
BEGIN { printed=0 }
|
BEGIN { printed=0 }
|
||||||
/^---$/ && !printed {
|
/^---$/ && !printed {
|
||||||
@@ -314,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}"
|
fi
|
||||||
echo -e " ${GREEN}✓${NC} Committed changelog update"
|
git commit -m "chore(release): prepare ${TAG}"
|
||||||
|
RELEASE_COMMIT=$(git rev-parse HEAD)
|
||||||
|
|
||||||
# Ask if user wants to push
|
echo -e " ${GREEN}✓${NC} VERSION, README, and CHANGELOG committed"
|
||||||
echo
|
echo -e " Release commit: ${CYAN}${RELEASE_COMMIT}${NC}"
|
||||||
read -rp "Push the changelog commit to GitHub (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
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 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}"
|
||||||
|
|
||||||
|
# GitHub main and Gitea staging-dev were verified equal during preflight, so
|
||||||
|
# these are normal fast-forward pushes. Stable is an intentional promotion and
|
||||||
|
# uses a lease to prevent overwriting a branch changed since the fetch.
|
||||||
|
git push "$GITHUB_REMOTE" HEAD:main
|
||||||
|
git push "$GITEA_REMOTE" HEAD:staging-dev
|
||||||
|
git push "$GITEA_REMOTE" HEAD:stable --force-with-lease
|
||||||
|
|
||||||
|
# Verify all three branch tips before creating an immutable release tag.
|
||||||
|
remote_branch_commit() {
|
||||||
|
local remote="$1"
|
||||||
|
local branch="$2"
|
||||||
|
git ls-remote "$remote" "refs/heads/${branch}" | awk 'NR == 1 { print $1 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
PUBLISHED_GITHUB=$(remote_branch_commit "$GITHUB_REMOTE" main)
|
||||||
|
PUBLISHED_STAGING=$(remote_branch_commit "$GITEA_REMOTE" staging-dev)
|
||||||
|
PUBLISHED_STABLE=$(remote_branch_commit "$GITEA_REMOTE" stable)
|
||||||
|
if [[ "$PUBLISHED_GITHUB" != "$RELEASE_COMMIT" || \
|
||||||
|
"$PUBLISHED_STAGING" != "$RELEASE_COMMIT" || \
|
||||||
|
"$PUBLISHED_STABLE" != "$RELEASE_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: post-push verification failed; no release tag was created.${NC}" >&2
|
||||||
|
echo " Expected : $RELEASE_COMMIT" >&2
|
||||||
|
echo " GitHub main : ${PUBLISHED_GITHUB:-missing}" >&2
|
||||||
|
echo " Gitea staging-dev : ${PUBLISHED_STAGING:-missing}" >&2
|
||||||
|
echo " Gitea stable : ${PUBLISHED_STABLE:-missing}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e " ${GREEN}✓${NC} GitHub main, Gitea staging-dev, and Gitea stable all match"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 3: Tag the final release commit and publish the tag
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 3: Creating annotated tag ${TAG} on ${RELEASE_COMMIT}...${NC}"
|
||||||
|
git tag -a "$TAG" -m "${RELEASE_MESSAGE}
|
||||||
|
|
||||||
|
- Stable release of Sovran_SystemsOS
|
||||||
|
- See CHANGELOG.md for full details" "$RELEASE_COMMIT"
|
||||||
|
|
||||||
|
git push "$GITHUB_REMOTE" "$TAG"
|
||||||
|
git push "$GITEA_REMOTE" "$TAG"
|
||||||
|
|
||||||
|
# For an annotated tag, ^{} resolves the commit referenced by the tag object.
|
||||||
|
remote_tag_commit() {
|
||||||
|
local remote="$1"
|
||||||
|
git ls-remote "$remote" "refs/tags/${TAG}^{}" | awk 'NR == 1 { print $1 }'
|
||||||
|
}
|
||||||
|
GITHUB_TAG_COMMIT=$(remote_tag_commit "$GITHUB_REMOTE")
|
||||||
|
GITEA_TAG_COMMIT=$(remote_tag_commit "$GITEA_REMOTE")
|
||||||
|
if [[ "$GITHUB_TAG_COMMIT" != "$RELEASE_COMMIT" || "$GITEA_TAG_COMMIT" != "$RELEASE_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: published tag verification failed.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e " ${GREEN}✓${NC} ${TAG} points to the final release commit on GitHub and Gitea"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 4: Create GitHub release via gh CLI
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 4: Creating GitHub release...${NC}"
|
||||||
|
|
||||||
if command -v gh &>/dev/null; then
|
|
||||||
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
||||||
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
||||||
if gh release create "${TAG}" \
|
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 ─────────────────────────────────────────────────────
|
|
||||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
|
||||||
echo
|
|
||||||
echo -e "${YELLOW}GITEA_TOKEN is not set.${NC}"
|
|
||||||
read -rsp "Enter your Gitea API token (input will be hidden): " GITEA_TOKEN
|
|
||||||
echo
|
|
||||||
if [[ -z "$GITEA_TOKEN" ]]; then
|
|
||||||
echo -e " ${YELLOW}⚠${NC} No token provided — skipping Gitea release"
|
|
||||||
GITEA_TOKEN=""
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
|
||||||
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
||||||
|
|
||||||
# Build JSON payload safely (release body may contain quotes/newlines)
|
# Build JSON safely because release notes may contain quotes and newlines.
|
||||||
if command -v jq &>/dev/null; then
|
if command -v jq >/dev/null 2>&1; then
|
||||||
PAYLOAD=$(jq -n \
|
PAYLOAD=$(jq -n \
|
||||||
--arg tag "${TAG}" \
|
--arg tag "$TAG" \
|
||||||
--arg name "${TAG} — Stable Release" \
|
--arg name "$TAG — Stable Release" \
|
||||||
--arg body "${RELEASE_BODY}" \
|
--arg body "$RELEASE_BODY" \
|
||||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||||
else
|
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}")
|
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
|
||||||
|
|
||||||
RESPONSE=$(curl -s -X POST \
|
RESPONSE_FILE=$(mktemp "/tmp/gitea-release-${TAG}.XXXXXX.json")
|
||||||
|
HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' -X POST \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "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 "${RED}Error: Gitea release creation failed (HTTP ${HTTP_STATUS}).${NC}" >&2
|
||||||
|
cat "$RESPONSE_FILE" >&2
|
||||||
|
rm -f "$RESPONSE_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "$RESPONSE_FILE"
|
||||||
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
||||||
elif echo "$RESPONSE" | grep -q "write:repository"; then
|
|
||||||
echo -e " ${YELLOW}⚠${NC} Gitea token scope issue: your token requires the 'write:repository' scope (currently has write:package)."
|
|
||||||
echo " To fix: In Gitea, navigate to Settings → Applications → Manage Access Tokens and generate a token with 'write:repository'."
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} Gitea release creation failed or already exists"
|
|
||||||
echo " Response: $RESPONSE"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Final Summary
|
# Final Summary
|
||||||
@@ -438,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}"
|
||||||
|
|||||||
+54
-23
@@ -7,7 +7,15 @@
|
|||||||
# Usage:
|
# Usage:
|
||||||
# ./scripts/upload-cdn.sh [--upload]
|
# ./scripts/upload-cdn.sh [--upload]
|
||||||
#
|
#
|
||||||
|
# EVERYTHING this script creates lives OUTSIDE the repository, even when you
|
||||||
|
# run it from inside the repo:
|
||||||
|
# - the nix build output symlink -> $ISO_OUT_DIR/result
|
||||||
|
# - the ISO and .sha256 files -> $ISO_OUT_DIR
|
||||||
|
# The repo working tree stays completely clean.
|
||||||
|
#
|
||||||
# Environment variables (optional):
|
# Environment variables (optional):
|
||||||
|
# ISO_OUT_DIR - dir for the build symlink + ISO + checksum
|
||||||
|
# (default: ~/Sovran-builds)
|
||||||
# CDN_RSYNC_TARGET - rsync destination (e.g. user@server:/var/www/downloads/)
|
# CDN_RSYNC_TARGET - rsync destination (e.g. user@server:/var/www/downloads/)
|
||||||
# CDN_RCLONE_REMOTE - rclone remote target (e.g. s3:my-bucket/downloads/)
|
# CDN_RCLONE_REMOTE - rclone remote target (e.g. s3:my-bucket/downloads/)
|
||||||
# CDN_UPLOAD_CMD - custom upload command
|
# CDN_UPLOAD_CMD - custom upload command
|
||||||
@@ -22,65 +30,88 @@ BLUE='\033[0;34m'
|
|||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Always operate from the repo root, no matter where the script is invoked from
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
# Read version from VERSION file
|
# Read version from VERSION file
|
||||||
if [ ! -f VERSION ]; then
|
if [ ! -f VERSION ]; then
|
||||||
echo -e "${RED}Error: VERSION file not found.${NC}" >&2
|
echo -e "${RED}Error: VERSION file not found in $REPO_ROOT.${NC}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
VERSION=$(cat VERSION | tr -d '\n\r ')
|
VERSION=$(cat VERSION | tr -d '\n\r ')
|
||||||
ISO_NAME="Sovran_SystemsOS-${VERSION}.iso"
|
ISO_NAME="Sovran_SystemsOS-${VERSION}.iso"
|
||||||
SHA_NAME="${ISO_NAME}.sha256"
|
SHA_NAME="${ISO_NAME}.sha256"
|
||||||
|
|
||||||
|
# Output directory OUTSIDE the repo (override with ISO_OUT_DIR)
|
||||||
|
OUT_DIR="${ISO_OUT_DIR:-$HOME/Sovran-builds}"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
OUT_DIR="$(cd "$OUT_DIR" && pwd)"
|
||||||
|
ISO_PATH="$OUT_DIR/${ISO_NAME}"
|
||||||
|
SHA_PATH="$OUT_DIR/${SHA_NAME}"
|
||||||
|
RESULT_LINK="$OUT_DIR/result"
|
||||||
|
|
||||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||||
echo -e "${BLUE}║ Sovran_SystemsOS CDN ISO Packaging & Upload Tool ║${NC}"
|
echo -e "${BLUE}║ Sovran_SystemsOS CDN ISO Packaging & Upload Tool ║${NC}"
|
||||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||||
echo -e " Version : ${CYAN}${VERSION}${NC}"
|
echo -e " Version : ${CYAN}${VERSION}${NC}"
|
||||||
echo -e " ISO : ${CYAN}${ISO_NAME}${NC}"
|
echo -e " ISO : ${CYAN}${ISO_NAME}${NC}"
|
||||||
|
echo -e " Repo : ${CYAN}${REPO_ROOT}${NC} (left untouched)"
|
||||||
|
echo -e " Output : ${CYAN}${OUT_DIR}${NC} (outside the repo)"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
# Locate or build ISO
|
# Locate or build ISO
|
||||||
SRC_ISO=""
|
SRC_ISO=""
|
||||||
if [ -f "result/iso/${ISO_NAME}" ]; then
|
if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
|
||||||
SRC_ISO="result/iso/${ISO_NAME}"
|
SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
|
||||||
|
elif [ -f "$RESULT_LINK/iso/Sovran_SystemsOS.iso" ]; then
|
||||||
|
SRC_ISO="$RESULT_LINK/iso/Sovran_SystemsOS.iso"
|
||||||
|
elif [ -f "result/iso/${ISO_NAME}" ]; then
|
||||||
|
SRC_ISO="result/iso/${ISO_NAME}" # legacy: from an older in-repo build
|
||||||
elif [ -f "result/iso/Sovran_SystemsOS.iso" ]; then
|
elif [ -f "result/iso/Sovran_SystemsOS.iso" ]; then
|
||||||
SRC_ISO="result/iso/Sovran_SystemsOS.iso"
|
SRC_ISO="result/iso/Sovran_SystemsOS.iso"
|
||||||
else
|
else
|
||||||
FOUND=$(find result -name "*.iso" 2>/dev/null | head -n 1 || true)
|
FOUND=$(find "$RESULT_LINK" result -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||||
if [ -n "$FOUND" ]; then
|
if [ -n "$FOUND" ]; then
|
||||||
SRC_ISO="$FOUND"
|
SRC_ISO="$FOUND"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
|
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
|
||||||
echo -e "${YELLOW}Built ISO not found in result/. Building ISO via Nix...${NC}"
|
echo -e "${YELLOW}Built ISO not found. Building via Nix (result link goes outside the repo)...${NC}"
|
||||||
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
rm -f "$RESULT_LINK"
|
||||||
|
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage \
|
||||||
|
--out-link "$RESULT_LINK"
|
||||||
|
|
||||||
if [ -f "result/iso/${ISO_NAME}" ]; then
|
if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
|
||||||
SRC_ISO="result/iso/${ISO_NAME}"
|
SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
|
||||||
else
|
else
|
||||||
FOUND=$(find result -name "*.iso" 2>/dev/null | head -n 1 || true)
|
FOUND=$(find "$RESULT_LINK" -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||||
if [ -n "$FOUND" ]; then
|
if [ -n "$FOUND" ]; then
|
||||||
SRC_ISO="$FOUND"
|
SRC_ISO="$FOUND"
|
||||||
else
|
|
||||||
echo -e "${RED}Error: Failed to locate built ISO in result/.${NC}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${BLUE}Step 1: Copying built ISO out of Nix store...${NC}"
|
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
|
||||||
cp -v "$SRC_ISO" "./${ISO_NAME}"
|
echo -e "${RED}Error: Failed to locate built ISO.${NC}" >&2
|
||||||
echo -e " ${GREEN}✓${NC} Copied to ./${ISO_NAME}"
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}Step 1: Copying built ISO to output dir (outside repo)...${NC}"
|
||||||
|
cp -v "$SRC_ISO" "$ISO_PATH"
|
||||||
|
echo -e " ${GREEN}✓${NC} Copied to $ISO_PATH"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Step 2: Generating versioned SHA-256 checksum...${NC}"
|
echo -e "${BLUE}Step 2: Generating versioned SHA-256 checksum...${NC}"
|
||||||
sha256sum "${ISO_NAME}" > "${SHA_NAME}"
|
sha256sum "$ISO_PATH" > "$SHA_PATH"
|
||||||
echo -e " ${GREEN}✓${NC} Generated ${SHA_NAME}"
|
echo -e " ${GREEN}✓${NC} Generated $SHA_PATH"
|
||||||
cat "${SHA_NAME}"
|
cat "$SHA_PATH"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Step 3: Verifying checksum...${NC}"
|
echo -e "${BLUE}Step 3: Verifying checksum...${NC}"
|
||||||
sha256sum --check "${SHA_NAME}"
|
sha256sum --check "$SHA_PATH"
|
||||||
echo -e " ${GREEN}✓${NC} Checksum verified successfully"
|
echo -e " ${GREEN}✓${NC} Checksum verified successfully"
|
||||||
|
|
||||||
# Parse arguments for upload
|
# Parse arguments for upload
|
||||||
@@ -107,13 +138,13 @@ if [ "$DO_UPLOAD" -eq 1 ]; then
|
|||||||
|
|
||||||
if [ -n "${CDN_RSYNC_TARGET:-}" ]; then
|
if [ -n "${CDN_RSYNC_TARGET:-}" ]; then
|
||||||
echo -e " Uploading via rsync to ${CDN_RSYNC_TARGET}..."
|
echo -e " Uploading via rsync to ${CDN_RSYNC_TARGET}..."
|
||||||
rsync -avP "${ISO_NAME}" "${SHA_NAME}" "${CDN_RSYNC_TARGET}"
|
rsync -avP "$ISO_PATH" "$SHA_PATH" "${CDN_RSYNC_TARGET}"
|
||||||
UPLOADED=1
|
UPLOADED=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "${CDN_RCLONE_REMOTE:-}" ]; then
|
if [ -n "${CDN_RCLONE_REMOTE:-}" ]; then
|
||||||
echo -e " Uploading via rclone to ${CDN_RCLONE_REMOTE}..."
|
echo -e " Uploading via rclone to ${CDN_RCLONE_REMOTE}..."
|
||||||
rclone copy "${ISO_NAME}" "${SHA_NAME}" "${CDN_RCLONE_REMOTE}"
|
rclone copy "$ISO_PATH" "$SHA_PATH" "${CDN_RCLONE_REMOTE}"
|
||||||
UPLOADED=1
|
UPLOADED=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -128,8 +159,8 @@ else
|
|||||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||||
echo -e "${GREEN}║ ✅ ISO packaging & verification complete! ║${NC}"
|
echo -e "${GREEN}║ ✅ ISO packaging & verification complete! ║${NC}"
|
||||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||||
echo -e "Ready files:"
|
echo -e "Ready files (in ${CYAN}${OUT_DIR}${NC}, outside the repo):"
|
||||||
ls -lh "${ISO_NAME}" "${SHA_NAME}"
|
ls -lh "$ISO_PATH" "$SHA_PATH"
|
||||||
echo
|
echo
|
||||||
echo "To upload to CDN, run:"
|
echo "To upload to CDN, run:"
|
||||||
echo " ./scripts/upload-cdn.sh --upload"
|
echo " ./scripts/upload-cdn.sh --upload"
|
||||||
|
|||||||
@@ -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()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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