Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8b169a04e3 | ||
|
|
d0c2ea933c | ||
|
|
b66c50c195 | ||
|
|
1737e6c7d8 | ||
|
|
782b279e0a | ||
|
|
564e9835ea | ||
|
|
3b8f809e7c | ||
|
|
9df52256d4 | ||
|
|
de481db97c | ||
|
|
9051aed737 | ||
|
|
65376ec8fb | ||
|
|
899f570ded | ||
|
|
d3b41b14fb | ||
|
|
56db634900 | ||
|
|
3522270373 | ||
|
|
efda70c187 | ||
|
|
a32b353eda | ||
|
|
4555764d87 | ||
|
|
c38d4be851 | ||
|
|
5130076400 | ||
|
|
1dbbd6a943 | ||
|
|
f0a640519d | ||
|
|
a14ef03d40 | ||
|
|
a0cc7e6bec | ||
|
|
1ecf245a07 | ||
|
|
f8730cc0ab | ||
|
|
8a766181de | ||
|
|
bd0d2cd812 | ||
|
|
592f2bd12f | ||
|
|
b7bba228fe | ||
|
|
3010557064 | ||
|
|
69f64c18d1 | ||
|
|
942da64332 | ||
|
|
bae790ebb4 | ||
|
|
501c5abe64 | ||
|
|
63f1069e4a | ||
|
|
f2842ffafb | ||
|
|
591ae766d2 | ||
|
|
f81b27cc19 | ||
|
|
dda6fb8e91 | ||
|
|
cf250604f9 | ||
|
|
963d41f1ed | ||
|
|
5188cca9aa | ||
|
|
f39f59fb81 | ||
|
|
61fc61cceb | ||
|
|
4b9800aef3 | ||
|
|
c447674ef9 | ||
|
|
a77bfc5070 | ||
|
|
7b381d0387 | ||
|
|
ec43d631d4 | ||
|
|
82a9952e63 | ||
|
|
1eb4be2c01 | ||
|
|
6f1edb938e | ||
|
|
bd96782229 | ||
|
|
cf23438a5c | ||
|
|
d2c3fd066e | ||
|
|
1bfec68d77 | ||
|
|
bda1372a1d | ||
|
|
13c700ca44 | ||
|
|
f6f87d6b03 | ||
|
|
4f75117540 | ||
|
|
2bb02e50d9 | ||
|
|
4d1c3565c9 | ||
|
|
3678aedc88 | ||
|
|
991c790816 | ||
|
|
a343afffaa | ||
|
|
eeef81ffc7 | ||
|
|
bc6b3f0843 | ||
|
|
f01e776f34 | ||
|
|
a19fa53468 | ||
|
|
baefca0c68 | ||
|
|
9ed6040425 | ||
|
|
ca17b381f6 | ||
|
|
3f74388359 | ||
|
|
a814ed332f | ||
|
|
94e4412e67 | ||
|
|
670cfb9acd | ||
|
|
2015a3ecd4 | ||
|
|
2622223033 | ||
|
|
d0ec6dd760 | ||
|
|
90b1c45a0d | ||
|
|
0ac1529a1d | ||
|
|
1b579271e5 | ||
|
|
c6b4b89be3 | ||
|
|
a8ff366992 | ||
|
|
ddba59cce8 | ||
|
|
e12e9ba01b | ||
|
|
853c3edf92 | ||
|
|
4a6ba3eacc | ||
|
|
537d4781f2 |
+261
-1
@@ -7,6 +7,266 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- 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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-08-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Speed up Hub service status loading
|
||||||
|
- Update Documentation
|
||||||
|
- Njalla.nix: fail on ImportError; fix redundant except clause
|
||||||
|
- Correctly escape DDNS placeholder in Nix string
|
||||||
|
- Security hardening: fix all 8 blocking findings for PR #419
|
||||||
|
- Security hardening: fix DDNS injection, Nix injection, reboot auth, support key, sudo rules
|
||||||
|
- Harden btcpay and nbxplorer config handling
|
||||||
|
- Add provenance headers and fix flake checks for PR409
|
||||||
|
- Restore upstream nix-bitcoin fetchNodeModules for mempool and RTL packages
|
||||||
|
- Refactor Nix build configuration for mempool
|
||||||
|
- Update npm dependencies and patch handling in default.nix
|
||||||
|
- Update npmDepsHash for backend and frontend packages
|
||||||
|
- Mempool: use postPatch to copy lock file for npmDeps fetcher
|
||||||
|
- Mempool: use preBuild cd instead of postUnpack so npmDeps fetcher finds lock files
|
||||||
|
- Mempool: fix npmDeps fetcher — postUnpack instead of sourceRoot, postPatch instead of patches
|
||||||
|
- Mempool: switch backend and frontend to buildNpmPackage with placeholder npmDepsHash
|
||||||
|
- Update npmDepsHash with correct hash value
|
||||||
|
- RTL: switch to buildNpmPackage with placeholder npmDepsHash
|
||||||
|
- Add bitcoind.rpc.users.btcpayserver for NBXplorer
|
||||||
|
- Restore original flake.nix with nixosModules.Sovran_SystemsOS export
|
||||||
|
- Add vendored RTL package and fix rtl.nix to use it
|
||||||
|
- Vendor mempool packages and wire mempool module to vendored pkgs
|
||||||
|
- Refactor onion service configuration for LND
|
||||||
|
- Add missing bitcoind-rpc-public-whitelist.nix
|
||||||
|
- Remove joinmarket-ob-watcher from onion-services defaults
|
||||||
|
- Tailor: make bitcoin modules truly Sovran-only (lnd-only) and delete stubs.nix
|
||||||
|
- Vendor: replace nix-bitcoin flake input with minimal vendored modules (nixpkgs-only)
|
||||||
|
- Remove duplicate 1.0.6 release notes from CHANGELOG
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Clean mempool module comment typo
|
||||||
|
- Move vendor/nix-bitcoin to modules/bitcoin, remove overlays
|
||||||
|
- Nix flake update - drop nix-bitcoin
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Correct RTL and Mempool Hub versions
|
||||||
|
- Fix DDNS URL validation: replace ${IP} temporarily for validator, keep placeholder for storage
|
||||||
|
- Fix all 8 security hardening blockers for PR #423
|
||||||
|
- Fix Nix interpolation in DDNS runner
|
||||||
|
- Fix IP validation in DDNS and document journalctl sudo rule
|
||||||
|
- Report configured BTCPay Server version
|
||||||
|
- Fix Matrix SIGPIPE and RTL v0.15.8 config schema regressions
|
||||||
|
- Add NBXplorer cookie auth and WorkingDirectory for BTCPay service
|
||||||
|
- Fix BTCPay startup regression: add home dirs to service users
|
||||||
|
- Address btcpay hardening review feedback
|
||||||
|
- Register bitcoin-HMAC-btcpayserver as managed secret owned by bitcoind user
|
||||||
|
- Fix RTL: use fetchNodeModules instead of npm ci in buildPhase
|
||||||
|
- Fix PostgreSQL ensureUsers: use ensureDBOwnership instead of ensureClauses
|
||||||
|
- Fix lnd macaroons: replace invalid 'enable' with 'user'
|
||||||
|
- Fix postgresql ensurePermissions -> ensureClauses for nixpkgs unstable
|
||||||
|
- Fix btcpayserver.nix to use pkgs.stable overlay
|
||||||
|
- Fix btcpayserver syntax error and pin to nixpkgs-stable (2.4.2)
|
||||||
|
- Fix mempool package: remove fetchNodeModules dependency
|
||||||
|
- Fix typo in onion-addresses service check
|
||||||
|
- Fix formatting of extraGroups in btcpayserver.nix
|
||||||
|
- Add missing semicolon after extraGroups in btcpayserver.nix
|
||||||
|
- Remove services.clightning.enable assignment that fails on f13ff45
|
||||||
|
- Remove clightning and clightning-rest from stubs to avoid duplicate on f13ff45
|
||||||
|
- Add stubs for nixpkgs-unstable 2026-08 where services.clightning removed
|
||||||
|
- Keep ISO artifacts out of the repo and auto-update README on release
|
||||||
|
[1.1.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.0
|
||||||
|
|
||||||
|
|
||||||
|
## [1.0.6] - 2026-08-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Iso: fix replaceStrings length mismatch in cleanVersion
|
||||||
|
- Promote virtual machine trial option in README
|
||||||
|
- Improve installer VM compatibility
|
||||||
|
- Trim redundant dev-vs-stable Gitea explanations in README
|
||||||
|
- Add Arean.Ai to AI tools used for development
|
||||||
|
- Prefix OS version badge with 'v' (v1.0.5)
|
||||||
|
- Match OS version badge styling to service modal version badges
|
||||||
|
- Place OS version badge inline right after the Hub title
|
||||||
|
- Center OS version badge under the Hub title
|
||||||
|
- Strengthen entropy section: add DYOR emphasis and hardware wallet verification note
|
||||||
|
- Add 'About Bitcoin wallet entropy' section to README
|
||||||
|
- README: balance intro between Bitcoin sovereignty and sovereign computing
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Removed temp patch file
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Resolve tag range detection and improve release diagnostics
|
||||||
|
- Add token scope diagnostics and un-silence gh release errors
|
||||||
|
- Harden error handling and sanitize exception details across security endpoints (CWE-209)
|
||||||
|
- Sanitize exception handling in verify-integrity and security-reset (CWE-209)
|
||||||
|
- Sanitize api_security_reset errors to prevent exception information exposure (CWE-209)
|
||||||
|
- Use canonical prefix containment check for CodeQL path-injection
|
||||||
|
- Pass sanitized abs_path to os.chown to resolve CodeQL path injection at 4338
|
||||||
|
- Add CodeQL-recognized path sanitization for domain_name
|
||||||
|
- Remove domain substring check for CodeQL incomplete-url
|
||||||
|
- Use sentinel for njalla header check (CodeQL incomplete-url-substring)
|
||||||
|
- Prevent reflected XSS in lnurl-qr print endpoint
|
||||||
|
- Hash root password instead of storing in clear text (CWE-312)
|
||||||
|
- Separate web auth hash from system password file
|
||||||
|
- Fix Matrix Hub admin API credentials
|
||||||
|
- Fix CWE-78: replace subprocess call with Synapse Admin API in create-user endpoint
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Add SECURITY.md detailing security policy and best practices
|
||||||
|
- Versioned CDN downloads + add CDN upload script
|
||||||
|
- Correct active development workflow
|
||||||
|
- Make repo references mirror-neutral for Gitea readers
|
||||||
|
- Clarify GitHub is the dev mirror of the Gitea stable repo
|
||||||
|
[1.0.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.6
|
||||||
|
|
||||||
|
## [1.0.5] - 2026-08-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- LND Update to v0.21.1-beta
|
||||||
|
- Nixpkgs Update
|
||||||
|
- Nixpkgs Update
|
||||||
|
- Update LND REST Zeus Connect instructions and design to match NWC exactly
|
||||||
|
- UI: Align LND REST and NWC Zeus connection instructions styling
|
||||||
|
- Make Zeus LND REST instructions coherent with NWC Zeus connect guide
|
||||||
|
- Clarify Zeus NWC wallet setup
|
||||||
|
- Make Manual Backup match the system role (Desktop Only scope)
|
||||||
|
- Reload Caddy immediately when a domain (or ACME email) is saved
|
||||||
|
- Unify Njal.la + router port-forwarding guidance across onboarding and feature enable modals
|
||||||
|
- Enhance Lightning Wallet Connect modal with professional benefits grid and refined messaging
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Resolve 'vdev' version badge and align it under the Hub title
|
||||||
|
- Align release script with Gitea staging-dev and stable branch workflow
|
||||||
|
- Auto-detect git remotes and clean up temp files in release-stable.sh
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Write real v1.0.4 changelog and auto-generate release notes in release script
|
||||||
|
[1.0.5]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.5
|
||||||
|
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Report the version of the configured BTCPay Server package in the Hub instead of the different package version from unstable nixpkgs.
|
||||||
|
- Installer VM compatibility: legacy BIOS VM boots now install with GRUB, UEFI VM installs avoid depending on NVRAM boot-entry writes, VM users get clearer disk/resource guidance, and the internet check falls back to HTTPS when ICMP is blocked.
|
||||||
|
- **Manual Backup now matches the system role**: on the Desktop Only role the Hub's
|
||||||
|
"What gets backed up" list no longer shows Node / Server + Desktop items
|
||||||
|
(nix-bitcoin secrets, `/var/lib` system service data, and the database/blockchain
|
||||||
|
caveats), and the backup script skips those stages entirely — Desktop Only backups
|
||||||
|
now mirror only the NixOS configuration (`/etc/nixos`) and home directory (`/home`).
|
||||||
|
The free-space estimate, stage numbering, backup manifest, and completion message
|
||||||
|
are all role-aware; Node and Server + Desktop backups are unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.4] - 2026-07-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Lightning Wallet Connections (NWC)** — Hub-managed Nostr Wallet Connect powered by Alby Hub + LND:
|
||||||
|
- Create, view, and delete wallet connections directly from the Sovran Hub with a tabbed modal UI
|
||||||
|
- Downloadable/printable LNURL QR codes for connecting external wallets
|
||||||
|
- Channel liquidity guide and onboarding guidance for new LND nodes
|
||||||
|
- Unique-hostname enforcement with conflict validation and UI guidance
|
||||||
|
- Official NWC branding and logo
|
||||||
|
- **Version visibility across the Hub**:
|
||||||
|
- Sovran_SystemsOS version badge displayed under the Hub title
|
||||||
|
- Version numbers shown on all Hub service tiles and next to service modal titles
|
||||||
|
- Deployed PHP app versions surfaced in service titles
|
||||||
|
- Build-time version reference file (`VERSION`) so version lookups are instantaneous and consistent
|
||||||
|
- **Automated stable release workflow** (`scripts/release-stable.sh`) with versioned ISO naming from the `VERSION` file
|
||||||
|
- `CONTRIBUTING.md` and expanded project documentation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Manual Backup overhauled**: replaced tar+DB+LND archive approach with a reliable ext4 + rsync workflow, including
|
||||||
|
mount checks, path safety, atomic completion markers, stale-marker cleanup, and behavioral test coverage
|
||||||
|
- **Port-forwarding UX simplified**: removed onboarding Step 4 and the misleading local "ready" status;
|
||||||
|
Njal.la DDNS now runs automatically when the feature is enabled
|
||||||
|
- README restructured for clarity, links, and accuracy; added router/ISP port-forwarding requirements
|
||||||
|
for Server + Desktop mode; acknowledged LiveKit and Alby Hub
|
||||||
|
- Updated nixpkgs and Bitcoin clients
|
||||||
|
- Repository cleanup: removed unused `.github`, `.tests`, `nix/`, and `docs/ai` directories
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- NWC wallet certificate path wiring (now uses the nix-bitcoin LND cert path)
|
||||||
|
- Deterministic LND/Alby Hub port collision
|
||||||
|
- Alby Hub executable resolution and v1.23.0 patches regenerated against exact upstream source
|
||||||
|
- Manual Backup exit-code failures (bash/gawk in service PATH, tar tolerance, flock, browser-cache exclusions)
|
||||||
|
- rsync destination-directory failures in backups (auto `mkdir -p`, mount check, 19 behavioral tests)
|
||||||
|
- `sovran-hosts-update` converted to `writeShellApplication` with explicit runtime inputs
|
||||||
|
- Incorrect `lib.mkIf` usage in the NWC wallets module
|
||||||
|
- Duplicate systemd LND strings
|
||||||
|
- `git fetch` tag-clobber errors against Gitea (now uses `--force`)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Hardened Lightning Wallet Connections (NWC): restricted `ReadOnlyPaths` for the unlock password,
|
||||||
|
fixed an Authorization-header bug, eliminated stack-trace exposure flagged by CodeQL,
|
||||||
|
and tightened credential access and amount validation
|
||||||
|
|
||||||
|
[1.0.4]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.4
|
||||||
|
|
||||||
|
|
||||||
## [1.0.3] - 2026-07-29
|
## [1.0.3] - 2026-07-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -30,7 +290,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Integrated Sparrow Wallet, Bisq, and Bisq 2
|
- Integrated Sparrow Wallet, Bisq, and Bisq 2
|
||||||
- Comprehensive Sovran Hub for service management
|
- Comprehensive Sovran Hub for service management
|
||||||
- NixOS-based operating system with privacy and sovereignty focus
|
- NixOS-based operating system with privacy and sovereignty focus
|
||||||
- Support for Bitcoin Knots + BIP110, Electrs, LND, Ride The Lightning, BTCPay Server, and more
|
- Support for Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and more
|
||||||
- Server + Desktop hybrid mode with Matrix, Nextcloud, VaultWarden, and other self-hosted services
|
- Server + Desktop hybrid mode with Matrix, Nextcloud, VaultWarden, and other self-hosted services
|
||||||
- Automated installer with graphical GNOME desktop
|
- Automated installer with graphical GNOME desktop
|
||||||
- Tor integration and onion services for all major components
|
- Tor integration and onion services for all major components
|
||||||
|
|||||||
+34
-5
@@ -2,13 +2,35 @@
|
|||||||
|
|
||||||
First off, thank you for considering contributing to Sovran_SystemsOS! 🎉
|
First off, thank you for considering contributing to Sovran_SystemsOS! 🎉
|
||||||
|
|
||||||
## This Github Repo
|
## Development and Release Repositories
|
||||||
|
|
||||||
This repo is for the development of Sovran_SystemsOS and serves as its connection to the GitHub ecosystem. The main repo for Sovran_SystemsOS is hosted at https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable. All activity in this GitHub repo is tested and eventually mirrored to the main repo on Gitea, as the ethos of Sovran_SystemsOS is self-sovereignty.
|
The GitHub repository is the primary location for Sovran_SystemsOS development
|
||||||
|
and collaboration and serves as the project's connection to the GitHub
|
||||||
|
ecosystem. Most work is contributed through feature branches and pull requests
|
||||||
|
that target GitHub `main`. Development may also begin in a local branch or on
|
||||||
|
`staging-dev` at the project's self-hosted Gitea instance:
|
||||||
|
https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS.
|
||||||
|
|
||||||
Please note: this repo may contain new features and code not yet in the stable branch on Gitea, and this code is not fully tested.
|
GitHub `main` and Gitea `staging-dev` are kept synchronized as the active
|
||||||
|
development line, although one may briefly lead the other while changes are
|
||||||
|
being integrated. The canonical, release-ready code is maintained on Gitea's
|
||||||
|
[`stable` branch](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable),
|
||||||
|
in keeping with the self-sovereignty ethos of Sovran_SystemsOS.
|
||||||
|
|
||||||
Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used Copilot to work through significant coding challenges and troubleshooting hurdles. We will continue to use AI to help keep Sovran_SystemsOS stable and maintained.
|
The workflow is:
|
||||||
|
|
||||||
|
1. Most development and project collaboration — including issues, pull
|
||||||
|
requests, and reviews — happens through GitHub, with completed work
|
||||||
|
integrated into `main`.
|
||||||
|
2. Work may also originate locally or on Gitea `staging-dev`. Accepted changes
|
||||||
|
are synchronized between GitHub `main` and Gitea `staging-dev`.
|
||||||
|
3. When changes are complete and tested, they are promoted to Gitea `stable`,
|
||||||
|
which is the code used by released Sovran_SystemsOS builds.
|
||||||
|
|
||||||
|
Please note: GitHub `main` and Gitea `staging-dev` may contain new features and
|
||||||
|
code not yet in `stable`, and that code may not be fully tested.
|
||||||
|
|
||||||
|
Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used Copilot and Arena.ai to work through significant coding challenges and troubleshooting hurdles. We will continue to use AI to help keep Sovran_SystemsOS stable and maintained.
|
||||||
|
|
||||||
## How Can I Contribute?
|
## How Can I Contribute?
|
||||||
|
|
||||||
@@ -26,7 +48,7 @@ Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used C
|
|||||||
### 🔧 Submitting Code Changes
|
### 🔧 Submitting Code Changes
|
||||||
|
|
||||||
#### 1. Fork the Repository
|
#### 1. Fork the Repository
|
||||||
Click the "Fork" button at the top right of this repository.
|
Click the "Fork" button at the top right of the GitHub repository.
|
||||||
|
|
||||||
#### 2. Clone Your Fork
|
#### 2. Clone Your Fork
|
||||||
```bash
|
```bash
|
||||||
@@ -70,6 +92,13 @@ git push origin feature/your-feature-name
|
|||||||
- **Do not push directly to `main`.** Always use a feature branch and open a PR.
|
- **Do not push directly to `main`.** Always use a feature branch and open a PR.
|
||||||
- **Be patient.** PRs will be reviewed as soon as possible.
|
- **Be patient.** PRs will be reviewed as soon as possible.
|
||||||
|
|
||||||
|
## Security-Sensitive Changes
|
||||||
|
|
||||||
|
- Report vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md).
|
||||||
|
- Pin upstream revisions and hashes; do not use floating source references.
|
||||||
|
- Update [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) when importing code.
|
||||||
|
- Add regression tests and describe any threat-model change in the pull request.
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
- Follow the existing patterns in the codebase
|
- Follow the existing patterns in the codebase
|
||||||
|
|||||||
@@ -4,25 +4,26 @@
|
|||||||
|
|
||||||
# Sovran_SystemsOS
|
# Sovran_SystemsOS
|
||||||
|
|
||||||
### Your Bitcoin life. Your keys. Your node. Your machine.
|
### Bitcoin sovereignty. Sovereign computing. One system.
|
||||||
|
|
||||||
Sovran_SystemsOS is a free and open-source operating system for **Bitcoin
|
Sovran_SystemsOS is a free and open-source operating system built for two
|
||||||
self-custody** and **digital sovereignty**. Hold your own keys, trade
|
inseparable freedoms: **Bitcoin self-custody** and **sovereign computing**.
|
||||||
peer-to-peer, and verify your own money with your own node. Then extend the
|
Hold your own keys, trade peer-to-peer, and verify your own money with your
|
||||||
same ownership to the rest of your digital life: your files, communications,
|
own node. Then claim that same uncompromising ownership over the rest of your
|
||||||
passwords, and websites, all on hardware you control.
|
digital life — your files, communications, passwords, and websites — all on
|
||||||
|
hardware you control, running auditable open-source software you can trust.
|
||||||
|
|
||||||
Every installation is a private [NixOS](https://nixos.org) desktop with
|
Every installation is a private [NixOS](https://nixos.org) desktop with
|
||||||
[Sparrow Wallet](https://sparrowwallet.com), [Bisq](https://bisq.network), and
|
[Sparrow Wallet](https://sparrowwallet.com), [Bisq](https://bisq.network), and
|
||||||
[Bisq 2](https://github.com/bisq-network/bisq2) ready to use. Move beyond
|
[Bisq 2](https://github.com/bisq-network/bisq2) ready to use. Move beyond
|
||||||
custodial exchanges from the first boot, and grow into your own Bitcoin and
|
custodial exchanges from the first boot, and grow into your own Bitcoin node,
|
||||||
Lightning infrastructure when you are ready.
|
Lightning infrastructure, private cloud, and communications platform when you
|
||||||
|
are ready.
|
||||||
**Privacy. Sovereignty. Bitcoin.**
|
|
||||||
|
|
||||||
[Visit the Website](https://sovransystems.com) ·
|
[Visit the Website](https://sovransystems.com) ·
|
||||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.3.iso) ·
|
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso) ·
|
||||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS.iso.sha256) ·
|
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||||
|
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.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.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||||
@@ -31,17 +32,26 @@ Lightning infrastructure when you are ready.
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
> **📌 Active development on GitHub `main` / Gitea `staging-dev` — releases on
|
||||||
|
> [Gitea `stable`](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable).**
|
||||||
|
> See [Development workflow](#development-workflow) for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- [Why Sovran_SystemsOS?](#why-sovran_systemsos)
|
- [Why Sovran_SystemsOS?](#why-sovran_systemsos)
|
||||||
|
- [Try it first in a virtual machine](#try-it-first-in-a-virtual-machine)
|
||||||
- [What is included](#what-is-included)
|
- [What is included](#what-is-included)
|
||||||
- [Three modes](#three-modes)
|
- [Three modes](#three-modes)
|
||||||
- [Use it your way](#use-it-your-way)
|
- [Use it your way](#use-it-your-way)
|
||||||
- [The Sovran Hub](#the-sovran-hub)
|
- [The Sovran Hub](#the-sovran-hub)
|
||||||
- [Install Sovran_SystemsOS](#install-sovran_systemsos)
|
- [Install Sovran_SystemsOS](#install-sovran_systemsos)
|
||||||
- [For developers](#for-developers)
|
- [For developers](#for-developers)
|
||||||
|
- [Development workflow](#development-workflow)
|
||||||
|
- [Build from source](#build-from-source)
|
||||||
|
- [Publishing a release](#publishing-a-release)
|
||||||
|
- [About Bitcoin wallet entropy](#about-bitcoin-wallet-entropy)
|
||||||
- [Security approach](#security-approach)
|
- [Security approach](#security-approach)
|
||||||
- [Acknowledgements](#acknowledgements)
|
- [Acknowledgements](#acknowledgements)
|
||||||
- [License](#license) · [Contributing](#contributing)
|
- [License](#license) · [Contributing](#contributing)
|
||||||
@@ -63,8 +73,7 @@ Sovran_SystemsOS solves both with one operating system:
|
|||||||
- **Buy and sell Bitcoin peer-to-peer** with Bisq and Bisq 2. No central
|
- **Buy and sell Bitcoin peer-to-peer** with Bisq and Bisq 2. No central
|
||||||
company holds user funds, and no exchange account stands between buyers and
|
company holds user funds, and no exchange account stands between buyers and
|
||||||
sellers.
|
sellers.
|
||||||
- **Verify your own Bitcoin** with a full node: [Bitcoin
|
- **Verify your own Bitcoin** with a full node: [Bitcoin Core](https://bitcoin.org) and
|
||||||
Knots](https://bitcoinknots.org) and
|
|
||||||
[Electrs](https://github.com/romanz/electrs), so your wallets connect to
|
[Electrs](https://github.com/romanz/electrs), so your wallets connect to
|
||||||
*your* node instead of a stranger's.
|
*your* node instead of a stranger's.
|
||||||
- **Use Lightning** with [LND](https://github.com/lightningnetwork/lnd) and
|
- **Use Lightning** with [LND](https://github.com/lightningnetwork/lnd) and
|
||||||
@@ -98,6 +107,40 @@ and a reproducible, auditable NixOS foundation.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Try it first in a virtual machine
|
||||||
|
|
||||||
|
**Curious, but not ready to replace your current operating system?** Start with
|
||||||
|
Sovran_SystemsOS in a virtual machine (VM). A VM runs Sovran_SystemsOS in a
|
||||||
|
window on your existing Windows, macOS, or Linux computer, using a virtual disk
|
||||||
|
file instead of your computer's internal drive. You can explore the desktop,
|
||||||
|
Sovran Hub, Sparrow, Bisq, and the installation experience before changing how
|
||||||
|
you use any physical machine.
|
||||||
|
|
||||||
|
This is a low-commitment way to decide whether Sovran_SystemsOS is right for
|
||||||
|
you:
|
||||||
|
|
||||||
|
- **Keep your current OS.** Closing or deleting the VM leaves the host operating
|
||||||
|
system in place.
|
||||||
|
- **Learn at your own pace.** Familiarize yourself with the desktop and tools
|
||||||
|
without needing to make it your daily computer on day one.
|
||||||
|
- **Choose your next step with confidence.** When you are ready, install it on
|
||||||
|
a dedicated computer, or keep using the VM as a learning environment.
|
||||||
|
|
||||||
|
You can use the same ISO with [VirtualBox](https://www.virtualbox.org), VMware,
|
||||||
|
QEMU/KVM, Proxmox, and similar x86_64 VM software. For a first look, select
|
||||||
|
**Desktop Only**, allocate at least **8 GB RAM**, and create a **256 GB or
|
||||||
|
larger dynamically allocated virtual disk**. The full VM setup and installer
|
||||||
|
requirements are in [Installing in a virtual machine](#installing-in-a-virtual-machine-optional).
|
||||||
|
|
||||||
|
> **A VM is for evaluation and learning, not a substitute for a dedicated,
|
||||||
|
> hardened setup.** Do not use a trial VM to hold meaningful Bitcoin, recovery
|
||||||
|
> phrases, passwords, or other sensitive data. Avoid attaching physical drives
|
||||||
|
> to the VM, and be deliberate about shared folders, clipboard sharing, and
|
||||||
|
> network settings. The installer only changes the disk you select, but you
|
||||||
|
> should always review VM disk selections before confirming an install.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What is included
|
## What is included
|
||||||
|
|
||||||
Depending on the selected mode and enabled features, Sovran_SystemsOS brings
|
Depending on the selected mode and enabled features, Sovran_SystemsOS brings
|
||||||
@@ -106,13 +149,13 @@ presents and manages the features available on your system.
|
|||||||
|
|
||||||
### Your money — Bitcoin sovereignty
|
### Your money — Bitcoin sovereignty
|
||||||
|
|
||||||
- Bitcoin Knots, Electrs, and Tor integration
|
- Bitcoin Core, Electrs, and Tor integration
|
||||||
- LND and Ride The Lightning, with [Alby Hub](https://albyhub.com) for Nostr
|
- LND and Ride The Lightning, with [Alby Hub](https://albyhub.com) for Nostr
|
||||||
Wallet Connect (NWC) connections
|
Wallet Connect (NWC) connections
|
||||||
- BTCPay Server
|
- BTCPay Server
|
||||||
- Sparrow Wallet, Bisq, and Bisq 2, with automatic wallet-to-node connections
|
- Sparrow Wallet, Bisq, and Bisq 2, with automatic wallet-to-node connections
|
||||||
- Optional: a self-hosted [Mempool](https://github.com/mempool/mempool)
|
- Optional: a self-hosted [Mempool](https://github.com/mempool/mempool)
|
||||||
explorer, or Bitcoin Core in place of Knots
|
explorer
|
||||||
|
|
||||||
Run your own Bitcoin infrastructure. Verify your own money. Trust no one.
|
Run your own Bitcoin infrastructure. Verify your own money. Trust no one.
|
||||||
|
|
||||||
@@ -142,7 +185,7 @@ hardware you control.
|
|||||||
### Your desktop
|
### Your desktop
|
||||||
|
|
||||||
- [GNOME](https://www.gnome.org) desktop
|
- [GNOME](https://www.gnome.org) desktop
|
||||||
- [Brave](https://brave.com) and
|
- [Brave Origin](https://brave.com/origin/) and
|
||||||
[Firefox](https://www.mozilla.org/firefox) browsers
|
[Firefox](https://www.mozilla.org/firefox) browsers
|
||||||
- File management, email, calendar, and office applications
|
- File management, email, calendar, and office applications
|
||||||
- System monitoring and administration tools
|
- System monitoring and administration tools
|
||||||
@@ -158,7 +201,7 @@ Bitcoin and self-hosting infrastructure runs on the machine.
|
|||||||
| Mode | Best for | What you get |
|
| Mode | Best for | What you get |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
|
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
|
||||||
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Knots, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
|
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
|
||||||
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services |
|
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services |
|
||||||
|
|
||||||
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
|
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
|
||||||
@@ -298,8 +341,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
|||||||
|
|
||||||
### 1. Download the ISO and checksum
|
### 1. Download the ISO and checksum
|
||||||
|
|
||||||
- [Download Sovran_SystemsOS.iso](https://downloads.sovransystems.com/Sovran_SystemsOS.iso)
|
- [Download Sovran_SystemsOS-1.1.1.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso)
|
||||||
- [Download Sovran_SystemsOS.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS.iso.sha256)
|
- [Download Sovran_SystemsOS-1.1.1.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso.sha256)
|
||||||
|
|
||||||
The download may take some time. Do not rename or modify the ISO before
|
The download may take some time. Do not rename or modify the ISO before
|
||||||
verifying it, and keep both files in the same folder.
|
verifying it, and keep both files in the same folder.
|
||||||
@@ -317,16 +360,16 @@ checksum exactly.
|
|||||||
Open a terminal in the download folder and run:
|
Open a terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sha256sum --check Sovran_SystemsOS.iso.sha256
|
sha256sum --check Sovran_SystemsOS-1.1.1.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
A successful comparison reports:
|
A successful comparison reports:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Sovran_SystemsOS.iso: OK
|
Sovran_SystemsOS-1.1.1.iso: OK
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also run `sha256sum Sovran_SystemsOS.iso` and compare the output
|
You can also run `sha256sum Sovran_SystemsOS-1.1.1.iso` and compare the output
|
||||||
against the checksum file manually.
|
against the checksum file manually.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -337,11 +380,11 @@ against the checksum file manually.
|
|||||||
Open Terminal in the download folder and run:
|
Open Terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
shasum -a 256 Sovran_SystemsOS.iso
|
shasum -a 256 Sovran_SystemsOS-1.1.1.iso
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value shown in Terminal with the value inside
|
Compare the value shown in Terminal with the value inside
|
||||||
`Sovran_SystemsOS.iso.sha256`.
|
`Sovran_SystemsOS-1.1.1.iso.sha256`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -351,7 +394,7 @@ Compare the value shown in Terminal with the value inside
|
|||||||
Open PowerShell in the download folder and run:
|
Open PowerShell in the download folder and run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Get-FileHash .\Sovran_SystemsOS.iso -Algorithm SHA256
|
Get-FileHash .\Sovran_SystemsOS-1.1.1.iso -Algorithm SHA256
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value under `Hash` with the published checksum.
|
Compare the value under `Hash` with the published checksum.
|
||||||
@@ -366,7 +409,7 @@ match exactly.
|
|||||||
|
|
||||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||||
connect the USB drive.
|
connect the USB drive.
|
||||||
2. Choose **Flash from file** and select `Sovran_SystemsOS.iso`.
|
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.1.iso`.
|
||||||
3. Choose **Select target**, select the USB drive, and review your selection
|
3. Choose **Select target**, select the USB drive, and review your selection
|
||||||
carefully.
|
carefully.
|
||||||
4. Choose **Flash** and wait for the writing and verification process to
|
4. Choose **Flash** and wait for the writing and verification process to
|
||||||
@@ -390,6 +433,26 @@ installer. Do not format the drive.
|
|||||||
If the normal operating system starts instead, restart and try the boot-menu
|
If the normal operating system starts instead, restart and try the boot-menu
|
||||||
key again.
|
key again.
|
||||||
|
|
||||||
|
### Installing in a virtual machine (optional)
|
||||||
|
|
||||||
|
Sovran_SystemsOS can be tested in VirtualBox, VMware, QEMU/KVM, Proxmox, and
|
||||||
|
similar x86_64 virtual machines. Use production-like resources where possible:
|
||||||
|
|
||||||
|
- Allocate **8 GB RAM or more** for Desktop Only. Node and Server + Desktop
|
||||||
|
should follow the normal 16 GB / 32 GB recommendations.
|
||||||
|
- Create a **256 GB or larger virtual OS disk**. Thin-provisioned / dynamically
|
||||||
|
allocated disks are fine; they do not consume the full size immediately.
|
||||||
|
- Use NAT or bridged networking with internet access before opening the
|
||||||
|
installer.
|
||||||
|
- UEFI/EFI firmware is preferred. In UEFI VMs, the installer avoids depending
|
||||||
|
on VM NVRAM boot-entry writes. If a VM boots the ISO in legacy BIOS mode,
|
||||||
|
the installer automatically switches the installed system to GRUB.
|
||||||
|
- For Node or Server + Desktop, attach a **second 2 TB virtual data disk**.
|
||||||
|
If you only attach one disk, choose **Desktop Only**.
|
||||||
|
- Present the install target as a normal virtual disk such as VirtIO, SATA,
|
||||||
|
SCSI, or NVMe. USB-attached target disks are intentionally hidden by the
|
||||||
|
installer to avoid erasing the installer USB by mistake.
|
||||||
|
|
||||||
### 5. Install
|
### 5. Install
|
||||||
|
|
||||||
Follow the on-screen installer. Before confirming:
|
Follow the on-screen installer. Before confirming:
|
||||||
@@ -443,21 +506,34 @@ setup, supported hardware, and Royal Membership.
|
|||||||
|
|
||||||
## For developers
|
## For developers
|
||||||
|
|
||||||
Sovran_SystemsOS combines the reproducibility of [NixOS](https://nixos.org),
|
Sovran_SystemsOS combines [NixOS](https://nixos.org), an in-repository
|
||||||
the Bitcoin service modules of
|
Bitcoin and Lightning stack, desktop packages from
|
||||||
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), the desktop Bitcoin
|
|
||||||
packages provided by
|
|
||||||
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
||||||
Sovran Hub into a complete Bitcoin operating system. The operating system
|
Sovran Hub. The Bitcoin modules under `modules/bitcoin/` were adapted from
|
||||||
configuration, installer, Hub, desktop integration, Bitcoin services, and
|
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) and are now maintained
|
||||||
optional self-hosting services are all maintained in this repository.
|
here. Builds no longer import or fetch nix-bitcoin. Legacy `nix-bitcoin.*`
|
||||||
|
option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||||
|
|
||||||
|
### Development workflow
|
||||||
|
|
||||||
|
| Branch | Host | Purpose |
|
||||||
|
|---------------|---------------------------------------------------|-----------------------------|
|
||||||
|
| `main` | [GitHub](https://github.com/naturallaw777/Sovran_SystemsOS) | Active development & PRs |
|
||||||
|
| `staging-dev` | [Gitea](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS) | Synced with `main` |
|
||||||
|
| `stable` | [Gitea](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable) | Tested, release-ready builds |
|
||||||
|
|
||||||
|
**Flow:** develop → sync `main` ↔ `staging-dev` → promote to `stable`
|
||||||
|
|
||||||
|
> ⚠️ `main` and `staging-dev` may contain unreleased or less-tested code. For
|
||||||
|
> stable, audited code, use Gitea
|
||||||
|
> [`stable`](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable).
|
||||||
|
|
||||||
### Technology
|
### Technology
|
||||||
|
|
||||||
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
||||||
for reproducible system configuration
|
for declarative, pinned system configuration
|
||||||
- [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) for declarative
|
- `modules/bitcoin/` for the in-repository Bitcoin and Lightning stack
|
||||||
Bitcoin and Lightning services
|
- `packages/` for Sovran-maintained package definitions and patches
|
||||||
- [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the
|
- [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the
|
||||||
Sparrow, Bisq, and Bisq 2 packages
|
Sparrow, Bisq, and Bisq 2 packages
|
||||||
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
|
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
|
||||||
@@ -472,16 +548,46 @@ optional self-hosting services are all maintained in this repository.
|
|||||||
|
|
||||||
You need a system with Nix installed and flakes enabled.
|
You need a system with Nix installed and flakes enabled.
|
||||||
|
|
||||||
|
Clone the **stable** branch from Gitea (recommended for builds):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone -b stable https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS.git
|
||||||
|
cd Sovran_SystemsOS
|
||||||
|
```
|
||||||
|
|
||||||
|
Or clone the **active development** line from GitHub:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/naturallaw777/Sovran_SystemsOS.git
|
git clone https://github.com/naturallaw777/Sovran_SystemsOS.git
|
||||||
cd Sovran_SystemsOS
|
cd Sovran_SystemsOS
|
||||||
|
```
|
||||||
|
|
||||||
|
Then build the installer ISO:
|
||||||
|
|
||||||
|
```bash
|
||||||
nix build \
|
nix build \
|
||||||
.#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
.#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
||||||
```
|
```
|
||||||
|
|
||||||
The resulting build output will be available through the `result` symlink.
|
The resulting build output will be available through the `result` symlink.
|
||||||
|
|
||||||
|
### Publishing a release
|
||||||
|
|
||||||
|
Releases are managed with `scripts/release-stable.sh` and `scripts/upload-cdn.sh`:
|
||||||
|
|
||||||
|
1. Run the stable release script to bump version, tag, update changelog, and push/create releases:
|
||||||
|
```bash
|
||||||
|
./scripts/release-stable.sh [version]
|
||||||
|
```
|
||||||
|
2. Build the installer ISO:
|
||||||
|
```bash
|
||||||
|
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
||||||
|
```
|
||||||
|
3. Copy, checksum, verify, and optionally upload to CDN:
|
||||||
|
```bash
|
||||||
|
./scripts/upload-cdn.sh --upload
|
||||||
|
```
|
||||||
|
|
||||||
### Common development commands
|
### Common development commands
|
||||||
|
|
||||||
Run these commands from the flake root.
|
Run these commands from the flake root.
|
||||||
@@ -517,11 +623,14 @@ sudo nixos-rebuild switch --rollback
|
|||||||
| `flake.nix` | Declares flake inputs, the running system, and installer outputs |
|
| `flake.nix` | Declares flake inputs, the running system, and installer outputs |
|
||||||
| `flake.lock` | Pins dependencies for reproducible builds |
|
| `flake.lock` | Pins dependencies for reproducible builds |
|
||||||
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
||||||
| `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features |
|
| `modules/` | Core modules, self-hosted services, and optional features |
|
||||||
|
| `modules/bitcoin/` | In-repository Bitcoin and Lightning service modules |
|
||||||
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
||||||
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
||||||
|
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
||||||
| `iso/` | Installer configuration, installer code, and installer assets |
|
| `iso/` | Installer configuration, installer code, and installer assets |
|
||||||
| `packages/` | Custom package sources and patches (for example, Alby Hub) |
|
| `packages/` | Sovran-maintained package definitions and patches |
|
||||||
|
| `tests/` | Security and Nix integration checks |
|
||||||
| `assets/` | Documentation images |
|
| `assets/` | Documentation images |
|
||||||
| `custom.template.nix` | Template for local features and service overrides |
|
| `custom.template.nix` | Template for local features and service overrides |
|
||||||
|
|
||||||
@@ -578,7 +687,6 @@ rebuilds the machine into the selected declarative state.
|
|||||||
| Shared credentials | `modules/credentials.nix` |
|
| Shared credentials | `modules/credentials.nix` |
|
||||||
| Bitcoin and Lightning stack | `modules/bitcoinecosystem.nix` |
|
| Bitcoin and Lightning stack | `modules/bitcoinecosystem.nix` |
|
||||||
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
||||||
| Optional Bitcoin Core in place of Knots | `modules/bitcoin-core.nix` |
|
|
||||||
| Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` |
|
| Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` |
|
||||||
| Matrix Synapse | `modules/synapse.nix` |
|
| Matrix Synapse | `modules/synapse.nix` |
|
||||||
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
||||||
@@ -593,31 +701,37 @@ production environment.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## About Bitcoin wallet entropy
|
||||||
|
|
||||||
|
Wallet recovery words control the funds. Never share them with a website,
|
||||||
|
support technician, cloud service, or chat application.
|
||||||
|
|
||||||
|
For meaningful balances, prefer a well-reviewed hardware signer and follow its
|
||||||
|
verified backup process. A BIP39 passphrase is optional, advanced protection;
|
||||||
|
it is not a replacement for the recovery words. If you use one, back it up
|
||||||
|
separately—losing either item can make the wallet unrecoverable.
|
||||||
|
|
||||||
|
Keep durable offline backups in separate secure locations. Test recovery before
|
||||||
|
relying on a wallet, and begin with a small amount.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Security approach
|
## Security approach
|
||||||
|
|
||||||
Sovran_SystemsOS is designed around local ownership and explicit control.
|
Sovran_SystemsOS uses layered controls:
|
||||||
|
|
||||||
Its security foundations include:
|
- Pinned flake inputs and hash-pinned source archives
|
||||||
|
- Bitcoin and Lightning modules maintained in this repository
|
||||||
|
- Firewall enabled; public SSH and remote desktop disabled by default
|
||||||
|
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
||||||
|
- Tor enforcement for supported Bitcoin services
|
||||||
|
- Restricted, time-limited support access with scoped `sudo`
|
||||||
|
- Operator-controlled public service exposure
|
||||||
|
|
||||||
- Reproducible builds from pinned flake inputs
|
See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
|
||||||
- Firewall enabled by default
|
and operator guidance. No operating system can protect funds after recovery
|
||||||
- Public SSH disabled by default
|
words, administrator credentials, or the root account are compromised. Apply
|
||||||
- Remote desktop disabled by default
|
updates and keep tested offline backups.
|
||||||
- Hub authentication
|
|
||||||
- Local-network Hub access through `sovransystemsos.local`
|
|
||||||
- Tor integration for the Bitcoin stack
|
|
||||||
- User-controlled service exposure
|
|
||||||
- Declarative system configuration
|
|
||||||
- Restricted technical-support access
|
|
||||||
- Auditable open-source code
|
|
||||||
|
|
||||||
No operating system can guarantee complete security. Users should still apply
|
|
||||||
updates, protect credentials, maintain backups, secure their local network,
|
|
||||||
and review any services they choose to expose publicly.
|
|
||||||
|
|
||||||
Bitcoin users must also securely back up wallet seed phrases, descriptors,
|
|
||||||
channel backups, and other recovery information. Never store your only wallet
|
|
||||||
backup on the same computer that holds the wallet.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -635,11 +749,15 @@ Sovran_SystemsOS would not have the same reliability, transparency, or reproduci
|
|||||||
|
|
||||||
### nix-bitcoin
|
### nix-bitcoin
|
||||||
|
|
||||||
Special thanks go to the [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) project and its [contributors](https://github.com/fort-nix/nix-bitcoin/graphs/contributors).
|
The in-repository Bitcoin stack began with code adapted from
|
||||||
|
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), primarily from commit
|
||||||
|
[`360e30f`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||||
|
It has since been narrowed to Sovran's supported services and is maintained in
|
||||||
|
this repository. nix-bitcoin is no longer a flake input or build dependency.
|
||||||
|
|
||||||
nix-bitcoin provides the declarative foundation for building and operating Bitcoin and Lightning services on NixOS. Its work makes it possible to configure complex Bitcoin infrastructure—including nodes, Electrs, Lightning, Tor integration, and related services—in a reproducible and auditable way.
|
We remain grateful to the nix-bitcoin contributors for the declarative and
|
||||||
|
security-focused foundation. Its MIT notice is retained in
|
||||||
Sovran_SystemsOS builds upon that foundation to make this infrastructure approachable through an integrated desktop, installer, and Sovran Hub.
|
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||||
|
|
||||||
### Emmanuel Rosa and btc-clients-nix
|
### Emmanuel Rosa and btc-clients-nix
|
||||||
|
|
||||||
@@ -657,7 +775,6 @@ This work helps make it possible for Sovran_SystemsOS to deliver self-custody an
|
|||||||
|
|
||||||
Sovran_SystemsOS also depends on the work of the developers and communities behind:
|
Sovran_SystemsOS also depends on the work of the developers and communities behind:
|
||||||
|
|
||||||
- [Bitcoin Knots](https://github.com/bitcoinknots/bitcoin)
|
|
||||||
- [Bitcoin Core](https://github.com/bitcoin/bitcoin)
|
- [Bitcoin Core](https://github.com/bitcoin/bitcoin)
|
||||||
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
|
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
|
||||||
- [Bisq](https://github.com/bisq-network/bisq)
|
- [Bisq](https://github.com/bisq-network/bisq)
|
||||||
@@ -714,15 +831,19 @@ license.
|
|||||||
> Individual upstream applications, packages, artwork, fonts, and other
|
> Individual upstream applications, packages, artwork, fonts, and other
|
||||||
> components included with or built by Sovran_SystemsOS may have their own
|
> components included with or built by Sovran_SystemsOS may have their own
|
||||||
> licenses and copyright holders. The AGPL-3.0 license for this repository does
|
> licenses and copyright holders. The AGPL-3.0 license for this repository does
|
||||||
> not replace the licenses of independent upstream projects.
|
> not replace those licenses.
|
||||||
|
|
||||||
Read the complete license terms in [`LICENSE`](LICENSE).
|
Read [`LICENSE`](LICENSE) and
|
||||||
|
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We welcome contributions! Please read our [Contributing Guidelines](CONTRIBUTING.md) before submitting a pull request.
|
We welcome contributions! The
|
||||||
|
[GitHub repository](https://github.com/naturallaw777/Sovran_SystemsOS) is the
|
||||||
|
primary location for collaboration. Please read our
|
||||||
|
[Contributing Guidelines](CONTRIBUTING.md) before submitting a pull request.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -731,7 +852,7 @@ We welcome contributions! Please read our [Contributing Guidelines](CONTRIBUTING
|
|||||||
## Privacy. Sovereignty. Bitcoin.
|
## Privacy. Sovereignty. Bitcoin.
|
||||||
|
|
||||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS.iso) ·
|
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso) ·
|
||||||
[View the License](LICENSE)
|
[View the License](LICENSE)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
| Release | Supported |
|
||||||
|
|---|:---:|
|
||||||
|
| Latest `1.0.x` stable release | Yes |
|
||||||
|
| `main` / `staging-dev` | Development only |
|
||||||
|
| Older than `1.0.0` | No |
|
||||||
|
|
||||||
|
Install the newest stable point release to receive security fixes.
|
||||||
|
|
||||||
|
## Report a vulnerability
|
||||||
|
|
||||||
|
**Do not open a public issue or pull request.** Report privately through:
|
||||||
|
|
||||||
|
- [GitHub Private Vulnerability Reporting](https://github.com/naturallaw777/Sovran_SystemsOS/security/advisories/new)
|
||||||
|
- Email: [support@sovransystems.com](mailto:support@sovransystems.com)
|
||||||
|
|
||||||
|
Include the affected version, impact, reproduction steps, and a minimal proof of
|
||||||
|
concept. Never send wallet recovery words, private keys, or live credentials.
|
||||||
|
|
||||||
|
We aim to acknowledge reports within two business days. Please allow reasonable
|
||||||
|
time for a fix and coordinated disclosure.
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
### Local-first operation
|
||||||
|
|
||||||
|
The Hub and core data run on operator-owned hardware. The Hub is intended for a
|
||||||
|
trusted local network and must not be port-forwarded to the internet. Public
|
||||||
|
services, DDNS, software updates, and optional third-party relays require
|
||||||
|
external networks and are outside a “fully offline” model.
|
||||||
|
|
||||||
|
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
||||||
|
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
||||||
|
|
||||||
|
### In-repository Bitcoin stack
|
||||||
|
|
||||||
|
Bitcoin and Lightning modules are maintained under `modules/bitcoin/`. They were
|
||||||
|
adapted from nix-bitcoin, but Sovran builds do not import or fetch nix-bitcoin.
|
||||||
|
The `nix-bitcoin.*` option namespace and `/etc/nix-bitcoin-secrets` path remain
|
||||||
|
only for upgrade compatibility.
|
||||||
|
|
||||||
|
### Supply chain and integrity
|
||||||
|
|
||||||
|
`flake.lock` pins flake inputs, and fetched source archives use fixed hashes.
|
||||||
|
Builds still depend on pinned Nixpkgs, NixVim, btc-clients-nix, upstream source
|
||||||
|
archives, and any configured binary cache. Keeping the Bitcoin modules in this
|
||||||
|
repository reduces an external dependency; it does not remove supply-chain
|
||||||
|
risk.
|
||||||
|
|
||||||
|
The Hub integrity check verifies Nix store contents and compares the running
|
||||||
|
system with a build from local `/etc/nixos`. It does not authenticate the release
|
||||||
|
publisher or protect against an attacker who already controls root and can
|
||||||
|
change both the system and local configuration.
|
||||||
|
|
||||||
|
### Access and service isolation
|
||||||
|
|
||||||
|
- Firewall enabled by default
|
||||||
|
- Public SSH and remote desktop disabled by default
|
||||||
|
- Separate service users and systemd sandboxing where supported
|
||||||
|
- Administrative service ports bound to loopback where practical
|
||||||
|
- Tor enforced for supported Bitcoin traffic and onion services
|
||||||
|
- Public web services exposed only when enabled by the operator
|
||||||
|
|
||||||
|
Tor reduces network exposure for configured Bitcoin services. It is not a
|
||||||
|
guarantee against every IP leak, application bug, or traffic-analysis attack.
|
||||||
|
|
||||||
|
### Restricted support access
|
||||||
|
|
||||||
|
Support uses a per-session SSH key on the non-root `sovran-support` account.
|
||||||
|
Sessions expire after 24 hours and have a small allowlist of `sudo` commands.
|
||||||
|
Wallet paths receive deny ACLs unless the operator explicitly removes them.
|
||||||
|
Disabling support removes the key and reapplies the ACLs.
|
||||||
|
|
||||||
|
Support events are written to `/var/log/sovran-support-audit.log`. This is a
|
||||||
|
local audit log, not a cryptographically tamper-evident record.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Sovran_SystemsOS cannot protect against:
|
||||||
|
|
||||||
|
- Compromised root or administrator credentials
|
||||||
|
- Stolen recovery words, private keys, or backups
|
||||||
|
- Malicious or compromised hardware, firmware, or build infrastructure
|
||||||
|
- Services the operator deliberately exposes or weakens
|
||||||
|
- Physical access without appropriate disk and firmware protections
|
||||||
|
|
||||||
|
## Operator basics
|
||||||
|
|
||||||
|
- Verify downloads and stop if the checksum does not match.
|
||||||
|
- Apply stable security updates promptly.
|
||||||
|
- Use unique passwords and keep SSH/RDP off when not needed.
|
||||||
|
- Prefer a well-reviewed hardware signer for meaningful Bitcoin balances.
|
||||||
|
- Keep tested, offline backups in separate secure locations.
|
||||||
|
- Never share recovery words or private keys with support.
|
||||||
|
- Disable support access when the session ends and review the audit log.
|
||||||
|
|
||||||
|
No software can provide absolute security. Review your configuration and threat
|
||||||
|
model before storing important funds or data.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Third-Party Notices
|
||||||
|
|
||||||
|
## nix-bitcoin
|
||||||
|
|
||||||
|
Portions of `modules/bitcoin/` and selected package definitions were adapted
|
||||||
|
from [fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
||||||
|
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||||
|
The current Sovran build does not import or fetch nix-bitcoin.
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2019 nix-bitcoin developers
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
Other packaged applications retain their own upstream licenses and copyright
|
||||||
|
notices.
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
<svg width="256" height="256" version="1.1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<image width="256" height="256" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAABmJLR0QA/wD/AP+gvaeTAAAgAElE
|
|
||||||
QVR4nO2dd3gU1frHv+9sTy9AKh2RIqh0kHJFpVoQO1IURa6CCFak6CrFdr1KU1HUa8NKEREQRQUR
|
|
||||||
ASlSlR5KEkpIQtr2Ob8/Aj9Isrszuzu7s7s5n+fJo+zOnHmTmfOdU94CcDgcDofD4XA4HA6Hw+Fw
|
|
||||||
OJyohtQ2gBM4zAytNTWtPkiozxjqChDqMiCVIKYyJtQBiakA6QGYiGAEAMaQAJDmfAsuIpSc/9wK
|
|
||||||
wALABrCzBJxlEM4SEwtE4AwJKICDHTOeO3mCzHCq9CtzFIILQITAvoLGdjqrqSiiLQmsFYDGYKwR
|
|
||||||
iBqBIRuANsQmOUE4AcZyQJQDRocZw98Cox2GohOHyAwxxPZw/IALQBjCzK31lpTidgKJnRmoDYAr
|
|
||||||
AbQCEKOyaXKpAKM9ANtBwC4RtNlkSN9Ko7c61DaMUxUuAGEAm52SYIOxEyN0B3DN+R+TymYpjYMI
|
|
||||||
O0XgdzBa79SzXxJG5xWobVRthwuACrCvoKnIz7iKNHQ9MdwEoCsAQW27VGAvMXwH4CeDIWMtHyGE
|
|
||||||
Hi4AIaL4rQbJBtF1M0Q2gAg3MCBZbZvCjEIAq0FshdVh+C55Qk6x2gbVBrgABBE2PzPGZsN1IAxj
|
|
||||||
wC0A9GrbFCHYGPAjMfraCMtSGldYorZB0QoXAIVh5tZ6S2rxLQLYEAb0Ayq33Th+YyXQChHiQpM+
|
|
||||||
cxmfJigLFwCFsM7Nag6RjWSE+wHUU9ueaISAIgb6WhCdcwyPndqttj3RABeAAGCzmxmsKL8dJIwC
|
|
||||||
WE/wv2eoYABbC9B7xrPJ35B5j11tgyIV/sD6AZudkmAjw/0M9CSAbLXtqeWcAvCO3SHMTnz8RKHa
|
|
||||||
xkQaXAB8wDInrTEjzXhieABArNr2cKpQBtBCIrxuHJu7X21jIgUuADKwzclsIQLPA7gDgEZtezhe
|
|
||||||
cYHwBYFe5EIgDRcAL1jeSG8EreZZgI1E6H3tOYEhErAIgjDJOObEQbWNCVe4ALjBMjejIUSaAsII
|
|
||||||
ADq17eEEhAOgD5nApseMyTuutjHhBheAS2CvpcVajZqnADwDvn8fbdgZ4R2TaJ3KHYsuwgUAAGMg
|
|
||||||
65ysYSD2MoAMte3hBJU8IvaCoSB/AQ9Z5gKAijmZ1xDwJoAOatvCCSVsM2PCYzHjcjeqbYma1FoB
|
|
||||||
YPObJFoc1heJYSxqZyQeB2Bg9KndSeNrqw9BrRQA69ysGxljb4M78XAqyWdgj8Y8mr9IbUNCTa0S
|
|
||||||
gLJ5ddM1Lv2rIDZMbVs44QcDlkMr/Dvm4RO5atsSKmqNAFTMzbpTYOwdHofPkaCQMRodMy73G7UN
|
|
||||||
CQVRLwDslTrx1hjDfwD2kNq2cCIIRp8YNfZHaMyZMrVNCSZRLQAVs7M7EYmfAWimti2cCISQwxiG
|
|
||||||
xjya97vapgSLqBQAZoZgTc2aDLDnwF14OYHhALHnjWPyXyYCU9sYpYk6AagM1TV+xIBBatvCiSq+
|
|
||||||
t2m0w5IeOVaktiFKElUCYHsru63oEhcDaKq2LZyo5KAAYbDh0RO71DZEKaLGAaZiduY9okvcAN75
|
|
||||||
OcGjmQhxk2Vu1gi1DVGKiB8BMDMEW2rmSwx4Wm1bOLUJesk4NndypK8LRLQAsNnNDFaq+BDAPWrb
|
|
||||||
Eu6cLQdmrBFRZgNeGSggOVKKjIUxBFpkcNAwevyERW1b/CViBaBkblaqjrElAHqobUu4s3Q3g/kH
|
|
||||||
EcXnH9N6cYTXbyb0aBKxtz+c2Ohk4s3x406eUdsQf4jIJ8A6u35TRq4VAJqrbUs4Y3EAz60S8fWO
|
|
||||||
mqNUIuCBzgIm9iZoo2YlSDUOkQsDjePz9qltiK9EnACUz81sJ4j4AYQ6atsSzhwrAh76WsQ/p71P
|
|
||||||
Ua+7jDB3sAATz3sUKKddAvWNG5P7l9qG+EJECUD5vPSOgiisApCiti3hzM48YOSXLhSUyzu+bSbw
|
|
||||||
v7s1SOHrAgHBgGIQDYgZm/uH2rbIJWIEwDI3oydAy8EQr7Yt4czvRxhGfSWiwscCWk1Tgc+GapDO
|
|
||||||
/7qBUk4Mg4zj8n5S2xA5RMTszzonvT8YreKd3zt/5TI89LXvnR8ADp0Fhi904Zw1one1woFYRlhu
|
|
||||||
m5t5i9qGyCHsBcA6J70/g7AUgEltW8KZAwXA8M9FlAdQJGv/GWDsYgaHi4tAgBhEhq+s8zL6qW2I
|
|
||||||
FGEtABVzMq9hEL4GL6vtlTI78PA3LpRYA2/rt8MMk1dwAVAAPRNpsWVO9r/UNsQbYSsAFbOzuhBh
|
|
||||||
JXgJLkmeXCbiYIH0cckxQPO60sd9tYNh0U4uAgpgAsTllnmZ3dU2xBNhKQD22VlXErHv+ZxfmqW7
|
|
||||||
GVb9I91Z4w3Ax/do8PlQDZqkSq/9vrCa4WQJFwEFiIWI5eVzM9upbYg7wk4ArHOzmrvAfgLf6pOk
|
|
||||||
2AK8uFo6tb1WAObfIaBNBpAaCyy4S0CcxKSqxMrw3A9cABQiUWBYaZ2XHXaJacJKAErmZqUyxpZz
|
|
||||||
Jx95vP6riMIK6eOevpbQrdHFt36TFOCVG6Vv/ep9DCtljC44sqjHRHFV6ex0GZOw0BE2AsA+bGTU
|
|
||||||
gS0DcJnatkQCJ84BX2yX7py9mxFGdal5mwe2ItzWVvr2v/ozg7PW189RjKZaCIvZh43CpuxcWAgA
|
|
||||||
YyBrmf19MHRT25ZIYdY6EQ6JjhmnB2YMEEAepvxTrifJqMAjhQzf7uajAMUgdLeV2T9m5vDoe2Fh
|
|
||||||
hG1u5ssAhqhtR6RQUA4s3SXdKR/rSchI8Px9cgwwoaf0I/DBZi4ASsKAO6ypWdPVtgMIAwGomJ15
|
|
||||||
D0/m4Rtf/sUk3/7JMcDQ9tK39552hOxE78fsOcmw7QQXAWVhz1pmZw1X2wpVM+aez+H3npo2RCIi
|
|
||||||
YxjSzvtWXpeGJCvCTycAD3UV8Nwq74qybA9Du+yICR2JDIi9ZZuVts3w2Kndqpmg1oWL32qQbHA5
|
|
||||||
/wTP4ac6pTag6ywXyry4EdeLI2x6zPN6AsdvDlid+k7JE3KK1bi4KlMAZoZgcDk/Ae/8YUG8Aejf
|
|
||||||
0nvPPl3G8LdEbgGOX1xm0Km3KKjKRSuLdmCgGtfmuKdfC+lX+4YjXACCATHcZE3NVGUdLOQDuvNJ
|
|
||||||
PX4HENE5aDYdQ8RHzXWoTzCeXwWyOYEr/+OC1en5+FuuIMwadPGdYXEAh88yxBsIqbFALA/ZCgQn
|
|
||||||
I6F7zNgTm0J50ZAuArJ5deOsovAZIrzzA5XRd3K88MKZ9+8ScN1lle8AgxZok0n485hnUfv7VNXv
|
|
||||||
Sm0MAxdcXDxMiQEapRBapxOuymToUJ/QMJkvGshES0z8jL1S52p6pqA0VBcN6RTAKmrnIUo8/d4c
|
|
||||||
JCDBGNkP9x9Hq/77qkzvv8/RIoBdogH14qjK36CwAth2guGTLSKeWMbQa56I69524ZWfRew9Fdmj
|
|
||||||
pRDR1BqjfyOUFwyZAFTMybgNINX3PZWiZxPC1yMEyT30cGZ7tb39xhLhVzYnaox6GiZ779iHzgJv
|
|
||||||
b2AY8J6IQR+K+GantA9DLeeBijmZd4XqYiERgPI3GmYIoKjb77+8LvD1CA3qJ6ltiX/knqvaebNk
|
|
||||||
iFlBNQFIjZU/Cvorl+HJZSL+Nc+Fhdt45iFPEPBW2Zv10kJxrZAIgEbrnMuA5FBcK9RkJACfDBFQ
|
|
||||||
Ly7ypgMF5YDrkrdxSoz072CxV+20SX6EteSeAyatENH3XYb1fGfBHSkajXZWKC4UdAGwzs26kYEN
|
|
||||||
DvZ11KRRCmHuYIJGdcdq33AxVHHsEUi6M9pcVUVCr/H/+ofPMgxbKGLSChEWPxKZRjl3hSKxaFAf
|
|
||||||
WTa/SSJj7J1gXiNc6NSAMOaayFIAgwYQLunPLlF6BKAVqopEoPN5xoCF2xhu+cCFAzLSmtUmRIa3
|
|
||||||
it5oFNQJZlCfWKvd+hqArGBeI5wYcw1FVF79RFPVDn+mXHoEoNdUPcfmxW/AF/afAW77n4gNOXxK
|
|
||||||
cAmZBo09qFGDQROAijmZ1wB4MFjthyMGLTC2e+SsBbSqtsyULyMHYPX8AWdliIZcSqwMIxaKPAvR
|
|
||||||
JRDh4Yq52Z2D1X5QBICZIRCjNxBBlYeUYnBbATER4ubUttq+/z+nvd8uIqButRzNueeUtckhAuOW
|
|
||||||
MPxykIvAeQRi4izGgtOXguIJaE3NHAGwjsFoO9yJ0QG9mhFW/i3/AW6VTljxoPrrBzvzvU/oU2Iq
|
|
||||||
RzmX4s112F8cLoYxixi+HqFB63Tl249AOlvnZtwL5H+qdMOKP3XslTrxAGYo3W4kcWkCTjmEwzCp
|
|
||||||
xArsOen9mBb1alq6ebwGK0cJMPcV0LUhVVlUDIQKB/DIIhElvFTZeehl9lqa4jUyFBcAm0k/GUCG
|
|
||||||
0u1GEm0i8K219hCr4hPgjsvr1fxMIKBlGuG+joTPhwlY/6gGj/ciJClQyO1oEcOUlVwAzpNlNWqe
|
|
||||||
UbpRRQXAMjejISM8pmSbkchldX17DYbDI75URuLPDjIyAmUmAON6CPhtrAbje1LAEYLL9jD8zp2F
|
|
||||||
LvBkxeysbCUbVHYNgNFUAGGT8lgtYvWVP3ILdfoiFw4XwwurK9OCeWNibwEJMu/E6TKGtYe8tycQ
|
|
||||||
0LWhfEvjDcD4ngJubs0wbomI3RLTC2+YfxCx6iFNxDlaBQETkTgZwMNKNaiYAFhn12/K4IqaYJ9A
|
|
||||||
qRNLKLfLe3P58n7TaQjbc0XsOen9rMvqMtzfUV6H/XgLJHP/t8mQTiHujiaphCUjNXh2eWUgkD8c
|
|
||||||
KABW72foLyNpSfRDD1jfSnvN+Mipw0q0ppimMojPIwri/JXCl6Gvr491zybSx3y2VYQoo7+V2oCP
|
|
||||||
t0gfeHNrGYZ5QCcAr94k4JYr/O/Ab2/gIYTn0TFRM1mpxhQRAOvcrOYgdo8SbUULugB85KUY0FL6
|
|
||||||
th0sAFbI2Iqc/weTXGnXCMBAiZyBUggEvH6zgM4N/Dt/Zx5kVUCuFTCMsL6ZebkSTSkiAIyxF6Fy
|
|
||||||
ivFwQ6+RP9z1dWDcJgNoJqN64n/Xeg+5zT0HLNgo/Wa9oTkhPSHw4bdWAF6/RVPDl0Auq/fxxcDz
|
|
||||||
aJgWU5VoKGABsMxJawzgdgVsiSp82Q/3p2vdeaX0WYfPMizwkmHuhdWiLEeeBzsrN/fOTpRXsMQd
|
|
||||||
aw5wAfh/GO62vpUmYzLonYAFgEHzOIAgDng57rj7apIs8Q1U1hA8Uliz46w9xGS9UTs1qEweqiTD
|
|
||||||
2vt33p6T0r4KtQgNnNpHA20kIAEofqtBMgH3BWoEx3cSjIS720nfPqsTGLdEhN1V9fPujQmDZCzK
|
|
||||||
PdNb+b23RikkawpTHasTyClS3JyIhREbde6/2RKJ3LwT0N01uFyPAIgLpA2O/zzSjRBvkD5uVz4w
|
|
||||||
48eqr06NAPznZgEDvCzu9WtBaB+kcmBSCUg94W40U4uJ1enFUYE04LcAsNnNDAAbE8jFOYGREgOM
|
|
||||||
6iKvI320pTJb76VoBWDWIHJbFShOD5j7Bs/zxt88iiUWZe2IdIhhHDO39tvf0u87bEX57ajlPv/h
|
|
||||||
wOiuAhqnyBOBF1Yz/FRtIU2nIcy9VcDIagt9T/UWgprcxF8X4RKbsnZEAZmWlKLb/D3Zf4knesjv
|
|
||||||
czmKYdAC0/qTrKKdThEYs0jEusNVRUAjAM/dIODlgQJ0GkKvpoTh7YPrdeetEKk3qqck4wBE/ife
|
|
||||||
8UsArHOzmgPo4e9FOcrSvTFhqES58AvYnMDor0W3W2p3X034eAjh9ZuDXwX4lJ+1b5JN3B3YDdda
|
|
||||||
Z2X7VXDHvxEAY6MQHmHsnPNMvkHAZTJX1i2OShH4fHtNEejakFBH8ajzmuT7KQCJCoQZRyHEBHGk
|
|
||||||
Pyf6LADM3FrPAB70E2YYtcD8OzWyIwCdIvDs9yJe/lmERGCh4jhcDFuP+3fRFC4AnrifzW/vcyyO
|
|
||||||
zwJgSS2+BYCb1BActWmSAswbLEDrw119ZwPDk9+xoKT28sS2XEKpH4t5eg3QzMdcC7WINIsj70Zf
|
|
||||||
T/J9CsDYvT6fwwkZPZoQpvbx7bYu2inixgWhy8v/zQ7/3PmuygqsEEm0IwBD/DhHPmx2SgIR+vp6
|
|
||||||
EU5oGdGBMMzHVfyDBcDgD10+JTP1h7wSYOku/67RqQHPCOINxmgAm1fXJ8c8n/6iVpgGgWf8iQhe
|
|
||||||
6Cfgtra+dZhSG/DIYhFTVzFUBKlU18yfRL+rCfVvoawtUUiMxaW7yZcTfHtCiN3p0/GckHGqlFXZ
|
|
||||||
WxcIePVGwq1tfMxPyIBPtojoO9+FzceUHQ0s2cWwfK9/bXZuALRO5/N/KQSCT31UtgCcr1F2g88W
|
|
||||||
cYKOUwTGLmEY9aVYpVSXRgBeu0nAwFa+d5zjxcCQT0X851cWcP0/oDLl+HOr/G/ovo58+C8HBvRn
|
|
||||||
85vIKPReiey/qlHnuAVAgDleOcHgP7+K+PMYwx9HGR5dIlbJ71fp7+9fOi6nCMxdL+KmBYEl9Txc
|
|
||||||
CAz/3OXXyj9QGZLcl+cDlIvBarPI3g2QL6siG+CXOZygsisfeG/jxWH16n0MTy8Xq8TNawXgzVsE
|
|
||||||
PCQzcKg6/5xmGPSBCy//XDOsWIqdecCdH7lwttyvS0OvAWYO0ChWcKRWIFB/2YfKOYh9BQ0I1/tv
|
|
||||||
EScYuETgme/FGkkyFu9keGypWCUdGBEw6XoBz/Xxz83XKVb6DNy4wIWdefLOWb2P4e5PXCjws/MD
|
|
||||||
lTb7kzugViOiLzPL69uyDrKcyuwCIKDEAxzlWbKbYa+H9ODL9zI89HVNB5+RnQiv3lgZ9OMP+88A
|
|
||||||
g//nwuZjno9xuBjeXMfw72/EgHYThneorDjE8RFCnYq66bLyLskSACLWLzCLOErjEIE31npfVPvl
|
|
||||||
IMOIha4akXd3XElYeC8hxY88/ykxwFu3CejkIbvv4bMMgz5keHOdvLTknri1DeGFIOYjiHYEJsia
|
|
||||||
BsgTAMifU3BCw/I9TFZp7rwSuPX179iAsOR+34bXPZsQVo4S0Ody92/lRTtF3PS+dNESKe7vGJqI
|
|
||||||
xKiGQdZLW1IASuZmpTKGqwO3iKMkH8ko5nFhB8BT2rCGyYTF92nQvbH3nmbSAdP6C/joHgFp8TWP
|
|
||||||
LawAHv5GxBPLmOxyaO7QCMBzfQQ831fgi36B00nOdqCkAOhF1k3OcZzQcbgQ+CtXWgDGdBfQTiKn
|
|
||||||
X4IR+OgewWMZsaapwOL7BAxr7z7pyJoDDP3eFbHyn8De+unxwOdDNRjZifd8hdDYnJauUgfJKdFw
|
|
||||||
jQLGcBRk2W5ph5r0BMLD3eR1Jo0APN9XQKNUhmmrL/oRDG5LmNFfgMlNkGmxBZi6SsR3ewL3Fuzb
|
|
||||||
onJhMpE7mSsKY7gGwCpvx0gKAOMCEHb8clD6mPE9CEYfK/CM6EBokiJgzGIRIzsJGN/TvYCsOcAw
|
|
||||||
8XsRZ8p8a786KTGViUdvbs3f+sGBJPuu10eEmVvrrVTkZxkHTjAos0NykS0rEbhdRuUgd/RoQvhj
|
|
||||||
nMZt0k67C3jpJxH/28ICSiJCBAy6gvDcDYJfFYc5MmHowsyt9WTe43FlxqsAWOoUtycGnoMljNhz
|
|
||||||
UrqU96AryKekINVx1/kPFwKPLg58hb9JSmWkYo8m/K0fAkwVdc9eCeBPTwd4FQCBiZ0YT/0XVuw/
|
|
||||||
I90Bb7lC2TXbRTtFPLcqsBX+GB3wUFcBY7oTdHxJOWRoXEJn+CsAjKitz6VrOUElp9D79xkJQPO6
|
|
||||||
ylyrzA5MXiHi293+PwREwOA2Aib2JviWqoKjBIzQ1tv33peJmPeTOaGnsMJ7Z2ydpsyI7Xgx8OCX
|
|
||||||
Luw7438brdIIL/QldGzg3iabExAZ3O4ycBSjjbcvPQ7G2FfQAGiluDmcgCixev++mQJv/y3HGQZ9
|
|
||||||
6H/nN+mA8T0FfPuA4LHzrz/CMOA9F/rMd2H9ET7MDCJtvAUGeRwB2PIym0EDvkYbZkj51/tbcusC
|
|
||||||
S3YxPP2d/2m7+rUgmPt6Lit2pJBh2mqGnw9e/EWGLRRxaxvC8324L0AQiLWl1G8MHD/k7kuPAiBq
|
|
||||||
qC3xBYCwQ8pFVgjAgf6TrQzPr/IviCc1FpjeX0B/D4k7yuzA7HUiPtxcM8MQY5UhzBtyRLxxC6Fr
|
|
||||||
Q77wrCQucrUB4FYAPA4NiFjLoFnE8RupyjgVdv9E+50NDFNX+tf5B7Qk/DRa47Hz//APw3Vvi3h3
|
|
||||||
o/f0YidLGIZ+JuLNdaxGjgOO/wiMeZzKe1sEbBwEWzgBUieWAC8jM39y+/93LcPs33zvcYlG4OWB
|
|
||||||
gtvy4gBwpqwyD6AvcQIuEXhznYiNRwmzbyXUi+OjgcAhj33Z844s83wSRz0aJnvvTP+c9u0V/sFm
|
|
||||||
/zr/1VmE5Q9qPHb+FX8z9HnX5XeQ0MajDDe97znhCccHCI08feXFJYN5PImjHs3reH8jHiuSPwr4
|
|
||||||
dKuIaT/61vk1AjC+J+GbEQLqJ7k/5tnvRTyySERRhU9N1+BUKcPdn1YmO+UEhG8jAGaGFkBW0Mzh
|
|
||||||
+M0VGSRZHmuZDMedVf8wPLfKN5/+eAPw3p0CxvcUoPHy6njqWgEd68tv1xslVobhnwUeblzLaXB+
|
|
||||||
W78Gbm+jNTWtPuSFCnNCjElXWSPPG9/srFofoDp7TzE8/q1vC34NkwmL7tOgdzPpOXlKDPD5MA1G
|
|
||||||
dFBm/u4QgXFLGNYe4iLgJzprfqbbp8a9jjPykPGNEw5c39y7M31+CfDxFvdD+8IKYNRXviXr7NqQ
|
|
||||||
sOwBwScXY61QGfQzc4D/CUgvxeGqTDK67QQXAb8g1tDdx+6nAKTh5b/DmBtbuc/OcynzfmduvQbH
|
|
||||||
LBJl5RK8QJ/LCR8N8d9BZ0g7wgd3EeIUKCljcQCjvhZxsjTwtmobDHAr324FQABSg2sOJxAyEyoT
|
|
||||||
dHqj2OK+FNewDuR1/n4pN7cmzBssveYgRY8mhC+Ga/zKQlyds+XAE8vEgPIR1EbIQ592PwJg4KUY
|
|
||||||
whxPOfwuZeluhmXVUnYNaEl45UbppJv3tiO8OUiZ4TsAXJEOfDVcg9TYwNv6/QjDp1u5p5BPCO77
|
|
||||||
tFsB8KQWnPChV1PClZnSnXPichF/n6oqAre3JTzf13Pa7XvbEab3Vz4zb7M6wMf3aDxmKfaFl9Yw
|
|
||||||
nPBhKlPbIZAPIwAuAGHPhVJfUlQ4gAe+FJFfUvXzER0I0/pRjU5+Q3PCC/2Cl5O/dTow/w4hoIxF
|
|
||||||
QOXv9aZEYRTORTz1afe3gRgvAxYBdG5QWUFHirwS4J5PxRo1+oa2F6oM86/OIsy+NfDOKUW3RoQJ
|
|
||||||
vQK/yJLdDMeLFTCoFuCjAPAw4Ejh+T6CrEw7OYUMIz53ocRadTpwc2vC/DsIbTIq6wOEKjnHw90I
|
|
||||||
XQKM+nOJ4GsBMvGU29ODHwAU2LThhIIkEzB3sEbWYt2ek8B9X7AaItC7GeHbkRokhDAWXyDgxX6B
|
|
||||||
rzMs3sUCqkFYi3C78uJpHKbAMg0nVHRuADzXR15P2naC4Z5PWQ0/fTVKcTWvC9wUYE2AM2XAjjyu
|
|
||||||
ANIw+QLA+Agg4hjWnmS73u45ydyuCajBv7sGrjwbjypgSJRDILd92v02IHEBiETMfQXcfbW8DvXP
|
|
||||||
aYY7P3Kp7lXXMo2QLVnC0jvbZdRJrO0w36YA7tWCE94QATP6CxjgIUa/OocLgbs+9s01OBhcKyPA
|
|
||||||
yBuHCrgASEM+rQFwIhSNAMwaRBjYSl6nOlrEcMdHLuQUqteJrs4KTAByz4G7BvuJBwFgAdSA4aiN
|
|
||||||
TkOYPUjAbW3lday8EuDuT9QbCWQGOAWwOit/ON5gNnefeloE5AIQQZRYWQ13X40AvHaTgDtkFgk9
|
|
||||||
WQoM/UydhUFfqxi7w+JDeHNthAD5AkDEBSCSmL0euOsThs3HqoqAQMCrNwoYLnN34Eghw/CFIs5Z
|
|
||||||
QzueVuLtLTfCsbbCPIzqPf3Z3KoFJ/w4WsTw8Z8iSqyVKbVXVUudRQS80FfAsPbyRGDvKYbxS0Pr
|
|
||||||
XFMYYO5AAIjR8UUA75D8EQD4CCBieGMtg91V+f92FzB2sYilu2uKwIv95E8HfjnIMOu30HWoE8WB
|
|
||||||
XcuohWJhy1GMDwLAoIAmc4LN8WJg+d6qnccpVibMWLSzpgi8PFDAjTJ3B+b8JtaYUgSLvacCOz/R
|
|
||||||
xDu/FIxgcfe5p12As8E0hqMM720U4XQTC+MSgae+E/HNzpoLg28MEmQF4YisMpeAvzUC5cIY8Ofx
|
|
||||||
wISmMY9dlYQY3CaL95QQhAtAmGNxAEt3e/5eZMDT34n45WDVzqUTgHdu95zT/1IOFwL/2xxcBdh7
|
|
||||||
igW8/XhZXT4CkMJTn/aQEETgAhDmrPy7ZlRfdfQaoEW9mp0jyQS8c7tG1vbbnPUM5UFcEfpie+DT
|
|
||||||
jGY8gZ0kzBcBICb6UWGOE0qqL/S548EuAjIS3H/XOh0Y31N676zECny1IzhrAQXlqDFN8YfOPIm9
|
|
||||||
NOTDFED0cDAnPCi3V9bO84ZRCzzY2fvQ+IHOkJXr/7MgJd2Y9qMYsANPejxwOZ8CSCL6tAbAGBeA
|
|
||||||
AAnm+vm6wxe3/jwxqA0hSaKUuE5DmNZfOuf3wQJg/xkfDJTBsj0M38oYxUhxbTPpGgkcQGDuF/Y9
|
|
||||||
5QQ8FlRragE2Z/Ceyg050sfcIzMsuHMDeUPonw8oJ2lbjjM8s1yZUcVgGTkROQDTUI67z90KgPHs
|
|
||||||
qeMAeHhFAHirzRcom4967zzZiUDbDPkdY2h76bWAbQrF3K87zDDi88CH/kBlLoGODbgAyMBhqpuX
|
|
||||||
5+4Lt+vAZIbTMhcnwDzXFed4p9QWnEnAOSuTLP/dr6Vvab17X1YZPnwhpNakAwyXPBkJRiBFYjoh
|
|
||||||
hcPF8PaGSg9Dl0JLCnJjHDg4RnfC7aTR80YQYzkANQqKOVGOSwROlxGCsRKw7zQk/fS7ui0D6ZlY
|
|
||||||
PTBvcPCiaX49BMz8SVR0HaFZHeD2tsq1F+Uc8fSFZwEgygnqSlYUc7qs8o0XDPad9t6uQECH+kG5
|
|
||||||
tE+UWBlW7QM++lPEnpPKtz/lBg10AdYsrEX4IQBeTuJ4Z1d+8JQzp8j7yKJBMiHRGPqhcYmVYf8Z
|
|
||||||
YNdJYO1BERuPBi9Jx4CWhH81DU7bUQljOZ6+8igAjLE9BD7H8gdfk1TanQzHitx/VycOiLmkWMep
|
|
||||||
Uu9tV9/XL7MDf8m0p9TqfdIisso1iBIrnf8vkF8C7D8TuDuvXBokAy8P5A8P8RsAABVFSURBVM+l
|
|
||||||
LwgC7fH0nUcBEEjYxXiiNb9Yf8S3v9uBAqDnPPcb+5/eK6B744sP/Jky7203Sq767x25lXkClEWd
|
|
||||||
58KoBebeGtoCJtGAyFw7PX3nceXHUJB7EEAYZI6PLI4WMezKV669rGr58k6VeX/7pcVX/Xduifvj
|
|
||||||
Ig2dALx1u4C2mWpbEmEQSo1jT+V4+tqjAJAZIsA8Dh047vlmh3JtEQGZCVU7vNQIoE5s1ePzoqCE
|
|
||||||
tk4A5gwW0DvA9OG1EobdRJ6HbBJ7P7RLaXuimXNWho+2KDfcNmqr7sdbHJCMzKte3FMqYjDcSTIB
|
|
||||||
nw7VoF8L3vn9gpjH4T/gfRcAxLCT8b+7bN7eULkwphTVt7nkeBdWPyeSl3Fa1CO8czuhEU/44Tck
|
|
||||||
kpesERICIArCJmK8/LIcduYBCzYq29sM1e6OXYZvQZDcD0KKVgAe7EJ4vJcAPd/rDwgXsY3evvcq
|
|
||||||
ACZd2jarPb8CQIyiVkUZpTZg/FKX2/RcgWCt5i9v1EoPx2zVzok1BMcjMVh0a0SYfD2hdTofeipA
|
|
||||||
eczZ/L+8HeB1DYBGb3UA2KKoSVGGQwTGLBJxuFD5tsvsqOI3HyOjYmORpWpnb5Ds4cAw48pMwsdD
|
|
||||||
BCwcKvDOrxQMG8nsPahPRlIo+h1gPZWyKZq4kIZ73eHgvGEZqxxdXIjr1wqVgTne1hnyq237ycn9
|
|
||||||
pxY6AbjhcsKQdlTF14GjGL9LHSApAMTwO18IrMk5K/DQVy5sCnLmhOPFqJLYo34SYc9Jz4JzrLjq
|
|
||||||
v69IJ+gEBD27r1wMWqB7Y8J1lxH6tSCk8Mll0CAlBMDmpD/0OiaCVxKuwj2fiAHns5dDTiFDm0ti
|
|
||||||
+xsmw2twTXUnpHgD0LEBYUOOOusA6fGVcfvts4FODSodeZSoBciRxGUwGDdJHSR5KxIfP1FomZO5
|
|
||||||
DUAHRcyKEro1JlyVFfzrJFaLw2+TQVjxt+fOfLSIobACVd6swzpcTA/mFIEKBbP8mnSVW48mHaFO
|
|
||||||
LENaHCE1FmiUAjRJJcS7rUrPCTqETTT6sKQbmFwtXgkuAFWYcj0BKgRLtc3w/j1jwK8HGQZfUhq8
|
|
||||||
fwtC/5A40vC5YtjAaJWcw2QN65kIWY1xgk+H+iQ5hP5Rwfx9nMhEhLhSznGyBMCUkbcJvFpQWGDQ
|
|
||||||
Ap0lSnv9coDhnIIeiZwIg6Eg5mz+NjmHypoC0J1wWebgJwB3BWQYRxEGtiKsPeT5LW91At/sEPFA
|
|
||||||
Z3nrtkUV0lGDBMb35yMFolWVwXzSyF+PJfY9GHEBCAP6Xg5MXek9NuDjLcCIjpW+A1I8+Z2INRLT
|
|
||||||
hh5NCJ8M4QIQCTDGVsg9VvbWnlG0fQuADyzDgEQj4ebW3jvj0SJWo0S4O/48xiQ7PwAMkVlngKM6
|
|
||||||
VhOs38s9WLYA0LjCEkb40T+bOEpzX0fpijiz1omo8JJ/3yUCL6yWHimmxROub+6jgRxVINAKGlco
|
|
||||||
Ow2MT849JLIvfTeJEwxapxOuv8y7AuSVVIqAJz7awrBbRsbesd0JOg0fAUQCIsGnPuqTABgtjmUA
|
|
||||||
LD5ZxAkaj/ciaCTu4PubGHa6qQlzpJDhv79Kv/2zE4G7+fA/UqgwWZyyh/+AryOAZwpKCSRrf5ET
|
|
||||||
fFqmkeRKv1MExix2VQkgqgxiYiiT4RE46XoBOu4EHil8R0+d8imPp8+3VoS40NdzOMFjQk9Cw2Tv
|
|
||||||
b+jjxcC4pSIcLgbGgInfi14Dii7QrwVhQEv+9o8UBMLnPp/j6wkmfeYyAEGo9cLxB5MOeGmg9ILg
|
|
||||||
rwcZnl7OMHMNw2IZuwOpscC0fvzVH0Gc1OsyZG//XcDnO0yjtzqI8Imv53GCR7dGhPs7Sb+pl+xi
|
|
||||||
eG+j9LxfpyHMGyygbpwS1nFCAtEH5xP4+IR/Ei9q5iOS8kzVAiZfp1za7MnXE7pIuBtzwgpGRB/6
|
|
||||||
c6JfAmAcd/wQQOv8OZcTHDTnc+e3Sgus447qIuC+jrzzRxQMPxvHnDjoz6kBTPLYu/6fywkGsXrg
|
|
||||||
/bsEZCT4d/6DXQRMvp53/kiDCVjg77l+C4BRn/E1gBP+ns8JDhkJwNL7BbT0YSRABIzvKZzPccCJ
|
|
||||||
MHJNBcmL/T3ZbwE4v+Awz9/zOcEjLZ7wxTAB7bKlO7ROAP5zE2F8T975IxHGMIvMe/zO8RTQPo/V
|
|
||||||
qX8HQFkgbXCCQ6IR+OxeAX0u99yxsxOBRfdpcFtbvt0XkRBKbS79e4E0EdCdT56QU8xA7wfSBid4
|
|
||||||
mHTA/NsFTO9PNWoG9m9JWDFKw6vtRjDEMD95Qk6x9JFe2gjUCMsb6Y2gFQ7Al9wCnJBzvBiY8K2I
|
|
||||||
/WcYJvYWMKQdH/JHOA6IaGZ6LC+gxPSKPAWWOZmfARiiRFuc4OFwMVQ4CIlGtS3hBA772PRo/ohA
|
|
||||||
W1Fk8keiYAa8lyDiqI9Owzt/lOAiEmYo0ZBi40DHn7M3CGlXdVWqPQ6H4x7x9F+/6TqMU6Rcn2Lz
|
|
||||||
9lMlSVPSr75mjUYqQJ3D4fiNyyWi8NDBF5VqT9GVoPffX5dzXGzZUMk2ORzORRpo9h4eObJXU6Xa
|
|
||||||
U3Tl3pKz/q4/zqZvtDNeD4rDURq9YEVayobblWxT0fH62GmTNnWM37BeyTY5HE4lneI2/PrwtGe3
|
|
||||||
K9mm4hP2VqacW1N1BXxHgMNRkBRNgesy01HF63IoLgBDzOaCDrF/cO9ADkdBOsZveGu42Xxa6XaD
|
|
||||||
smRvNRQ80thwkMcIcDgK0Nh4sNRqODs+GG0HRQDMZrPYJmbrBJJXnozD4XiAIOLKmC1jzGZzUDpT
|
|
||||||
UB3Cn35q4e4/y65pHcxrcDjRTKf433a88urQq4LVflC9dlon7umboilwBfMaHE60kqorcDbR7u0f
|
|
||||||
zGsEVQBGTJmR2y1h3fRgXoPDiVa6xq1/ZfTMmfnBvEZIYkLHPbnk+K7ydtmhuBaHEw1cGbc1583X
|
|
||||||
BjcO9nVC4rjfIm7HwFhNKU8jrhAxRgHxMRd/BD/volLtAJAsTMKRT4K2RGwRu3NgKK4Vstv22qQ3
|
|
||||||
Xl5RNPiZUF0vWkmME/DF9CwY9Bdv3eiXTuLAcd/SwtVJ0mDhi5nQai+2M9ychxOn5flwEQHXto/B
|
|
||||||
dR1j0bKRHolxGjhdDGeKXNi814Jl68qQk+9znQoOgP7JS994euZjj4fiWiEL3Xtq5oSJ7eI37Q/V
|
|
||||||
9aKV4QMSq3R+fxl5U1KVzu8LSfEazH4iDVNG1kHXNiYkxWtABOi0hMy6WgzqFY93J6VjaP/EgO2s
|
|
||||||
bXSI++PvUHV+IIQCAABXxuztkabP8zuDaW2nW1sTBvWKD7id3h1i0LdLrF/nxhgFvP5YPbRu4j3g
|
|
||||||
S6shjLwpEcMHcBGQS5ou39E86dC1obxmSAVguNl8ukvs2ge0xEMFfKVrGxOmjqwT8Fy7V7sYPDM8
|
|
||||||
1e92hg9IQONMnfSB5xk2IBFNsuQfX1vRkhNdY38bPWry5FOhvG7Is3eMnznx024Jv6wO9XUjFa2W
|
|
||||||
8OAtSZg2um5AQ3+DjvDI7cl47oE60Pk59E9L0WLwtVVHIIwB7y4pxh3P5uLB6fnYvs9a5XuNAIy+
|
|
||||||
Ndlvu2sLPRJ/Xv7YS0/5Vd8vEFRJ30PGE/3bxPyVq8a1I4n2LYx4Z2I6hvRN8HuFngjo1NqE+c+m
|
|
||||||
4/be8QGNIK5tHwOtpmoD3/1Whi9+LMHZcy4cznPg+XcLUFpe1Wu1fQsjkhM0/l84ymlt2nFSNOTe
|
|
||||||
osa1VREAs9kstjZs6pihz7Wpcf1wx6gnzH82Ha+Nq4cmPgy3qxMfK2DB5Ay8PKYuGqQHPgzv1S6m
|
|
||||||
xmcr/6ga81VmEfHrtooqnwkC0OMqU8DXj0bS9Hn2dqadnYLl6y+Fagn8Rs+cmd8tZs1gk1DB/QOq
|
|
||||||
odcRLquvr/H5vqN2n7b7YgyC2/n67kM2n7fodFpCs2o2uUTgUG7NdvYcrqnrLRvxLFHVMQoW1j1x
|
|
||||||
zZ0jZ0w5rpYNqmbwHPvS1BXXJq56lYhrgDcYA5avL8P4/55Ccan/LwrGgMW/lOKJWadRWuFbO/Xr
|
|
||||||
aVE932t+gRNOZ817d+JMzUXeRhl8IfBSiBiuTfzhzbEvTvlWTTtUr+bz1MwJE6c8ndLz99J/8ZTi
|
|
||||||
bjhw3I53lxRj6z9W6YO9sPeIDfOXFGPXQf9mXfXdTCEKit3v5hQU1fy8fprqj1pY0S3+1/Wh3O/3
|
|
||||||
RFjcFW3Mke5Xi7EHt5d3DLrvcyTAGPD7TguWry/D5j0WMD8HSC6RYe32CixfX4atfwcmIAmxNQeL
|
|
||||||
Nrt7w+yOmp/HGAVoteR2xFDbuDJ22zGtKaeX2nYAKk8BLmA2m8UuFbvbXmb6p0htW8KB0goRU985
|
|
||||||
g027/e/8AFBQ7MIL7xUE3PkBwGRwIwBuOjoA2D24eZgMPGCgqWn/uQ5xO9qotehXnbAQAAC48y1z
|
|
||||||
WbuYzZ3TuadgWGJ044Pg7k0PAHYPb3l3IlKbqKc76eiU8GenoWZzidq2XCCs7si/p089cE3izwMS
|
|
||||||
tOfCQh05fuJh1CLU4gFAkrZIvC5xzQ0PmSeFVTxMWAkAAIx9cfKaPokr7+Lhw+GFu+G+J49Cvc79
|
|
||||||
5xXW2qnrMUI565mwcuhD0yatVduW6oSdAADAmOnPfNMn+YcHTYKFi0CYYLXV7LyeOrqnzy222nc7
|
|
||||||
TUIF65P63f0TZjz7udq2uCMsBQAAxk174oO+ScsfMZBFbVM4AMrcaLHehxGAzcHgqGU7AHqy4bqk
|
|
||||||
5Y8/9uIzH6ltiyfCVgAA4LEZT75zffKKqTx6UH3yztT0+EtJdO/fX8fN58dO1q7kIDpyoG/y8slP
|
|
||||||
zHjqTbVt8UZYCwAAPDnj8el9k7+dqiceNqAmx0/VFOHMulq3wUXZ9Wq6lxytRdmB9IIV/ZKXTX58
|
|
||||||
xuMz1bZFirAXAKBSBAakfDeWxw2oR5lFRH61ko8GHbl18XUX/7//WO3Y3TUKFtY38bsnI6HzAxEi
|
|
||||||
AADw2PQn5vVJ/HZ4nFDCRUAlNuysuR7Tr2tclX9rtYR/ta+abYgxYN1f0b+WEyuUsT6Jyx56fOaT
|
|
||||||
r6tti1wiRgCAymQi/ZOW38X9BNThl60VNT4bfG08eneIAVFl+PHTQ1NQL7nqGsDuwzacLozudZwk
|
|
||||||
bZHYJ3n57RNmPr1AbVt8ISxiAXzhkRnPfv3uVHb615J/rc63Z9WMmeUEjb1HbFi3vQI9r76YF0Aj
|
|
||||||
AFNG1sHTwxh0WqqxJsAYsGBpcYgtDS1punxH97g1A8dOn/yj2rb4SkSNAC7w0LRJa3vp17RqZtwX
|
|
||||||
3U9WGDLv6yK3b3O9rmbnByrDmHcdit4F3MbGg6W9U36+YuzMyOv8QIQKAACMfm3qoV6JGxpeFbcl
|
|
||||||
R21bahNnil14fNZpHMmTXtX//vcyzPqyMARWqUOb2G0neiT+1iDc3Ht9IaITtS3+9Vdbvz6t5mQL
|
|
||||||
5/octzeqr7Y9oaBxhg52J0N+gfP/f9bvqEBJuW/LIk2z9LDYqrazblsFKqzSa6ylFSJW/F6Oc+Ui
|
|
||||||
EuM0qJN08TGyOxg277Vi9ldFWPRzaUDRjOEKEcM1Cb+uj43dd+UEszmiVzejJjzjtSmzZv5S1Gei
|
|
||||||
RYyJmt8pUtBpCcnxApwuoKjUFZWd/gJGwcKuTfzhzXBI5qEEUdVZ5k2Z1u/30t5L8+3ZPAEdR3HS
|
|
||||||
9Hn2nvG/3PXI9ElL1bZFKaJKAABg/qRJGXtsnf/cVXFVltq2cKKHVqYdp9qbdnZUM4FnMIjYRUBP
|
|
||||||
jJ45Mz8lfnuDXgmrf+AxBJxA0ZIT1yatXl43YVtmtHV+IApHAJcya/Irw/4o67nglD2T+wtwfCZd
|
|
||||||
n2fvHLv2gfEzJ36qti3BIqoFAAAWmM0pByqa/by5tMeVatvCiRyuivvzcKukfd1CXasv1ES9AFzg
|
|
||||||
1WffeG1d6Q1PlLvia83vzPGdBG2JeE3CmlefnjH+WbVtCQW1qjO8Pcl8xX7HFd/+Vdaxidq2cMKP
|
|
||||||
NrHbTrSK29H332bzXrVtCRW1SgAu8PqkN8x/lPWcfNZRJ+JiITjKk6w56+qasPY/T82cMFFtW0JN
|
|
||||||
rRQAAPjYbK63v6LptxtKu3dh0bcZwpEBEUP72I1/N9PtvG70zJn5atujBrVWAC4we+rrI/8qvWrW
|
|
||||||
EVuzOOmjOdFCE+OBkrYxW8c+NuOZT9S2RU0iOhZACVauW739ln5NXmmlP5pQ5ErtaBHd1MDiRA3J
|
|
||||||
2gJXz8Q1C2Jj/+kxcdqUHWrboza1fgRwKQvN5jr7rdkfbyrt3s8qmvjfJorQC1Z0jN28sWHisUHR
|
|
||||||
vrXnC/whd8O8qS91zLE0/N9fFZ1bORlfJ4xktOTE1bGbdjcyHr3vkWnPblXbnnCDC4AX3ps8vct+
|
|
||||||
e7MPtpd1bOmKvORJtRotOXFF7PbDzXT7Hhwzc/IvatsTrnABkMHcSTNuOORo8e6usqsacSEIb7Tk
|
|
||||||
RNuY7Yea6PeN4h1fGi4APjBv8vTu+Y6Gr20vb9+5Qozlf7swwiRYWJvYLTub6XL+PWrGlI1q2xMp
|
|
||||||
8IfYDz6aPjnraFn9OTvLO9x81lm31u+kqEmStkhsE7vtt7ra3PsfnT71iNr2RBpcAALgq0fMcUcS
|
|
||||||
U186ZGt+70Hr5cmM8T9nKCBiaGb8p6iJ4cCnyYazE0ebzTXzlXNkwZ9YhZj73IzrTtszpu4tb9ud
|
|
||||||
jwqCQ5K2SGxt2rU5Q3tsEp/fKwMXAIWZbzbHnHMkTc21NRr6T0WrbDvj2ckCQS9Y0cK093i28din
|
|
||||||
dbRFL95vNlvVtima4AIQRBaYzSnFjqQJufbsIX+XX9HExkxqmxQR6MiO5qZ/TmUZjn6bgjxzbfXT
|
|
||||||
DwVcAELER9MnZ+VXpD170pE9MMfSrME5VyJ3Ob6ERM05sYlx/9E0Xf73SZQ/k3f60MAFQAV+MZu1
|
|
||||||
O+zGu4vFOkPy7VmdD1ubp9Q2j0OBXKivP1aRrT++M8Vw6pOrNWXvXms28ySOIYYLQBjw3owZaaVW
|
|
||||||
w20ljqQbCxxp7Y5Ym9aLNj8DvWBFtv54SYYuf2+S7uxP8fbS/41+beohte2q7UTVQxYtfGg2G0ts
|
|
||||||
sfeUs/j+hc6kNoXOutn5jqzYSAlQMgoWlqHLLU/VnTmerCneHas/932CYPmSL+CFHxHxQHEAs9ks
|
|
||||||
1LXrelUgpm+5K75duRjfoMSZWLfYlRxf6KyjC/UUQktOpGgLHEnawtIETcmZWKH0WIy2bGuSULEq
|
|
||||||
T2P/zWw28xLuEQAXgCjgK7NZf86hbVdBpvZ2UahvF/UZdhjS7aIh1S4akmzMFOdgWp2LaTROptcB
|
|
||||||
gJUZtIxpCACIXMxINicA6MhuF8gl6sjpMJClzKCxFenIVqiH7aResOfrBfF4DLNsTdQ5t91pNtvV
|
|
||||||
/L05HA6Hw+FwOBwOh8PhcDgcDocjwf8BnHZ+mE3sgIkAAAAASUVORK5CYII=
|
|
||||||
"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 18 KiB |
@@ -3,10 +3,18 @@
|
|||||||
# Backs up Sovran_SystemsOS data to an external USB hard drive using rsync.
|
# Backs up Sovran_SystemsOS data to an external USB hard drive using rsync.
|
||||||
# Designed for the Hub web UI (no GUI dependencies).
|
# Designed for the Hub web UI (no GUI dependencies).
|
||||||
#
|
#
|
||||||
# Your Sovran Pro already backs up your data automatically to its
|
# On Server + Desktop and Node systems, your Sovran Pro already backs up
|
||||||
# internal second drive (BTCEcoandBackup at /run/media/Second_Drive).
|
# your data automatically to its internal second drive (BTCEcoandBackup at
|
||||||
# This script creates an additional copy on an external USB drive —
|
# /run/media/Second_Drive); this script stores a copy in a third location.
|
||||||
# storing your data in a third location for maximum protection.
|
# Desktop Only systems have no internal second drive, so the external copy
|
||||||
|
# is the second location.
|
||||||
|
#
|
||||||
|
# What gets mirrored depends on the system role:
|
||||||
|
# - Node / Server + Desktop: /etc/nixos, /etc/nix-bitcoin-secrets,
|
||||||
|
# /home, and /var/lib (minus databases, blockchain data, logs, caches).
|
||||||
|
# - Desktop Only: /etc/nixos and /home. Desktop Only runs no server or
|
||||||
|
# Bitcoin services, so there are no nix-bitcoin secrets or system
|
||||||
|
# service data to back up.
|
||||||
#
|
#
|
||||||
# The external drive must be formatted as ext4. Files are stored as
|
# The external drive must be formatted as ext4. Files are stored as
|
||||||
# directly browsable files under Sovran_SystemsOS_Backup/current/.
|
# directly browsable files under Sovran_SystemsOS_Backup/current/.
|
||||||
@@ -344,6 +352,19 @@ case "$ROLE" in
|
|||||||
esac
|
esac
|
||||||
log "Detected role: $ROLE_LABEL"
|
log "Detected role: $ROLE_LABEL"
|
||||||
|
|
||||||
|
# Backup scope depends on the role. Desktop Only systems run no server or
|
||||||
|
# Bitcoin services, so only the NixOS configuration and home directory are
|
||||||
|
# mirrored (2 stages). Node and Server + Desktop systems also mirror the
|
||||||
|
# nix-bitcoin secrets and /var/lib system service data (4 stages).
|
||||||
|
if [[ "$ROLE" == "desktop" ]]; then
|
||||||
|
TOTAL_STAGES=2
|
||||||
|
HOME_STAGE_NUM=2
|
||||||
|
log "Desktop Only role: backing up the NixOS configuration (/etc/nixos) and home directory (/home) only."
|
||||||
|
else
|
||||||
|
TOTAL_STAGES=4
|
||||||
|
HOME_STAGE_NUM=3
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Detect target drive ──────────────────────────────────────────
|
# ── Detect target drive ──────────────────────────────────────────
|
||||||
|
|
||||||
if [[ -n "${BACKUP_TARGET:-}" ]]; then
|
if [[ -n "${BACKUP_TARGET:-}" ]]; then
|
||||||
@@ -385,12 +406,14 @@ log "Backup destination: $BACKUP_DIR"
|
|||||||
|
|
||||||
ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos)
|
ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos)
|
||||||
HOME_BYTES=$(estimate_path_bytes /home --exclude='*/.cache' --exclude='*/.local/share/Trash' --exclude='*/Trash')
|
HOME_BYTES=$(estimate_path_bytes /home --exclude='*/.cache' --exclude='*/.local/share/Trash' --exclude='*/Trash')
|
||||||
|
|
||||||
|
# nix-bitcoin secrets and /var/lib system service data exist only on the
|
||||||
|
# Node and Server + Desktop roles — they are skipped entirely on Desktop Only.
|
||||||
SECRETS_BYTES=0
|
SECRETS_BYTES=0
|
||||||
|
VAR_LIB_BYTES=0
|
||||||
if [[ "$ROLE" != "desktop" ]]; then
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets)
|
SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets)
|
||||||
fi
|
VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \
|
||||||
|
|
||||||
VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \
|
|
||||||
--exclude='postgresql' \
|
--exclude='postgresql' \
|
||||||
--exclude='mysql' \
|
--exclude='mysql' \
|
||||||
--exclude='mariadb' \
|
--exclude='mariadb' \
|
||||||
@@ -400,6 +423,7 @@ VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \
|
|||||||
--exclude='*/logs' \
|
--exclude='*/logs' \
|
||||||
--exclude='*/cache' \
|
--exclude='*/cache' \
|
||||||
--exclude='*/tmp')
|
--exclude='*/tmp')
|
||||||
|
fi
|
||||||
|
|
||||||
ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES ))
|
ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES ))
|
||||||
# Require 20% growth headroom plus a fixed 1 GiB safety margin.
|
# Require 20% growth headroom plus a fixed 1 GiB safety margin.
|
||||||
@@ -418,10 +442,10 @@ log "Free space on drive: ${FREE_GB} GB"
|
|||||||
(( FREE_BYTES >= REQUIRED_BYTES )) || \
|
(( FREE_BYTES >= REQUIRED_BYTES )) || \
|
||||||
fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)."
|
fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)."
|
||||||
|
|
||||||
# ── Stage 1/4: NixOS configuration ──────────────────────────────
|
# ── Stage 1: NixOS configuration ────────────────────────────────
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────"
|
log "── Stage 1/${TOTAL_STAGES}: NixOS configuration (/etc/nixos) ──────────────"
|
||||||
if [[ -d /etc/nixos ]]; then
|
if [[ -d /etc/nixos ]]; then
|
||||||
sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/etc/nixos/"
|
sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/etc/nixos/"
|
||||||
log "Stage 1 complete."
|
log "Stage 1 complete."
|
||||||
@@ -429,26 +453,30 @@ else
|
|||||||
log "WARNING: /etc/nixos not found — skipping."
|
log "WARNING: /etc/nixos not found — skipping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Stage 2/4: Secrets ──────────────────────────────────────────
|
# ── Stage 2: Secrets ────────────────────────────────────────────
|
||||||
|
# Only applies to the Node and Server + Desktop roles. Desktop Only systems
|
||||||
|
# run no nix-bitcoin services, so there are no secrets to back up and this
|
||||||
|
# stage does not exist for them.
|
||||||
|
|
||||||
log ""
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
log "── Stage 2/4: Secrets (/etc/nix-bitcoin-secrets) ───────────"
|
log ""
|
||||||
if [[ "$ROLE" == "desktop" ]]; then
|
log "── Stage 2/${TOTAL_STAGES}: Secrets (/etc/nix-bitcoin-secrets) ───────────"
|
||||||
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
|
if [[ -e /etc/nix-bitcoin-secrets ]]; then
|
||||||
elif [[ -e /etc/nix-bitcoin-secrets ]]; then
|
|
||||||
sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/"
|
sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/"
|
||||||
else
|
else
|
||||||
log "(not found: /etc/nix-bitcoin-secrets — skipping)"
|
log "(not found: /etc/nix-bitcoin-secrets — skipping)"
|
||||||
|
fi
|
||||||
|
log "Stage 2 complete."
|
||||||
fi
|
fi
|
||||||
log "Stage 2 complete."
|
|
||||||
|
|
||||||
# ── Stage 3/4: Home directory ───────────────────────────────────
|
# ── Home directory ──────────────────────────────────────────────
|
||||||
|
# Stage 2/2 on Desktop Only, stage 3/4 on Node and Server + Desktop.
|
||||||
# Rsync exit code 24 (vanished source files) is treated as nonfatal here
|
# Rsync exit code 24 (vanished source files) is treated as nonfatal here
|
||||||
# because the desktop may be active and files can disappear between the
|
# because the desktop may be active and files can disappear between the
|
||||||
# directory scan and the copy. All other nonzero exit codes remain fatal.
|
# directory scan and the copy. All other nonzero exit codes remain fatal.
|
||||||
|
|
||||||
log ""
|
log ""
|
||||||
log "── Stage 3/4: Home directory (/home) ───────────────────────"
|
log "── Stage ${HOME_STAGE_NUM}/${TOTAL_STAGES}: Home directory (/home) ───────────────────────"
|
||||||
if [[ -d /home ]]; then
|
if [[ -d /home ]]; then
|
||||||
sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \
|
sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \
|
||||||
--exclude='.cache/' \
|
--exclude='.cache/' \
|
||||||
@@ -463,23 +491,28 @@ if [[ -d /home ]]; then
|
|||||||
--exclude='.config/chromium/*/Code Cache/' \
|
--exclude='.config/chromium/*/Code Cache/' \
|
||||||
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
|
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
|
||||||
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
|
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
|
||||||
|
--exclude='.config/BraveSoftware/Brave-Origin/*/Cache/' \
|
||||||
|
--exclude='.config/BraveSoftware/Brave-Origin/*/Code Cache/' \
|
||||||
--exclude='.local/share/baloo/' \
|
--exclude='.local/share/baloo/' \
|
||||||
--exclude='.thumbnails/' \
|
--exclude='.thumbnails/' \
|
||||||
--exclude='.xsession-errors' \
|
--exclude='.xsession-errors' \
|
||||||
--exclude='.xsession-errors.old'
|
--exclude='.xsession-errors.old'
|
||||||
log "Stage 3 complete."
|
log "Stage ${HOME_STAGE_NUM} complete."
|
||||||
else
|
else
|
||||||
log "WARNING: /home not found — skipping."
|
log "WARNING: /home not found — skipping."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Stage 4/4: System data ──────────────────────────────────────
|
# ── Stage 4: System data ────────────────────────────────────────
|
||||||
|
# Only applies to the Node and Server + Desktop roles — Desktop Only systems
|
||||||
|
# run no server services, so /var/lib holds no service data worth mirroring.
|
||||||
# PostgreSQL/MariaDB raw database directories are excluded. Application
|
# PostgreSQL/MariaDB raw database directories are excluded. Application
|
||||||
# databases must be backed up separately with native database tools.
|
# databases must be backed up separately with native database tools.
|
||||||
# Bitcoin/Electrs data are excluded; they live on the internal second drive.
|
# Bitcoin/Electrs data are excluded; they live on the internal second drive.
|
||||||
|
|
||||||
log ""
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
log "── Stage 4/4: System data (/var/lib) ───────────────────────"
|
log ""
|
||||||
if [[ -d /var/lib ]]; then
|
log "── Stage 4/${TOTAL_STAGES}: System data (/var/lib) ───────────────────────"
|
||||||
|
if [[ -d /var/lib ]]; then
|
||||||
sync_tree "/var/lib" no /var/lib/ "$BACKUP_DIR/var/lib/" \
|
sync_tree "/var/lib" no /var/lib/ "$BACKUP_DIR/var/lib/" \
|
||||||
--exclude='postgresql/' \
|
--exclude='postgresql/' \
|
||||||
--exclude='mysql/' \
|
--exclude='mysql/' \
|
||||||
@@ -491,8 +524,9 @@ if [[ -d /var/lib ]]; then
|
|||||||
--exclude='*/cache/' \
|
--exclude='*/cache/' \
|
||||||
--exclude='*/tmp/'
|
--exclude='*/tmp/'
|
||||||
log "Stage 4 complete."
|
log "Stage 4 complete."
|
||||||
else
|
else
|
||||||
log "WARNING: /var/lib not found — skipping."
|
log "WARNING: /var/lib not found — skipping."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Generate manifest ────────────────────────────────────────────
|
# ── Generate manifest ────────────────────────────────────────────
|
||||||
@@ -517,23 +551,29 @@ MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
|||||||
echo "- /etc/nix-bitcoin-secrets (when present) → current/etc/nix-bitcoin-secrets/"
|
echo "- /etc/nix-bitcoin-secrets (when present) → current/etc/nix-bitcoin-secrets/"
|
||||||
fi
|
fi
|
||||||
echo "- /home → current/home/"
|
echo "- /home → current/home/"
|
||||||
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
echo "- /var/lib → current/var/lib/"
|
echo "- /var/lib → current/var/lib/"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Exclusions:"
|
echo "Exclusions:"
|
||||||
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
echo "- /var/lib/postgresql (PostgreSQL raw database files — not included)"
|
echo "- /var/lib/postgresql (PostgreSQL raw database files — not included)"
|
||||||
echo "- /var/lib/mysql, /var/lib/mariadb (MariaDB raw database files — not included)"
|
echo "- /var/lib/mysql, /var/lib/mariadb (MariaDB raw database files — not included)"
|
||||||
echo "- /var/lib/bitcoind (Bitcoin blockchain — excluded; lives on internal second drive)"
|
echo "- /var/lib/bitcoind (Bitcoin blockchain — excluded; lives on internal second drive)"
|
||||||
echo "- /var/lib/electrs (Electrs index — excluded; lives on internal second drive)"
|
echo "- /var/lib/electrs (Electrs index — excluded; lives on internal second drive)"
|
||||||
echo "- /run/media/Second_Drive (internal second drive — never traversed)"
|
echo "- /run/media/Second_Drive (internal second drive — never traversed)"
|
||||||
echo "- /var/lib/*/log, /var/lib/*/logs, /var/lib/*/cache, /var/lib/*/tmp"
|
echo "- /var/lib/*/log, /var/lib/*/logs, /var/lib/*/cache, /var/lib/*/tmp"
|
||||||
|
fi
|
||||||
echo "- Browser disk caches, thumbnail caches, trash directories, X session error logs"
|
echo "- Browser disk caches, thumbnail caches, trash directories, X session error logs"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Important limitations:"
|
echo "Important limitations:"
|
||||||
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
echo "- PostgreSQL and MariaDB/MySQL application databases are NOT included in this"
|
echo "- PostgreSQL and MariaDB/MySQL application databases are NOT included in this"
|
||||||
echo " backup. If you use Nextcloud, Matrix/Synapse, or other database-backed"
|
echo " backup. If you use Nextcloud, Matrix/Synapse, or other database-backed"
|
||||||
echo " applications, their data must be backed up separately using native tools."
|
echo " applications, their data must be backed up separately using native tools."
|
||||||
echo "- Bitcoin blockchain data and Electrs indexes are NOT included; they are"
|
echo "- Bitcoin blockchain data and Electrs indexes are NOT included; they are"
|
||||||
echo " reconstructable or stored on the internal second drive."
|
echo " reconstructable or stored on the internal second drive."
|
||||||
|
fi
|
||||||
echo "- This is a live file-level mirror, not a transactional database backup."
|
echo "- This is a live file-level mirror, not a transactional database backup."
|
||||||
echo " Files being written during the backup may be in an inconsistent state."
|
echo " Files being written during the backup may be in an inconsistent state."
|
||||||
echo ""
|
echo ""
|
||||||
@@ -542,7 +582,9 @@ MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
|||||||
echo "- To restore a directory:"
|
echo "- To restore a directory:"
|
||||||
echo " sudo rsync -aAXH --numeric-ids current/etc/nixos/ /etc/nixos/"
|
echo " sudo rsync -aAXH --numeric-ids current/etc/nixos/ /etc/nixos/"
|
||||||
echo " sudo rsync -aAXH --numeric-ids current/home/ /home/"
|
echo " sudo rsync -aAXH --numeric-ids current/home/ /home/"
|
||||||
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
echo " sudo rsync -aAXH --numeric-ids current/var/lib/ /var/lib/"
|
echo " sudo rsync -aAXH --numeric-ids current/var/lib/ /var/lib/"
|
||||||
|
fi
|
||||||
echo "- To copy individual files:"
|
echo "- To copy individual files:"
|
||||||
echo " sudo cp -a current/home/username/ /home/username/"
|
echo " sudo cp -a current/home/username/ /home/username/"
|
||||||
echo "- When restoring /etc/nixos to replacement hardware, regenerate"
|
echo "- When restoring /etc/nixos to replacement hardware, regenerate"
|
||||||
@@ -556,10 +598,12 @@ MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
|||||||
echo "- $warning"
|
echo "- $warning"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
if [[ "$ROLE" != "desktop" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "Note: Bitcoin blockchain and Electrs index data are intentionally excluded"
|
echo "Note: Bitcoin blockchain and Electrs index data are intentionally excluded"
|
||||||
echo "from manual external backup because they already live on the internal second drive"
|
echo "from manual external backup because they already live on the internal second drive"
|
||||||
echo "(/run/media/Second_Drive) and are reconstructable/internal-backup data."
|
echo "(/run/media/Second_Drive) and are reconstructable/internal-backup data."
|
||||||
|
fi
|
||||||
} > "$MANIFEST_FILE"
|
} > "$MANIFEST_FILE"
|
||||||
|
|
||||||
log "Manifest written to $MANIFEST_FILE"
|
log "Manifest written to $MANIFEST_FILE"
|
||||||
@@ -576,7 +620,11 @@ if [[ "${#RSYNC_WARNINGS[@]}" -gt 0 ]]; then
|
|||||||
log "vanished during backup, which is normal on an active desktop."
|
log "vanished during backup, which is normal on an active desktop."
|
||||||
log ""
|
log ""
|
||||||
fi
|
fi
|
||||||
log "All Finished! Your data is now backed up to a third location."
|
if [[ "$ROLE" == "desktop" ]]; then
|
||||||
|
log "All Finished! Your data is now backed up to a second, external location."
|
||||||
|
else
|
||||||
|
log "All Finished! Your data is now backed up to a third location."
|
||||||
|
fi
|
||||||
log "Files are directly browsable on the drive under: ${BACKUP_DIR}"
|
log "Files are directly browsable on the drive under: ${BACKUP_DIR}"
|
||||||
log "Please eject the drive safely before removing it from your Sovran Pro."
|
log "Please eject the drive safely before removing it from your Sovran Pro."
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""Sovran Hub — pure security validation helpers.
|
||||||
|
|
||||||
|
This module contains the dependency-light security helper functions used by
|
||||||
|
the Hub server. Keeping them here allows tests to import and exercise the
|
||||||
|
exact production implementations rather than maintaining separate copies.
|
||||||
|
|
||||||
|
All functions in this module depend only on the Python standard library.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
# ── Nix string escaping ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _nix_escape(value: str) -> str:
|
||||||
|
"""Escape *value* for use inside a Nix double-quoted string literal.
|
||||||
|
|
||||||
|
Handles backslashes, double-quotes, newlines, carriage returns, tabs, and
|
||||||
|
Nix-specific anti-quotation sequences (``${...}``). The returned value is
|
||||||
|
safe to embed as ``"<returned_value>"`` in generated Nix source.
|
||||||
|
"""
|
||||||
|
value = value.replace("\\", "\\\\")
|
||||||
|
value = value.replace('"', '\\"')
|
||||||
|
value = value.replace("\n", "\\n")
|
||||||
|
value = value.replace("\r", "\\r")
|
||||||
|
value = value.replace("\t", "\\t")
|
||||||
|
value = value.replace("${", "\\${")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# ── Nostr npub validation (NIP-19 / Bech32) ───────────────────────────────────
|
||||||
|
|
||||||
|
# Fast pre-filter: "npub1" followed by exactly 58 lower-case bech32 characters.
|
||||||
|
NPUB_RE = re.compile(r"^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
|
||||||
|
|
||||||
|
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||||
|
_BECH32_GENERATOR = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
|
||||||
|
|
||||||
|
|
||||||
|
def _bech32_polymod(values: list[int]) -> int:
|
||||||
|
chk = 1
|
||||||
|
for value in values:
|
||||||
|
top = chk >> 25
|
||||||
|
chk = ((chk & 0x1FFFFFF) << 5) ^ value
|
||||||
|
for i in range(5):
|
||||||
|
if (top >> i) & 1:
|
||||||
|
chk ^= _BECH32_GENERATOR[i]
|
||||||
|
return chk
|
||||||
|
|
||||||
|
|
||||||
|
def _bech32_hrp_expand(hrp: str) -> list[int]:
|
||||||
|
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
||||||
|
|
||||||
|
|
||||||
|
def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]:
|
||||||
|
values = _bech32_hrp_expand(hrp) + data
|
||||||
|
polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
|
||||||
|
return [(polymod >> (5 * (5 - i))) & 31 for i in range(6)]
|
||||||
|
|
||||||
|
|
||||||
|
def _bech32_convertbits_decode(data: list[int]) -> list[int] | None:
|
||||||
|
"""Convert a 5-bit integer sequence to 8-bit bytes, stripping padding."""
|
||||||
|
acc = 0
|
||||||
|
bits = 0
|
||||||
|
ret: list[int] = []
|
||||||
|
for value in data:
|
||||||
|
acc = (acc << 5) | value
|
||||||
|
bits += 5
|
||||||
|
while bits >= 8:
|
||||||
|
bits -= 8
|
||||||
|
ret.append((acc >> bits) & 0xFF)
|
||||||
|
if bits >= 5 or ((acc << (8 - bits)) & 0xFF):
|
||||||
|
return None # invalid padding
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def _bech32_decode(bech: str) -> tuple[str, bytes] | None:
|
||||||
|
"""Decode a bech32 string. Returns ``(hrp, payload_bytes)`` or ``None``.
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Lowercase-only (mixed case rejected per BIP-173).
|
||||||
|
- Only valid bech32 charset characters.
|
||||||
|
- Valid checksum.
|
||||||
|
- Exactly one separator (``1``).
|
||||||
|
- Minimum data part length (≥ 8 chars = 6 checksum + ≥ 2 data).
|
||||||
|
"""
|
||||||
|
if bech != bech.lower():
|
||||||
|
return None # mixed case
|
||||||
|
sep = bech.rfind("1")
|
||||||
|
if sep < 1 or sep + 7 > len(bech):
|
||||||
|
return None
|
||||||
|
hrp = bech[:sep]
|
||||||
|
data_part = bech[sep + 1:]
|
||||||
|
if any(c not in _BECH32_CHARSET for c in data_part):
|
||||||
|
return None
|
||||||
|
decoded = [_BECH32_CHARSET.index(c) for c in data_part]
|
||||||
|
if _bech32_polymod(_bech32_hrp_expand(hrp) + decoded) != 1:
|
||||||
|
return None # bad checksum
|
||||||
|
converted = _bech32_convertbits_decode(decoded[:-6])
|
||||||
|
if converted is None:
|
||||||
|
return None
|
||||||
|
return hrp, bytes(converted)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_npub(value: str) -> bool:
|
||||||
|
"""Return ``True`` iff *value* is a valid NIP-19 Nostr npub.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- Lowercase ``npub`` HRP.
|
||||||
|
- Valid bech32 charset (no uppercase, no invalid chars).
|
||||||
|
- Valid bech32 checksum.
|
||||||
|
- Exactly 32 decoded payload bytes (256-bit public key).
|
||||||
|
- Retains the original regex as a fast pre-filter.
|
||||||
|
"""
|
||||||
|
if not NPUB_RE.fullmatch(value):
|
||||||
|
return False
|
||||||
|
result = _bech32_decode(value)
|
||||||
|
if result is None:
|
||||||
|
return False
|
||||||
|
hrp, payload = result
|
||||||
|
return hrp == "npub" and len(payload) == 32
|
||||||
|
|
||||||
|
|
||||||
|
# ── DDNS URL validation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_DDNS_URL_MAX_LEN = 2048
|
||||||
|
_DDNS_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||||
|
|
||||||
|
# Allowlist: only the official Njal.la provider hostnames are accepted for
|
||||||
|
# DDNS update URLs. Any other host would allow SSRF against the Hub's
|
||||||
|
# internal network.
|
||||||
|
_DDNS_ALLOWED_HOSTNAMES: frozenset[str] = frozenset(["njal.la", "www.njal.la"])
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ddns_url(url: str) -> str:
|
||||||
|
"""Validate *url* as a safe DDNS update URL and return it normalised.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Must be a valid URL parseable by urllib.parse.
|
||||||
|
- Scheme must be ``https`` (case-insensitive).
|
||||||
|
- No userinfo (credentials must not be embedded in the URL).
|
||||||
|
- No fragment.
|
||||||
|
- No control characters.
|
||||||
|
- Must not exceed ``_DDNS_URL_MAX_LEN`` bytes.
|
||||||
|
- Hostname must be the exact Njal.la provider hostname (njal.la or www.njal.la).
|
||||||
|
- Port must be absent or the default HTTPS port 443.
|
||||||
|
- No percent-encoded null bytes.
|
||||||
|
|
||||||
|
Raises ``ValueError`` with a safe (non-secret) message on failure.
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
raise ValueError("DDNS URL must not be empty")
|
||||||
|
if len(url) > _DDNS_URL_MAX_LEN:
|
||||||
|
raise ValueError("DDNS URL exceeds maximum length")
|
||||||
|
if _DDNS_CONTROL_RE.search(url):
|
||||||
|
raise ValueError("DDNS URL contains control characters")
|
||||||
|
try:
|
||||||
|
parsed = urllib.parse.urlparse(url)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("DDNS URL could not be parsed")
|
||||||
|
if parsed.scheme.lower() != "https":
|
||||||
|
raise ValueError("DDNS URL must use the https scheme")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise ValueError("DDNS URL must not contain credentials")
|
||||||
|
if parsed.fragment:
|
||||||
|
raise ValueError("DDNS URL must not contain a fragment")
|
||||||
|
if parsed.port is not None and parsed.port != 443:
|
||||||
|
raise ValueError("DDNS URL must use the default HTTPS port")
|
||||||
|
hostname = parsed.hostname or ""
|
||||||
|
if not hostname:
|
||||||
|
raise ValueError("DDNS URL must contain a hostname")
|
||||||
|
# Reject raw IP addresses
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(hostname)
|
||||||
|
raise ValueError("DDNS URL hostname must not be a raw IP address")
|
||||||
|
except ValueError as exc:
|
||||||
|
if "raw IP" in str(exc):
|
||||||
|
raise
|
||||||
|
# Allowlist: only Njal.la
|
||||||
|
if hostname.lower() not in _DDNS_ALLOWED_HOSTNAMES:
|
||||||
|
raise ValueError(
|
||||||
|
f"DDNS URL hostname is not an allowed Njal.la host "
|
||||||
|
f"(got {hostname!r})"
|
||||||
|
)
|
||||||
|
if "%00" in url.lower():
|
||||||
|
raise ValueError("DDNS URL must not contain encoded null bytes")
|
||||||
|
# Reject any remaining $ expressions — after ${IP} substitution there
|
||||||
|
# must be none. Callers that store ${IP} placeholder URLs must substitute
|
||||||
|
# before calling this function.
|
||||||
|
if "$" in url:
|
||||||
|
raise ValueError("DDNS URL must not contain $ expressions")
|
||||||
|
# Require the exact /update/ path used by Njal.la
|
||||||
|
if parsed.path != "/update/":
|
||||||
|
raise ValueError("DDNS URL path must be exactly /update/")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
# ── SSH public-key validation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SSH_PUBKEY_ALGORITHMS = frozenset([
|
||||||
|
"ssh-ed25519",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
"sk-ssh-ed25519@openssh.com",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_ssh_pubkey(key: str) -> str:
|
||||||
|
"""Validate *key* as a single OpenSSH public key and return it normalised.
|
||||||
|
|
||||||
|
Accepts only single-line keys with a supported algorithm, valid base64
|
||||||
|
payload, and an optional comment. Rejects options, multiple lines,
|
||||||
|
control characters, and unsupported algorithms.
|
||||||
|
|
||||||
|
Raises ``ValueError`` with a safe message on failure.
|
||||||
|
"""
|
||||||
|
key = key.strip()
|
||||||
|
if not key:
|
||||||
|
raise ValueError("SSH public key must not be empty")
|
||||||
|
if _DDNS_CONTROL_RE.search(key):
|
||||||
|
raise ValueError("SSH public key contains control characters")
|
||||||
|
if "\n" in key or "\r" in key:
|
||||||
|
raise ValueError("SSH public key must be a single line")
|
||||||
|
parts = key.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
raise ValueError("SSH public key is malformed")
|
||||||
|
algo, b64 = parts[0], parts[1]
|
||||||
|
if algo not in _SSH_PUBKEY_ALGORITHMS:
|
||||||
|
raise ValueError(f"Unsupported SSH key algorithm: {algo!r}")
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(b64, validate=True)
|
||||||
|
except Exception:
|
||||||
|
raise ValueError("SSH public key payload is not valid base64")
|
||||||
|
if len(decoded) < 20:
|
||||||
|
raise ValueError("SSH public key payload is too short")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
# ── Persistent Hub session store ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_session_store(path: str) -> dict[str, float]:
|
||||||
|
"""Load persisted Hub sessions from *path*.
|
||||||
|
|
||||||
|
Returns a mapping of session token → expiry timestamp (epoch seconds).
|
||||||
|
Expired entries are discarded. A missing, unreadable or malformed file
|
||||||
|
yields an empty mapping — losing sessions is a UX inconvenience (the user
|
||||||
|
must log in again), never a fatal error.
|
||||||
|
|
||||||
|
Persistence exists so that authenticated sessions survive a restart of
|
||||||
|
the Hub service itself. ``nixos-rebuild switch`` restarts
|
||||||
|
``sovran-hub-web.service`` during activation (its unit definition changes
|
||||||
|
with every feature toggle), and without persistence the in-progress
|
||||||
|
rebuild/update status polling loses authentication and the UI hangs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
now = time.time()
|
||||||
|
sessions: dict[str, float] = {}
|
||||||
|
for token, expiry in data.items():
|
||||||
|
if not isinstance(token, str) or not token:
|
||||||
|
continue
|
||||||
|
if isinstance(expiry, bool) or not isinstance(expiry, (int, float)):
|
||||||
|
continue
|
||||||
|
if expiry > now:
|
||||||
|
sessions[token] = float(expiry)
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
|
def save_session_store(path: str, sessions: dict[str, float]) -> bool:
|
||||||
|
"""Atomically persist *sessions* (token → expiry) to *path* with mode 0600.
|
||||||
|
|
||||||
|
Writes to a temp file in the same directory and renames it into place so
|
||||||
|
the store is never left partially written. Returns True on success,
|
||||||
|
False otherwise (persistence is best-effort).
|
||||||
|
"""
|
||||||
|
directory = os.path.dirname(path) or "."
|
||||||
|
fd = None
|
||||||
|
tmp_path = None
|
||||||
|
try:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".hub_sessions_tmp")
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
fd = None # os.fdopen takes ownership of the descriptor
|
||||||
|
json.dump(sessions, f)
|
||||||
|
os.chmod(tmp_path, 0o600)
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
if fd is not None:
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if tmp_path is not None:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
+1014
-544
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,15 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.header-buttons {
|
.header-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -45,42 +54,21 @@
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── OS Version Badge (polished) ───────────────────────────────── */
|
/* ── OS Version Badge — identical styling to the version badge ────
|
||||||
|
── shown next to titles in the service modal windows ──────────── */
|
||||||
.os-version-badge {
|
.os-version-badge {
|
||||||
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: linear-gradient(145deg, #1a2a22, #0f1a15);
|
|
||||||
border: 1px solid rgba(74, 222, 128, 0.25);
|
|
||||||
border-radius: 9999px;
|
|
||||||
padding: 2px 14px 2px 10px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #4ade80;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
margin-left: 12px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
flex-shrink: 0;
|
||||||
|
|
||||||
.os-version-badge:hover {
|
|
||||||
border-color: rgba(74, 222, 128, 0.45);
|
|
||||||
box-shadow: 0 4px 8px rgba(74, 222, 128, 0.15);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.os-version-badge .version-label {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #4ade80;
|
|
||||||
opacity: 0.85;
|
|
||||||
margin-right: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.os-version-badge .version-number {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: #86efac;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── IP bar ─────────────────────────────────────────────────────── */
|
/* ── IP bar ─────────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -1,5 +1,56 @@
|
|||||||
|
/* ── NWC Benefits Grid ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.nwc-benefits-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-benefit-item {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-benefit-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-benefit-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-benefit-content strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-benefit-content p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.nwc-benefits-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Update modal ────────────────────────────────────────────────── */
|
/* ── Update modal ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -607,10 +658,12 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
|
|
||||||
.nwc-tab-intro {
|
.nwc-tab-intro {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
align-items: stretch;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nwc-tab-intro-title {
|
.nwc-tab-intro-title {
|
||||||
@@ -982,6 +1035,74 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
background: #1c2a24;
|
background: #1c2a24;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── NWC Connect Guide (shown after wallet creation) ────────────── */
|
||||||
|
|
||||||
|
.nwc-connect-guide {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(109, 191, 139, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-connect-guide-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-connect-guide-intro {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-connect-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-connect-step {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-step-num {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: #0A1A10;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-connect-note {
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
border-left: 3px solid #f59e0b;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nwc-connect-note strong {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Narrow screens ─────────────────────────────────────────────── */
|
/* ── Narrow screens ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
|||||||
@@ -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,10 @@
|
|||||||
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;
|
||||||
|
// Max consecutive failed rebuild/update status polls before the page gives up
|
||||||
|
// waiting and reloads to re-sync (2s interval → ~2 minutes of failures).
|
||||||
|
// A brief Hub restart during activation only causes a handful of failures.
|
||||||
|
const STATUS_POLL_MAX_FAILURES = 60;
|
||||||
const REBOOT_CHECK_INTERVAL = 5000;
|
const REBOOT_CHECK_INTERVAL = 5000;
|
||||||
const REBOOT_FETCH_TIMEOUT = 12000;
|
const REBOOT_FETCH_TIMEOUT = 12000;
|
||||||
const REBOOT_REQUEST_TIMEOUT = 4000;
|
const REBOOT_REQUEST_TIMEOUT = 4000;
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/* Sovran_SystemsOS Hub — Shared domain-prerequisite instructions.
|
||||||
|
|
||||||
|
SINGLE SOURCE OF TRUTH for the "you need a Njal.la domain + router port
|
||||||
|
forwarding" guidance that must read identically everywhere it appears:
|
||||||
|
|
||||||
|
• First-boot onboarding wizard (Server + Desktop role)
|
||||||
|
— onboarding.js step 3
|
||||||
|
• Feature-enable domain modal (Node role, and any role)
|
||||||
|
— features.js openDomainSetupModal()
|
||||||
|
(Lightning Wallet Connections / NWC, BTCPay Server, Haven, …)
|
||||||
|
• Domain reconfigure / troubleshooting modal
|
||||||
|
— features.js openDomainReconfigureModal()
|
||||||
|
|
||||||
|
Keep these three surfaces word-for-word consistent: always edit this
|
||||||
|
file, never fork the wording inline. Plain classic script (no modules) —
|
||||||
|
both templates load it with a plain <script> tag. */
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/* Escape helper local to this file so it is self-contained on both pages. */
|
||||||
|
function dpEsc(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── "What you'll need" intro ──────────────────────────────────────
|
||||||
|
opts.serviceName — e.g. "Lightning Wallet Connections", or null for
|
||||||
|
the multi-service (onboarding) wording.
|
||||||
|
opts.hostExample — e.g. "lightning" → lightning.yourdomain.com */
|
||||||
|
function renderDomainNeedsHtml(opts) {
|
||||||
|
var serviceName = opts.serviceName || null;
|
||||||
|
var hostExample = dpEsc(opts.hostExample || "myservice");
|
||||||
|
var html = "";
|
||||||
|
|
||||||
|
if (serviceName) {
|
||||||
|
html += "<p>To enable <strong>" + dpEsc(serviceName) + "</strong>, you'll need two things first:</p>";
|
||||||
|
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
||||||
|
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
|
||||||
|
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
|
||||||
|
+ dpEsc(serviceName) + " gets its own hostname: a subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
|
||||||
|
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many.</li>";
|
||||||
|
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
|
||||||
|
+ "to this computer once. All domain-based services share these two ports; "
|
||||||
|
+ "they're required for HTTPS and SSL certificates.</li>";
|
||||||
|
html += "</ol>";
|
||||||
|
} else {
|
||||||
|
html += "<p><strong>Each service below needs two things set up:</strong></p>";
|
||||||
|
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
||||||
|
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
|
||||||
|
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
|
||||||
|
+ "Each service gets its own hostname: its own subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
|
||||||
|
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many, "
|
||||||
|
+ "so a single domain can serve every service.</li>";
|
||||||
|
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
|
||||||
|
+ "to this computer once. All domain-based services share these two ports; "
|
||||||
|
+ "they're required for HTTPS and SSL certificates.</li>";
|
||||||
|
html += "</ol>";
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── "How to set it up at Njal.la" steps ───────────────────────────
|
||||||
|
opts.hostExample — host part used in the examples (default "call").
|
||||||
|
opts.pasteHint — where the curl command goes:
|
||||||
|
"below" (single service) or "next to its service below"
|
||||||
|
(onboarding, many services). */
|
||||||
|
function renderNjallaStepsHtml(opts) {
|
||||||
|
var hostExample = dpEsc((opts && opts.hostExample) || "call");
|
||||||
|
var pasteHint = (opts && opts.pasteHint) || "below";
|
||||||
|
var html = "";
|
||||||
|
|
||||||
|
html += "<p style=\"margin-top:12px;\"><strong>How to set it up at Njal.la:</strong></p>";
|
||||||
|
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
||||||
|
html += "<li>Create an account at <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">https://njal.la</a> and buy a domain.</li>";
|
||||||
|
html += "<li>Add a <strong>Dynamic</strong> record for the hostname:"
|
||||||
|
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
|
||||||
|
+ "<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>"
|
||||||
|
+ "(For "" + hostExample + ".yourdomain.com" you'd type just: <code>" + hostExample + "</code>.)<br>"
|
||||||
|
+ "⚠ Do NOT type the full domain here — Njal.la adds it automatically.</li>"
|
||||||
|
+ "<li>Dedicating a whole separate domain to this service? Leave the Name field blank or use <code>@</code>.</li>"
|
||||||
|
+ "</ul>"
|
||||||
|
+ "</li>";
|
||||||
|
html += "<li>A Dynamic record has <strong>NO IP field</strong> — you don'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>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── "One router task" port-forwarding box ─────────────────────────
|
||||||
|
opts.internalIp — LAN IP string, or empty/null for generic wording.
|
||||||
|
opts.plural — true for multi-service (onboarding) wording.
|
||||||
|
opts.includeSsh — also mention port 22 for SSH (onboarding).
|
||||||
|
opts.extraNote — extra sentence appended at the end (optional). */
|
||||||
|
function renderRouterPortsHtml(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var ipPart = opts.internalIp
|
||||||
|
? " to this computer’s internal IP <strong>" + dpEsc(opts.internalIp) + "</strong>"
|
||||||
|
: " to this computer’s internal IP";
|
||||||
|
var serviceWord = opts.plural ? "services" : "service";
|
||||||
|
|
||||||
|
var html = "";
|
||||||
|
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>"
|
||||||
|
+ ipPart + ". Use the <strong>same number for the internal and external port</strong>. "
|
||||||
|
+ "This only needs to be done once — all domain services share these ports — "
|
||||||
|
+ "but HTTPS and SSL certificates won’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) {
|
||||||
|
html += dpEsc(opts.extraNote);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Async helper: fill a router-box container with the internal IP ──
|
||||||
|
Renders generic text immediately, then upgrades to the concrete LAN IP
|
||||||
|
if /api/network provides one. Best-effort — never blocks the UI. */
|
||||||
|
function renderRouterPortsBox(elId, opts) {
|
||||||
|
var el = document.getElementById(elId);
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = renderRouterPortsHtml(opts || {});
|
||||||
|
fetch("/api/network")
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var target = document.getElementById(elId);
|
||||||
|
if (!target) return;
|
||||||
|
var ip = data && data.internal_ip;
|
||||||
|
if (ip && ip !== "unavailable") {
|
||||||
|
var next = {};
|
||||||
|
for (var k in (opts || {})) next[k] = opts[k];
|
||||||
|
next.internalIp = String(ip).trim();
|
||||||
|
target.innerHTML = renderRouterPortsHtml(next);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { /* generic wording already shown — fine */ });
|
||||||
|
}
|
||||||
@@ -187,30 +187,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++) {
|
||||||
|
|||||||
@@ -86,45 +86,17 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
|
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
|
||||||
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
|
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
|
||||||
|
|
||||||
var introHtml;
|
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
|
||||||
if (_currentRole === "node") {
|
// identical wording to the Server + Desktop onboarding wizard and every other
|
||||||
introHtml =
|
// domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
|
||||||
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la.</p>' +
|
var hostExample = isWalletConnections ? "lightning" : "call";
|
||||||
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
|
||||||
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">njal.la</a>.</li>' +
|
|
||||||
'<li>Set up a domain for it — either a free subdomain or a separate domain. Pick one option:</li>' +
|
|
||||||
'</ol>';
|
|
||||||
} else {
|
|
||||||
introHtml =
|
|
||||||
'<p>To enable <strong>' + escHtml(feat.name) + '</strong>, it needs its own domain from Njal.la. ' +
|
|
||||||
'In your Njal.la account, set up a domain for it — either a free subdomain or a separate domain. Pick one option:</p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$domainSetupBody.innerHTML =
|
$domainSetupBody.innerHTML =
|
||||||
'<div class="domain-setup-intro">' +
|
'<div class="domain-setup-intro">' +
|
||||||
nwcWarning +
|
nwcWarning +
|
||||||
introHtml +
|
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample }) +
|
||||||
'<details style="margin-top:10px;">' +
|
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
|
||||||
'<summary style="cursor:pointer;font-weight:600;">Option A — Free subdomain (recommended)</summary>' +
|
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
|
||||||
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
|
||||||
'<li>In Njal.la, open a domain you own and click "Add record".</li>' +
|
|
||||||
'<li>Set record type to <strong>Dynamic</strong>.</li>' +
|
|
||||||
'<li>In the <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>' +
|
|
||||||
'(Example only, your choice — for "' + domainLabelExample + '" you'd type just: <code>' + (isWalletConnections ? 'lightning' : 'call') + '</code>)<br>' +
|
|
||||||
'⚠ Do NOT type the full domain here — Njal.la adds it automatically.</li>' +
|
|
||||||
'<li>A Dynamic record has NO IP field — the IP auto-fills after the rebuild/reboot.</li>' +
|
|
||||||
'<li>Copy the curl command Njal.la gives you, e.g.:<br>' +
|
|
||||||
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=' + domainLabelExample + '&k=abc123&auto"</code></li>' +
|
|
||||||
'</ol>' +
|
|
||||||
'</details>' +
|
|
||||||
'<details style="margin-top:6px;">' +
|
|
||||||
'<summary style="cursor:pointer;font-weight:600;">Option B — Separate / new domain</summary>' +
|
|
||||||
'<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">' +
|
|
||||||
'<li>In Njal.la, buy the domain you want.</li>' +
|
|
||||||
'<li>Add a Dynamic record as in Option A. If this domain is dedicated to the service, leave the Name field blank or use <code>@</code>.</li>' +
|
|
||||||
'<li>Copy the curl command Njal.la gives you.</li>' +
|
|
||||||
'</ol>' +
|
|
||||||
'</details>' +
|
|
||||||
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' +
|
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
|
||||||
@@ -170,6 +142,9 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$domainSetupModal.classList.add("open");
|
$domainSetupModal.classList.add("open");
|
||||||
|
|
||||||
|
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
|
||||||
|
renderRouterPortsBox("domain-router-box");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||||
@@ -218,7 +193,9 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
|||||||
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
||||||
'<li>If the IP is wrong or the record is missing, update it</li>' +
|
'<li>If the IP is wrong or the record is missing, update it</li>' +
|
||||||
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
|
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
|
||||||
|
'<li>Confirm ports <strong>80</strong> and <strong>443</strong> (TCP) are still forwarded on your router to this computer — see the reminder below:</li>' +
|
||||||
'</ol>' +
|
'</ol>' +
|
||||||
|
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" value="' + escHtml(currentDomain) + '" /></div>' +
|
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" value="' + escHtml(currentDomain) + '" /></div>' +
|
||||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "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 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>' +
|
||||||
@@ -263,6 +240,9 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$domainSetupModal.classList.add("open");
|
$domainSetupModal.classList.add("open");
|
||||||
|
|
||||||
|
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
|
||||||
|
renderRouterPortsBox("domain-router-box");
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDomainSetupModal() {
|
function closeDomainSetupModal() {
|
||||||
@@ -362,8 +342,11 @@ async function performFeatureToggle(featId, enabled, extra) {
|
|||||||
function handleFeatureToggle(feat, newEnabled) {
|
function handleFeatureToggle(feat, newEnabled) {
|
||||||
if (!newEnabled) {
|
if (!newEnabled) {
|
||||||
// Disable: ask confirmation
|
// Disable: ask confirmation
|
||||||
|
var disableMessage = (feat.id === "bitcoin-tor-gossip")
|
||||||
|
? "This will stop advertising your Bitcoin Core onion address to other peers. Nodes that already know the address can still connect through Tor, and no clearnet port will be opened. The system will rebuild. Continue?"
|
||||||
|
: "This will disable " + feat.name + ". The system will rebuild. Continue?";
|
||||||
openFeatureConfirm(
|
openFeatureConfirm(
|
||||||
"This will disable " + feat.name + ". The system will rebuild. Continue?",
|
disableMessage,
|
||||||
function() { performFeatureToggle(feat.id, false, {}); }
|
function() { performFeatureToggle(feat.id, false, {}); }
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -423,9 +406,9 @@ function handleFeatureToggle(feat, newEnabled) {
|
|||||||
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feat.id === "bitcoin-core") {
|
if (feat.id === "bitcoin-tor-gossip") {
|
||||||
var confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will replace Bitcoin Knots + BIP110 as the active node. Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
|
var torGossipConfirmMsg = "This will advertise your Bitcoin Core .onion P2P address through Bitcoin peer gossip. More Tor-capable nodes may discover your node and request historical blocks during IBD, which can use significant upload bandwidth. Your home IP remains hidden and no clearnet port or router forwarding is opened. Continue?";
|
||||||
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
openFeatureConfirm(torGossipConfirmMsg, proceedAfterConflictCheck);
|
||||||
} else if (conflictNames.length > 0) {
|
} else if (conflictNames.length > 0) {
|
||||||
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -133,17 +144,3 @@ async function apiFetch(path, options) {
|
|||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── BIP-110 badge state config ────────────────────────────────────
|
|
||||||
// Shared lookup used by tiles.js and service-detail.js.
|
|
||||||
// Keys match the "state" values returned by /api/bitcoin/bip110.
|
|
||||||
|
|
||||||
var BIP110_BADGE_CONFIG = {
|
|
||||||
active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' },
|
|
||||||
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' },
|
|
||||||
signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' },
|
|
||||||
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' },
|
|
||||||
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' },
|
|
||||||
unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' }
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function openRebuildModal() {
|
|||||||
_rebuildLogOffset = 0;
|
_rebuildLogOffset = 0;
|
||||||
_rebuildServerDown = false;
|
_rebuildServerDown = false;
|
||||||
_rebuildFinished = false;
|
_rebuildFinished = 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";
|
||||||
@@ -45,6 +46,7 @@ async function pollRebuildStatus() {
|
|||||||
if (_rebuildFinished) return;
|
if (_rebuildFinished) return;
|
||||||
try {
|
try {
|
||||||
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
|
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
|
||||||
|
_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,6 +59,18 @@ 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, the page's session
|
||||||
|
// almost certainly no longer matches the server (e.g. the Hub restarted
|
||||||
|
// and sessions were not recovered). Reload to re-authenticate and show
|
||||||
|
// the real feature state instead of spinning forever.
|
||||||
|
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…"; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
// ── Service detail modal ──────────────────────────────────────────
|
// ── Service detail modal ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function _getZeusConnectGuideHtml() {
|
||||||
|
return '<div class="nwc-connect-guide">' +
|
||||||
|
'<div class="nwc-connect-guide-title">📱 Connect to Zeus</div>' +
|
||||||
|
'<p class="nwc-connect-guide-intro">This connection URL is an <strong>LND REST</strong> connection — the direct way to use Zeus with your node for full admin access. It connects securely through Tor, allowing you to manage channels, balances, and your full node on the go.</p>' +
|
||||||
|
'<div class="nwc-connect-steps">' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">1</div><div><strong>Download Zeus</strong> from the App Store or Google Play.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the LND REST QR and fills in the connection details. Review them, then tap <strong>Save Wallet Config</strong>.</div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the NWC pairing QR shown in Lightning Wallet Connections — that gives your wallet sandboxed, limited access for everyday spending. LND REST connects Zeus directly to your node for full admin control.</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
function _renderCredsHtml(credentials, unit) {
|
function _renderCredsHtml(credentials, unit) {
|
||||||
var html = "";
|
var html = "";
|
||||||
for (var i = 0; i < credentials.length; i++) {
|
for (var i = 0; i < credentials.length; i++) {
|
||||||
@@ -9,7 +24,10 @@ function _renderCredsHtml(credentials, unit) {
|
|||||||
var id = "cred-" + Math.random().toString(36).substring(2, 8);
|
var id = "cred-" + Math.random().toString(36).substring(2, 8);
|
||||||
var qrBlock = "";
|
var qrBlock = "";
|
||||||
if (cred.qrcode) {
|
if (cred.qrcode) {
|
||||||
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</div></div>';
|
var qrHint = (unit === "zeus-connect-setup.service")
|
||||||
|
? "This is an <strong>LND REST</strong> connection QR — in Zeus, add a wallet and use the scan icon (see steps below)."
|
||||||
|
: "In Zeus: <em>Wallets → + → scan icon</em>. This is an <strong>LND REST</strong> QR for direct node access.";
|
||||||
|
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">' + qrHint + '</div></div>';
|
||||||
}
|
}
|
||||||
// If qronly, render the label + QR block only — skip value and copy button
|
// If qronly, render the label + QR block only — skip value and copy button
|
||||||
if (cred.qronly) {
|
if (cred.qronly) {
|
||||||
@@ -174,7 +192,7 @@ function _nwcRenderWalletState() {
|
|||||||
var selectedLimited = state.createForm.access_preset === "send_receive_limited";
|
var selectedLimited = state.createForm.access_preset === "send_receive_limited";
|
||||||
html +=
|
html +=
|
||||||
'<div class="nwc-tab-intro-title">Create a Wallet</div>' +
|
'<div class="nwc-tab-intro-title">Create a Wallet</div>' +
|
||||||
'<p class="nwc-tab-intro-desc">An isolated wallet you pair to a single app, with its own Lightning Address.</p>' +
|
'<p class="nwc-tab-intro-desc">Create a secure, sandboxed wallet for a specific app. Each wallet gets its own Lightning Address and optional spending limit.</p>' +
|
||||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-name">Wallet Name</label>' +
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-name">Wallet Name</label>' +
|
||||||
'<input class="matrix-form-input" id="nwc-wallet-name" type="text" placeholder="My Wallet" value="' + escHtml(state.createForm.name || "") + '" autocomplete="off"></div>' +
|
'<input class="matrix-form-input" id="nwc-wallet-name" type="text" placeholder="My Wallet" value="' + escHtml(state.createForm.name || "") + '" autocomplete="off"></div>' +
|
||||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-alias">Lightning Address Alias</label>' +
|
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-alias">Lightning Address Alias</label>' +
|
||||||
@@ -218,7 +236,8 @@ function _nwcRenderWalletState() {
|
|||||||
var pairId = "nwc-pairing-uri-" + Math.random().toString(36).substring(2, 8);
|
var pairId = "nwc-pairing-uri-" + Math.random().toString(36).substring(2, 8);
|
||||||
html += '<div class="nwc-secret-warning">⚠ One-time pairing secret. Save it now — it will not be shown again.</div>';
|
html += '<div class="nwc-secret-warning">⚠ One-time pairing secret. Save it now — it will not be shown again.</div>';
|
||||||
if (created.pairing_qrcode) {
|
if (created.pairing_qrcode) {
|
||||||
html += '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + created.pairing_qrcode + '" alt="QR code for Lightning Wallet Connections pairing secret"><div class="creds-qr-hint">Scan now in Zeus or copy the URI below.</div></div>';
|
html += '<div class="creds-row"><div class="creds-label">QR Code</div>' +
|
||||||
|
'<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + created.pairing_qrcode + '" alt="QR code for Lightning Wallet Connections pairing secret"><div class="creds-qr-hint">This is an <strong>NWC</strong> pairing QR — in Zeus, add a wallet and use the scan icon (see steps below).</div></div></div>';
|
||||||
}
|
}
|
||||||
html += '<div class="creds-row"><div class="creds-label">Pairing URI</div>' +
|
html += '<div class="creds-row"><div class="creds-label">Pairing URI</div>' +
|
||||||
'<div class="creds-value-wrap"><div class="creds-value" id="' + pairId + '">' + escHtml(created.pairing_uri || "Unavailable") + '</div><button class="creds-copy-btn" data-target="' + pairId + '">Copy</button></div></div>';
|
'<div class="creds-value-wrap"><div class="creds-value" id="' + pairId + '">' + escHtml(created.pairing_uri || "Unavailable") + '</div><button class="creds-copy-btn" data-target="' + pairId + '">Copy</button></div></div>';
|
||||||
@@ -226,6 +245,18 @@ function _nwcRenderWalletState() {
|
|||||||
html += '<div class="creds-row"><div class="creds-label">Lightning Address</div>' +
|
html += '<div class="creds-row"><div class="creds-label">Lightning Address</div>' +
|
||||||
'<div class="creds-value-wrap"><div class="creds-value">' + escHtml(created.wallet.lightning_address) + '</div></div></div>';
|
'<div class="creds-value-wrap"><div class="creds-value">' + escHtml(created.wallet.lightning_address) + '</div></div></div>';
|
||||||
}
|
}
|
||||||
|
html += '<div class="nwc-connect-guide">' +
|
||||||
|
'<div class="nwc-connect-guide-title">📱 Connect to Zeus</div>' +
|
||||||
|
'<p class="nwc-connect-guide-intro">This pairing URI is an <strong>NWC (Nostr Wallet Connect)</strong> connection — the modern, mobile-friendly way to use Zeus with your node. It connects directly through your Lightning domain, so no Tor or port forwarding is needed on your phone.</p>' +
|
||||||
|
'<div class="nwc-connect-steps">' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">1</div><div><strong>Download Zeus</strong> from the App Store or Google Play.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
|
||||||
|
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the NWC QR and fills in <strong>Nostr Wallet Connect</strong>. Review it, then tap <strong>Save Wallet Config</strong>.</div></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the LND REST / Tor QR shown on your LND tile — that connects Zeus directly to your Lightning node for full admin control. NWC gives your wallet sandboxed, limited access for everyday spending.</div>' +
|
||||||
|
'</div>';
|
||||||
html += '<div class="matrix-form-actions">' +
|
html += '<div class="matrix-form-actions">' +
|
||||||
'<button class="matrix-form-back" id="nwc-created-another-btn"' + (state.busy ? " disabled" : "") + '>Create Another Wallet</button>' +
|
'<button class="matrix-form-back" id="nwc-created-another-btn"' + (state.busy ? " disabled" : "") + '>Create Another Wallet</button>' +
|
||||||
'<button class="matrix-form-submit" id="nwc-created-continue-btn"' + (state.busy ? " disabled" : "") + '>I Saved This Secret</button>' +
|
'<button class="matrix-form-submit" id="nwc-created-continue-btn"' + (state.busy ? " disabled" : "") + '>I Saved This Secret</button>' +
|
||||||
@@ -330,9 +361,9 @@ function _nwcRenderWalletState() {
|
|||||||
|
|
||||||
if (!state.wallets || state.wallets.length === 0) {
|
if (!state.wallets || state.wallets.length === 0) {
|
||||||
html += '<div class="nwc-empty-state">' +
|
html += '<div class="nwc-empty-state">' +
|
||||||
'<div class="nwc-empty-icon">👛</div>' +
|
'<div class="nwc-empty-icon">⚡</div>' +
|
||||||
'<div class="nwc-empty-title">No wallets yet</div>' +
|
'<div class="nwc-empty-title">Ready to start spending</div>' +
|
||||||
'<p class="nwc-empty-desc">Create your first wallet to get a one-time pairing secret for an app, plus a reusable Lightning Address.</p>' +
|
'<p class="nwc-empty-desc">Create your first isolated wallet to connect to apps like Zeus (via NWC) or Nostr. Experience faster, more secure Lightning payments today.</p>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
host.innerHTML = html;
|
host.innerHTML = html;
|
||||||
_nwcWireDomainLink();
|
_nwcWireDomainLink();
|
||||||
@@ -673,20 +704,6 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>');
|
'</div>');
|
||||||
|
|
||||||
// Section B2: BIP-110 live status (bip110 tile only)
|
|
||||||
if (icon === 'bip110' && data.bip110) {
|
|
||||||
var bip110 = data.bip110;
|
|
||||||
var bip110State = bip110.state || 'unknown';
|
|
||||||
var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown;
|
|
||||||
var bip110Source = bip110.source ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
|
|
||||||
html += '<div class="svc-detail-section">' +
|
|
||||||
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
|
|
||||||
'<div class="bip110-status-row">' +
|
|
||||||
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
|
|
||||||
bip110Source +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Section C: Domain diagnostics (domain services)
|
// Section C: Domain diagnostics (domain services)
|
||||||
if (data.needs_domain) {
|
if (data.needs_domain) {
|
||||||
@@ -761,9 +778,32 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
if (isNwc) {
|
if (isNwc) {
|
||||||
if (effectiveEnabled || data.enabled) {
|
if (effectiveEnabled || data.enabled) {
|
||||||
html += '<div class="nwc-tab-intro">' +
|
html += '<div class="nwc-tab-intro">' +
|
||||||
'<div>' +
|
'<div class="nwc-intro-header">' +
|
||||||
'<div class="nwc-tab-intro-title">Your Lightning Wallets</div>' +
|
'<div class="nwc-tab-intro-title">Lightning Wallet Connections</div>' +
|
||||||
'<p class="nwc-tab-intro-desc">Each wallet is isolated: pair it to one app with a one-time secret, and share its Lightning Address to get paid.</p>' +
|
'<p class="nwc-tab-intro-desc">Powerful, isolated wallets for your daily spending and modern apps.</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="nwc-benefits-grid">' +
|
||||||
|
'<div class="nwc-benefit-item">' +
|
||||||
|
'<div class="nwc-benefit-icon">🛡️</div>' +
|
||||||
|
'<div class="nwc-benefit-content">' +
|
||||||
|
'<strong>Isolated & Secure</strong>' +
|
||||||
|
'<p>Create sandboxed wallets for quick spending while keeping your main node protected. Isolated access is faster than Tor and perfect for budgeting.</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="nwc-benefit-item">' +
|
||||||
|
'<div class="nwc-benefit-icon">🌐</div>' +
|
||||||
|
'<div class="nwc-benefit-content">' +
|
||||||
|
'<strong>Modern Ecosystem</strong>' +
|
||||||
|
'<p>Easily spend and receive bitcoin using LNURL and Nostr (NWC) across the growing ecosystem of decentralized apps.</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="nwc-benefit-item">' +
|
||||||
|
'<div class="nwc-benefit-icon">📱</div>' +
|
||||||
|
'<div class="nwc-benefit-content">' +
|
||||||
|
'<strong>Zeus on the Go (via NWC)</strong>' +
|
||||||
|
'<p>Connect Zeus to your wallet using NWC — no Tor, no port forwarding. Create a wallet below, scan the pairing QR, and start spending from your phone.</p>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div id="nwc-wallets-body"><p class="creds-loading">Loading wallets…</p></div>' +
|
'<div id="nwc-wallets-body"><p class="creds-loading">Loading wallets…</p></div>' +
|
||||||
@@ -813,6 +853,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
html += '<div class="svc-detail-section">' +
|
html += '<div class="svc-detail-section">' +
|
||||||
'<div class="svc-detail-section-title">Credentials & Access</div>' +
|
'<div class="svc-detail-section-title">Credentials & Access</div>' +
|
||||||
_renderCredsHtml(data.credentials, unit) +
|
_renderCredsHtml(data.credentials, unit) +
|
||||||
|
(unit === "zeus-connect-setup.service" ? _getZeusConnectGuideHtml() : "") +
|
||||||
(unit === "matrix-synapse.service" ?
|
(unit === "matrix-synapse.service" ?
|
||||||
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
||||||
'<button class="matrix-action-btn" id="matrix-add-user-btn">➕ Add New User</button>' +
|
'<button class="matrix-action-btn" id="matrix-add-user-btn">➕ Add New User</button>' +
|
||||||
@@ -846,9 +887,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||||
|
|
||||||
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
||||||
var addonSectionTitle = (feat.id === "bitcoin-core")
|
var addonSectionTitle = "\uD83D\uDD27 Addon Feature";
|
||||||
? "\u20BF Bitcoin Node Selection"
|
|
||||||
: "\uD83D\uDD27 Addon Feature";
|
|
||||||
|
|
||||||
// Description: prefer the feature's own description over a generic fallback
|
// Description: prefer the feature's own description over a generic fallback
|
||||||
var addonDesc = feat.description
|
var addonDesc = feat.description
|
||||||
@@ -879,6 +918,54 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
'</div>');
|
'</div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Section G: Contextual feature options. The Tor IBD advertising control is
|
||||||
|
// deliberately shown only inside the Bitcoin Core modal so its bandwidth
|
||||||
|
// and privacy implications are explained before the user enables it.
|
||||||
|
var relatedFeatures = Array.isArray(data.related_features) ? data.related_features : [];
|
||||||
|
relatedFeatures.forEach(function(optionFeat) {
|
||||||
|
// Keep the shared feature state current for the standard rebuild flow.
|
||||||
|
if (!_featuresData) {
|
||||||
|
_featuresData = { features: [optionFeat], ssl_email_configured: false };
|
||||||
|
} else {
|
||||||
|
var optionIndex = _featuresData.features.findIndex(function(f) { return f.id === optionFeat.id; });
|
||||||
|
if (optionIndex >= 0) _featuresData.features[optionIndex] = optionFeat;
|
||||||
|
else _featuresData.features.push(optionFeat);
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailsHtml = "";
|
||||||
|
if (Array.isArray(optionFeat.details) && optionFeat.details.length > 0) {
|
||||||
|
detailsHtml = '<ul class="svc-detail-option-list">' +
|
||||||
|
optionFeat.details.map(function(detail) {
|
||||||
|
return '<li>' + escHtml(detail) + '</li>';
|
||||||
|
}).join("") +
|
||||||
|
'</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var optionAvailable = optionFeat.available !== false;
|
||||||
|
var optionStatusLabel = optionFeat.enabled ? "Advertising enabled \u2713" : "Not advertised";
|
||||||
|
var optionStatusCls = optionFeat.enabled ? "addon-status--on" : "addon-status--off";
|
||||||
|
var optionButtonLabel = optionFeat.enabled ? "Stop Advertising" : "Advertise Onion Address";
|
||||||
|
var optionButtonCls = optionFeat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||||
|
if (!optionAvailable) {
|
||||||
|
optionStatusLabel = "Bitcoin Core is not enabled";
|
||||||
|
optionButtonLabel = "Enable Bitcoin Core First";
|
||||||
|
optionButtonCls = "btn btn-close-modal";
|
||||||
|
}
|
||||||
|
|
||||||
|
addSetup('<div class="svc-detail-section svc-detail-option-card">' +
|
||||||
|
'<div class="svc-detail-section-title">Tor IBD Service Advertising</div>' +
|
||||||
|
'<p class="svc-detail-desc">' + escHtml(optionFeat.description || "") + '</p>' +
|
||||||
|
detailsHtml +
|
||||||
|
'<div class="svc-detail-option-privacy">' +
|
||||||
|
'<strong>Tor-only:</strong> This setting advertises the onion service. It does not open a clearnet port, expose your home IP address, or require router port forwarding.' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="svc-detail-addon-row">' +
|
||||||
|
'<span class="svc-detail-addon-status ' + optionStatusCls + '">' + escHtml(optionStatusLabel) + '</span>' +
|
||||||
|
'<button class="' + optionButtonCls + ' svc-detail-related-feature-btn" data-feature-id="' + escHtml(optionFeat.id) + '"' + (!optionAvailable ? ' disabled' : '') + '>' + escHtml(optionButtonLabel) + '</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>');
|
||||||
|
});
|
||||||
|
|
||||||
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
||||||
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
|
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
|
||||||
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
||||||
@@ -939,6 +1026,18 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var relatedFeatureButtons = $credsBody.querySelectorAll(".svc-detail-related-feature-btn");
|
||||||
|
relatedFeatureButtons.forEach(function(button) {
|
||||||
|
button.addEventListener("click", function() {
|
||||||
|
if (button.disabled) return;
|
||||||
|
var featureId = button.getAttribute("data-feature-id");
|
||||||
|
var relatedFeat = relatedFeatures.find(function(f) { return f.id === featureId; });
|
||||||
|
if (!relatedFeat) return;
|
||||||
|
closeCredsModal();
|
||||||
|
handleFeatureToggle(relatedFeat, !relatedFeat.enabled);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
var restartBtn = document.getElementById("svc-detail-restart-btn");
|
var restartBtn = document.getElementById("svc-detail-restart-btn");
|
||||||
var restartResult = document.getElementById("svc-detail-restart-result");
|
var restartResult = document.getElementById("svc-detail-restart-result");
|
||||||
if (restartBtn && restartResult) {
|
if (restartBtn && restartResult) {
|
||||||
@@ -1030,6 +1129,9 @@ async function openCredsModal(unit, name, icon) {
|
|||||||
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
if (unit === "zeus-connect-setup.service") {
|
||||||
|
html += _getZeusConnectGuideHtml();
|
||||||
|
}
|
||||||
$credsBody.innerHTML = html;
|
$credsBody.innerHTML = html;
|
||||||
_attachCopyHandlers($credsBody);
|
_attachCopyHandlers($credsBody);
|
||||||
if (unit === "matrix-synapse.service") {
|
if (unit === "matrix-synapse.service") {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ let _updatePollTimer = null;
|
|||||||
let _updateLogOffset = 0;
|
let _updateLogOffset = 0;
|
||||||
let _serverWasDown = false;
|
let _serverWasDown = false;
|
||||||
let _updateFinished = false;
|
let _updateFinished = 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
|
||||||
@@ -25,6 +26,7 @@ let _rebuildLogOffset = 0;
|
|||||||
let _rebuildPollTimer = null;
|
let _rebuildPollTimer = null;
|
||||||
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;
|
||||||
|
|||||||
@@ -110,12 +110,26 @@ function renderSupportInactive() {
|
|||||||
'</div>',
|
'</div>',
|
||||||
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
|
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
|
||||||
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
|
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
|
||||||
'<li>Our SSH key is added only to that restricted account</li>',
|
'<li>Support\'s SSH key is added only to that restricted account — not to root</li>',
|
||||||
'<li>Wallet files are locked via access controls — not visible to support</li>',
|
'<li>Wallet files are locked via access controls — not visible to support</li>',
|
||||||
'<li>You control if and when wallet access is granted (time-limited)</li>',
|
'<li>You control if and when wallet access is granted (time-limited)</li>',
|
||||||
'<li>All session events are logged for your audit</li>',
|
'<li>All session events are logged for your audit</li>',
|
||||||
|
'<li>Access expires automatically after 24 hours</li>',
|
||||||
'</ol></div>',
|
'</ol></div>',
|
||||||
|
'<div class="support-key-section">',
|
||||||
|
'<label class="support-key-label" for="support-ssh-pubkey">',
|
||||||
|
'<strong>Paste the support SSH public key provided by Sovran Systems:</strong>',
|
||||||
|
'</label>',
|
||||||
|
'<textarea id="support-ssh-pubkey" class="support-key-input" rows="3" ',
|
||||||
|
'placeholder="ssh-ed25519 AAAA… support-session" ',
|
||||||
|
'spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>',
|
||||||
|
'<p class="support-key-hint">',
|
||||||
|
'The key must start with <code>ssh-ed25519</code> or <code>ecdsa-sha2-nistp256</code>. ',
|
||||||
|
'Do not paste your own private key — only paste the one-time public key sent by Sovran Systems support.',
|
||||||
|
'</p>',
|
||||||
|
'</div>',
|
||||||
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
|
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
|
||||||
|
'<p id="support-key-error" class="support-key-error" style="display:none;color:#c0392b;margin-top:8px;"></p>',
|
||||||
'<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>',
|
'<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>',
|
||||||
'</div>',
|
'</div>',
|
||||||
].join("");
|
].join("");
|
||||||
@@ -227,16 +241,43 @@ function renderSupportRemoved(verified) {
|
|||||||
|
|
||||||
async function enableSupport() {
|
async function enableSupport() {
|
||||||
var btn = document.getElementById("btn-support-enable");
|
var btn = document.getElementById("btn-support-enable");
|
||||||
|
var errEl = document.getElementById("support-key-error");
|
||||||
|
var textarea = document.getElementById("support-ssh-pubkey");
|
||||||
|
if (errEl) { errEl.style.display = "none"; errEl.textContent = ""; }
|
||||||
|
|
||||||
|
var sshKey = textarea ? textarea.value.trim() : "";
|
||||||
|
if (!sshKey) {
|
||||||
|
if (errEl) { errEl.textContent = "Please paste the SSH public key provided by Sovran Systems support."; errEl.style.display = "block"; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Client-side pre-validation: key must start with a known algorithm prefix
|
||||||
|
var validPrefixes = ["ssh-ed25519 ", "ecdsa-sha2-nistp256 ", "ecdsa-sha2-nistp384 ", "ecdsa-sha2-nistp521 ", "sk-ssh-ed25519@openssh.com "];
|
||||||
|
var hasValidPrefix = validPrefixes.some(function(p) { return sshKey.startsWith(p); });
|
||||||
|
if (!hasValidPrefix) {
|
||||||
|
if (errEl) { errEl.textContent = "Invalid key format. The key must start with ssh-ed25519 or ecdsa-sha2-nistp256. Do not paste a private key."; errEl.style.display = "block"; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sshKey.indexOf("\n") !== -1) {
|
||||||
|
if (errEl) { errEl.textContent = "The key must be a single line. Please check the pasted value."; errEl.style.display = "block"; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
|
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/support/enable", { method: "POST" });
|
await apiFetch("/api/support/enable", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ssh_public_key: sshKey }),
|
||||||
|
});
|
||||||
var status = await apiFetch("/api/support/status");
|
var status = await apiFetch("/api/support/status");
|
||||||
_supportStatus = status;
|
_supportStatus = status;
|
||||||
_supportEnabledAt = status.enabled_at;
|
_supportEnabledAt = status.enabled_at;
|
||||||
renderSupportActive(status);
|
renderSupportActive(status);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
||||||
alert("Failed to enable support access. Please try again.");
|
var detail = (err && err.detail) ? err.detail : "Failed to enable support access. Please check the key and try again.";
|
||||||
|
if (errEl) { errEl.textContent = detail; errEl.style.display = "block"; }
|
||||||
|
else { alert(detail); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,11 +514,27 @@ function renderBackupReady(drives) {
|
|||||||
].join("");
|
].join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
$supportBody.innerHTML = [
|
// ── Role-aware backup description ─────────────────────────────
|
||||||
'<div class="support-section">',
|
// Desktop Only systems run no server or Bitcoin services and have no
|
||||||
'<div class="support-icon-big">\ud83d\udcbe</div>',
|
// internal second drive, so the backup mirrors only the NixOS configuration
|
||||||
'<h3 class="support-heading">Manual Backup</h3>',
|
// and home directory. nix-bitcoin secrets, /var/lib system service data,
|
||||||
|
// and the database/blockchain caveats apply only to the Node and
|
||||||
|
// Server + Desktop roles.
|
||||||
|
var isDesktopOnly = (_currentRole === "desktop");
|
||||||
|
|
||||||
|
var introHtml;
|
||||||
|
if (isDesktopOnly) {
|
||||||
|
introHtml = [
|
||||||
|
'<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
|
||||||
|
'<p class="support-wallet-desc">',
|
||||||
|
'This manual backup lets you create a copy of your system on an external USB drive \u2014 ',
|
||||||
|
'storing your data in a second location, outside the computer, for maximum protection ',
|
||||||
|
'against hardware failure or physical damage.',
|
||||||
|
'</p>',
|
||||||
|
'</div>',
|
||||||
|
].join("");
|
||||||
|
} else {
|
||||||
|
introHtml = [
|
||||||
'<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
|
'<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
|
||||||
'<p class="support-wallet-desc">',
|
'<p class="support-wallet-desc">',
|
||||||
'Your Sovran Pro already backs up your data automatically to its internal second drive. ',
|
'Your Sovran Pro already backs up your data automatically to its internal second drive. ',
|
||||||
@@ -486,6 +543,44 @@ function renderBackupReady(drives) {
|
|||||||
'against hardware failure or physical damage.',
|
'against hardware failure or physical damage.',
|
||||||
'</p>',
|
'</p>',
|
||||||
'</div>',
|
'</div>',
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
var backupItemsHtml;
|
||||||
|
if (isDesktopOnly) {
|
||||||
|
backupItemsHtml = [
|
||||||
|
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
||||||
|
'<li>Home directory (<code>/home</code>)</li>',
|
||||||
|
].join("");
|
||||||
|
} else {
|
||||||
|
backupItemsHtml = [
|
||||||
|
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
||||||
|
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
|
||||||
|
'<li>System service data (<code>/var/lib</code>) — excluding databases and blockchain data (see note below)</li>',
|
||||||
|
'<li>Home directory (<code>/home</code>)</li>',
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The database/blockchain caveat is only relevant when server services exist.
|
||||||
|
var dbNoteHtml = "";
|
||||||
|
if (!isDesktopOnly) {
|
||||||
|
dbNoteHtml = [
|
||||||
|
'<div class="support-wallet-box support-wallet-warning">',
|
||||||
|
'<div class="support-wallet-header">',
|
||||||
|
'<span class="support-wallet-icon">\u2139\ufe0f</span>',
|
||||||
|
'<span class="support-wallet-title">Database and Blockchain Data</span>',
|
||||||
|
'</div>',
|
||||||
|
'<p class="support-wallet-desc">Application databases stored in PostgreSQL or MariaDB/MySQL are <strong>not included</strong> in Manual Backup. Bitcoin blockchain and Electrs index data are also excluded (they are stored on the internal second drive). If you use Nextcloud, Matrix, or other database-backed applications, back up those databases separately with their native tools.</p>',
|
||||||
|
'</div>',
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
$supportBody.innerHTML = [
|
||||||
|
'<div class="support-section">',
|
||||||
|
'<div class="support-icon-big">\ud83d\udcbe</div>',
|
||||||
|
'<h3 class="support-heading">Manual Backup</h3>',
|
||||||
|
|
||||||
|
introHtml,
|
||||||
|
|
||||||
'<div class="support-steps">',
|
'<div class="support-steps">',
|
||||||
'<div class="support-steps-title">Requirements</div>',
|
'<div class="support-steps-title">Requirements</div>',
|
||||||
@@ -500,20 +595,11 @@ function renderBackupReady(drives) {
|
|||||||
'<div class="support-steps">',
|
'<div class="support-steps">',
|
||||||
'<div class="support-steps-title">What gets backed up</div>',
|
'<div class="support-steps-title">What gets backed up</div>',
|
||||||
'<ol class="support-backup-steps">',
|
'<ol class="support-backup-steps">',
|
||||||
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
backupItemsHtml,
|
||||||
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
|
|
||||||
'<li>System service data (<code>/var/lib</code>) — excluding databases and blockchain data (see note below)</li>',
|
|
||||||
'<li>Home directory (<code>/home</code>)</li>',
|
|
||||||
'</ol>',
|
'</ol>',
|
||||||
'</div>',
|
'</div>',
|
||||||
|
|
||||||
'<div class="support-wallet-box support-wallet-warning">',
|
dbNoteHtml,
|
||||||
'<div class="support-wallet-header">',
|
|
||||||
'<span class="support-wallet-icon">\u2139\ufe0f</span>',
|
|
||||||
'<span class="support-wallet-title">Database and Blockchain Data</span>',
|
|
||||||
'</div>',
|
|
||||||
'<p class="support-wallet-desc">Application databases stored in PostgreSQL or MariaDB/MySQL are <strong>not included</strong> in Manual Backup. Bitcoin blockchain and Electrs index data are also excluded (they are stored on the internal second drive). If you use Nextcloud, Matrix, or other database-backed applications, back up those databases separately with their native tools.</p>',
|
|
||||||
'</div>',
|
|
||||||
|
|
||||||
'<div class="support-wallet-box support-wallet-protected">',
|
'<div class="support-wallet-box support-wallet-protected">',
|
||||||
'<div class="support-wallet-header">',
|
'<div class="support-wallet-header">',
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ function _doOpenUpdateModal() {
|
|||||||
_updateLogOffset = 0;
|
_updateLogOffset = 0;
|
||||||
_serverWasDown = false;
|
_serverWasDown = false;
|
||||||
_updateFinished = false;
|
_updateFinished = 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");
|
||||||
@@ -94,6 +95,7 @@ async function pollUpdateStatus() {
|
|||||||
if (_updateFinished) return;
|
if (_updateFinished) return;
|
||||||
try {
|
try {
|
||||||
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
|
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
|
||||||
|
_updatePollFailures = 0;
|
||||||
if (_serverWasDown) {
|
if (_serverWasDown) {
|
||||||
_serverWasDown = false;
|
_serverWasDown = false;
|
||||||
if (!data.running) {
|
if (!data.running) {
|
||||||
@@ -143,6 +145,16 @@ async function pollUpdateStatus() {
|
|||||||
onUpdateDone(false);
|
onUpdateDone(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
_updatePollFailures += 1;
|
||||||
|
// Same guard as the rebuild modal: if polling stays broken long past a
|
||||||
|
// normal Hub restart, reload to re-authenticate and show the real state
|
||||||
|
// instead of spinning forever.
|
||||||
|
if (_updatePollFailures >= STATUS_POLL_MAX_FAILURES) {
|
||||||
|
_updateFinished = true;
|
||||||
|
stopUpdatePoll();
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
|
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,38 +357,24 @@ async function loadStep3() {
|
|||||||
if (relevantDomains.length === 0) {
|
if (relevantDomains.length === 0) {
|
||||||
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
|
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
|
||||||
} else {
|
} else {
|
||||||
|
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
|
||||||
|
// identical wording to the feature-enable domain modal shown for NWC,
|
||||||
|
// BTCPay Server, and every other domain-based feature, in every role.
|
||||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||||
+ '<p style="margin:0 0 8px;"><strong>Sovran_SystemsOS uses Njal.la for domains and Dynamic DNS.</strong></p>'
|
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
|
||||||
+ '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">'
|
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
|
||||||
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a>.</li>'
|
|
||||||
+ '<li>Buy at least one domain. Each service below needs its own domain — you can either give each service its own subdomain of a single domain you buy (subdomains are free, and one domain can have many), OR use a separate domain for each. Your choice.</li>'
|
|
||||||
+ '<li>For each service, add a <strong>Dynamic</strong> record in Njal.la:'
|
|
||||||
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
|
|
||||||
+ '<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>'
|
|
||||||
+ '(Example only, your choice — for "call.yourdomain.com" you'd type just: <code>call</code>.)<br>'
|
|
||||||
+ 'If you bought a whole separate domain just for this service, leave Name blank or use <code>@</code>.<br>'
|
|
||||||
+ '⚠ Do NOT type the full domain in the Name field — Njal.la adds it automatically.</li>'
|
|
||||||
+ '<li>A Dynamic record has NO IP field. You don't enter an IP anywhere — it auto-fills once Sovran_SystemsOS updates it (on save, and again after reboot).</li>'
|
|
||||||
+ '</ul>'
|
|
||||||
+ '</li>'
|
|
||||||
+ '<li>Njal.la gives you a curl command like:<br>'
|
|
||||||
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=call.yourdomain.com&k=abc123&auto"</code></li>'
|
|
||||||
+ '</ol>'
|
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
|
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
|
||||||
|
|
||||||
// Compact router note (full port guidance is shown when a feature is
|
// Router note (the same wording is shown again, per-service, whenever a
|
||||||
// enabled, and lives on each service tile afterwards)
|
// domain-based feature is enabled, and lives on each service tile afterwards)
|
||||||
var routerIpPart = internalIp
|
|
||||||
? ' to this computer’s internal IP <strong>' + escHtml(internalIp) + '</strong>'
|
|
||||||
: ' to this computer’s internal IP';
|
|
||||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||||
+ '🔌 <strong>One router task:</strong> in your router’s <strong>port forwarding</strong> settings, forward '
|
+ renderRouterPortsHtml({
|
||||||
+ 'port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>'
|
internalIp: internalIp,
|
||||||
+ routerIpPart + '. Use the <strong>same number for the internal and external port</strong>. '
|
plural: true,
|
||||||
+ 'These are required for HTTPS and SSL certificates — without them your services cannot be reached from outside your home network. '
|
includeSsh: true,
|
||||||
+ 'Add port <strong>22 (TCP)</strong> as well if you want remote SSH access. '
|
extraNote: "Element Call needs a few extra ports (some UDP), and you’ll be shown exactly which when you enable it.",
|
||||||
+ '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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,13 @@
|
|||||||
<!-- Header bar -->
|
<!-- Header bar -->
|
||||||
<header class="header-bar">
|
<header class="header-bar">
|
||||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
|
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
|
||||||
|
|
||||||
|
<div class="title-group">
|
||||||
<span class="title">Sovran_SystemsOS Hub</span>
|
<span class="title">Sovran_SystemsOS Hub</span>
|
||||||
|
|
||||||
<!-- OS Version Badge -->
|
<!-- OS Version Badge — styled identically to the version badge in service modals -->
|
||||||
<span class="os-version-badge" id="os-version-badge" title="Sovran_SystemsOS Version">
|
<span class="os-version-badge" id="os-version-badge" title="Sovran_SystemsOS Version">v{{ sovran_version }}</span>
|
||||||
<span class="version-label">v</span>
|
</div>
|
||||||
<span class="version-number" id="version-number">{{ sovran_version }}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
<span class="role-badge" id="role-badge">Loading…</span>
|
<span class="role-badge" id="role-badge">Loading…</span>
|
||||||
@@ -55,7 +55,12 @@
|
|||||||
<div id="sidebar-support"></div>
|
<div id="sidebar-support"></div>
|
||||||
<div id="sidebar-features"></div>
|
<div id="sidebar-features"></div>
|
||||||
</aside>
|
</aside>
|
||||||
<div id="tiles-area"></div>
|
<div id="tiles-area">
|
||||||
|
<div class="dashboard-loading" role="status" aria-live="polite">
|
||||||
|
<span class="dashboard-loading-spinner" aria-hidden="true"></span>
|
||||||
|
<span>Loading service status…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Update modal -->
|
<!-- Update modal -->
|
||||||
@@ -309,6 +314,7 @@
|
|||||||
<script src="/static/js/constants.js?v={{ asset_version }}"></script>
|
<script src="/static/js/constants.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/js/state.js?v={{ asset_version }}"></script>
|
<script src="/static/js/state.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/js/helpers.js?v={{ asset_version }}"></script>
|
<script src="/static/js/helpers.js?v={{ asset_version }}"></script>
|
||||||
|
<script src="/static/js/domain-prereqs.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/js/tiles.js?v={{ asset_version }}"></script>
|
<script src="/static/js/tiles.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/js/service-detail.js?v={{ asset_version }}"></script>
|
<script src="/static/js/service-detail.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/js/support.js?v={{ asset_version }}"></script>
|
<script src="/static/js/support.js?v={{ asset_version }}"></script>
|
||||||
|
|||||||
@@ -125,9 +125,8 @@
|
|||||||
<span class="onboarding-step-icon">🌐</span>
|
<span class="onboarding-step-icon">🌐</span>
|
||||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
||||||
<p class="onboarding-step-desc">
|
<p class="onboarding-step-desc">
|
||||||
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS.
|
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS, and your router needs ports <strong>80</strong> and <strong>443</strong> (TCP) forwarded to this computer.
|
||||||
Create an account at Njal.la, then for each service below, add a <strong>Dynamic</strong> record — no IP needed, it auto-populates once the DDNS curl command runs.
|
Everything you need — Njal.la account, Dynamic records, and the one router task — is laid out step by step below.
|
||||||
Paste the curl command from your Njal.la dashboard for each service.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-card" id="step-3-body">
|
<div class="onboarding-card" id="step-3-body">
|
||||||
@@ -170,6 +169,7 @@
|
|||||||
</div><!-- /panel-wrap -->
|
</div><!-- /panel-wrap -->
|
||||||
</div><!-- /shell -->
|
</div><!-- /shell -->
|
||||||
|
|
||||||
|
<script src="/static/js/domain-prereqs.js?v={{ asset_version }}"></script>
|
||||||
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
|
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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.8",
|
||||||
"btcpayserver.service": "2.0.0",
|
"btcpayserver.service": "2.4.2",
|
||||||
"albyhub.service": "1.8.0",
|
"albyhub.service": "1.8.0",
|
||||||
"mempool.service": "3.0.0",
|
"mempool.service": "3.2.1",
|
||||||
"matrix-synapse.service": "1.115.0",
|
"matrix-synapse.service": "1.115.0",
|
||||||
"livekit.service": "1.5.2",
|
"livekit.service": "1.5.2",
|
||||||
"vaultwarden.service": "1.32.0",
|
"vaultwarden.service": "1.32.0",
|
||||||
|
|||||||
+4
-7
@@ -145,7 +145,7 @@
|
|||||||
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
|
||||||
@@ -192,12 +192,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
+19
-150
@@ -5,11 +5,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784932759,
|
"lastModified": 1785778881,
|
||||||
"narHash": "sha256-44/iCx+wiYukHGhvPm65ppJZ3FZZbp6f9JE5isz8TsA=",
|
"narHash": "sha256-yHJJTm3o7ZhiVp68G+hh+dz1GKV+ACOT6mQj+eYvJLQ=",
|
||||||
"owner": "emmanuelrosa",
|
"owner": "emmanuelrosa",
|
||||||
"repo": "btc-clients-nix",
|
"repo": "btc-clients-nix",
|
||||||
"rev": "8aab86c245ab9a2bea0d72175d6fd663a892af9f",
|
"rev": "fc1aca94d839f82e7501fe0d01279d5c1fba6e63",
|
||||||
"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": [
|
||||||
@@ -65,54 +39,13 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nix-bitcoin": {
|
|
||||||
"inputs": {
|
|
||||||
"extra-container": "extra-container",
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs_2",
|
|
||||||
"nixpkgs-25_05": "nixpkgs-25_05",
|
|
||||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1779253922,
|
|
||||||
"narHash": "sha256-k5DpYVfyy27ELuEiV+51EfVg7B6vKUW63NWeA6eKGd0=",
|
|
||||||
"owner": "fort-nix",
|
|
||||||
"repo": "nix-bitcoin",
|
|
||||||
"rev": "1496f842477976c085cd96f1837ea12444014088",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "fort-nix",
|
|
||||||
"ref": "release",
|
|
||||||
"repo": "nix-bitcoin",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782911660,
|
"lastModified": 1785590095,
|
||||||
"narHash": "sha256-PbR+tJ5E/Ux+01UtdFKqblccVA4/FgWbkym4ev3VHHQ=",
|
"narHash": "sha256-CNO2szJbdLjVN/Hi1BML9MSALz1GM2fIdwnzs404QO8=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "cf720c15e108d432d29041cc5a185630809acefb",
|
"rev": "e568f3b19d54b08f48bfae9b12b3e124d1a28002",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -121,29 +54,13 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs-25_05": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1767313136,
|
|
||||||
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-25.05",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784856561,
|
"lastModified": 1786201459,
|
||||||
"narHash": "sha256-J+Bx1Z6Oeoj2FgnBhRMKyUhhtDoOpTgXYaVLZpDjW4A=",
|
"narHash": "sha256-CiOTEjmwAmG2AWnaIno9YaCJJmpca2FXPhMAsnrolCg=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "597283ad8aa0b331c788e97c4c262d58877074ef",
|
"rev": "8b8c811c7c2541c30382c5de7ed26be055569c60",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -153,45 +70,13 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs-unstable": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1778869304,
|
|
||||||
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixpkgs-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1778737229,
|
"lastModified": 1786106723,
|
||||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-25.11",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs_3": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1784796856,
|
|
||||||
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
|
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -201,7 +86,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_4": {
|
"nixpkgs_3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784555310,
|
"lastModified": 1784555310,
|
||||||
"narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=",
|
"narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=",
|
||||||
@@ -220,15 +105,15 @@
|
|||||||
"nixvim": {
|
"nixvim": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
"nixpkgs": "nixpkgs_4",
|
"nixpkgs": "nixpkgs_3",
|
||||||
"systems": "systems_2"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784814601,
|
"lastModified": 1785763201,
|
||||||
"narHash": "sha256-T32JXjZ7kIbhBn8/Har171yGg6IBdl97cxAWameqZDE=",
|
"narHash": "sha256-wA373y/B9orM3HatLu9oS+Ke5lmdZyBl/bdjd2gLMq4=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "f316e949e0ed9df0e1e0bf645c6dce721d4e230e",
|
"rev": "c7be49306b23a952c0151cf4bbeacd944ed82f2a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -240,28 +125,12 @@
|
|||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"btc-clients": "btc-clients",
|
"btc-clients": "btc-clients",
|
||||||
"nix-bitcoin": "nix-bitcoin",
|
"nixpkgs": "nixpkgs_2",
|
||||||
"nixpkgs": "nixpkgs_3",
|
|
||||||
"nixpkgs-stable": "nixpkgs-stable",
|
"nixpkgs-stable": "nixpkgs-stable",
|
||||||
"nixvim": "nixvim"
|
"nixvim": "nixvim"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"systems": {
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems_2": {
|
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774449309,
|
"lastModified": 1774449309,
|
||||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
|
|
||||||
nixvim.url = "github:nix-community/nixvim";
|
nixvim.url = "github:nix-community/nixvim";
|
||||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }:
|
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
overlay-stable = final: prev: {
|
overlay-stable = final: prev: {
|
||||||
@@ -22,7 +21,7 @@
|
|||||||
{
|
{
|
||||||
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
|
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
|
||||||
modules = [
|
modules = [
|
||||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||||
self.nixosModules.Sovran_SystemsOS
|
self.nixosModules.Sovran_SystemsOS
|
||||||
./hardware-configuration.nix
|
./hardware-configuration.nix
|
||||||
./role-state.nix
|
./role-state.nix
|
||||||
@@ -32,10 +31,9 @@
|
|||||||
|
|
||||||
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
|
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
|
||||||
modules = [
|
modules = [
|
||||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||||
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
|
||||||
./iso/common.nix
|
./iso/common.nix
|
||||||
nix-bitcoin.nixosModules.default
|
./modules/bitcoin
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -46,7 +44,7 @@
|
|||||||
nixpkgs.overlays = [ overlay-stable ];
|
nixpkgs.overlays = [ overlay-stable ];
|
||||||
})
|
})
|
||||||
./configuration.nix
|
./configuration.nix
|
||||||
nix-bitcoin.nixosModules.default
|
./modules/bitcoin
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
config = {
|
config = {
|
||||||
@@ -58,13 +56,21 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
nixosTests.nwc-wallets-port-collision =
|
checks.x86_64-linux = let
|
||||||
import ./nix/tests/nwc-wallets-port-collision.nix {
|
pkgs = import nixpkgs {
|
||||||
inherit nixpkgs;
|
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
};
|
};
|
||||||
|
fetchNodeModules =
|
||||||
checks.x86_64-linux.nwc-wallets-port-collision =
|
pkgs.callPackage ./packages/build-support/fetch-node-modules.nix {};
|
||||||
self.nixosTests.nwc-wallets-port-collision;
|
mempoolPkgs =
|
||||||
|
pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; };
|
||||||
|
in {
|
||||||
|
bitcoin-btcpay-hardening = import ./tests/bitcoin-btcpay-hardening.nix {
|
||||||
|
inherit nixpkgs overlay-stable;
|
||||||
|
};
|
||||||
|
mempool-backend = mempoolPkgs.mempool-backend;
|
||||||
|
mempool-frontend = mempoolPkgs.mempool-frontend;
|
||||||
|
rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ let
|
|||||||
else "dev";
|
else "dev";
|
||||||
|
|
||||||
# Clean version (remove 'v' prefix and newlines)
|
# Clean version (remove 'v' prefix and newlines)
|
||||||
cleanVersion = builtins.replaceStrings ["v" "\n" "\r"] [""] versionFile;
|
cleanVersion = builtins.replaceStrings ["v" "\n" "\r"] ["" "" ""] versionFile;
|
||||||
|
|
||||||
pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ps.pycairo ]);
|
pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ps.pycairo ]);
|
||||||
|
|
||||||
|
|||||||
+186
-23
@@ -103,25 +103,81 @@ def human_size(nbytes):
|
|||||||
return f"{nbytes:.1f} PB"
|
return f"{nbytes:.1f} PB"
|
||||||
|
|
||||||
def check_internet():
|
def check_internet():
|
||||||
"""Return True if the machine can reach the internet."""
|
"""Return True if the machine can reach the internet.
|
||||||
|
|
||||||
|
Some VM NAT setups and managed networks block ICMP even when HTTPS works,
|
||||||
|
so keep the existing ping checks but fall back to small HTTPS requests.
|
||||||
|
"""
|
||||||
|
checks = [
|
||||||
|
["ping", "-c", "1", "-W", "5", "nixos.org"],
|
||||||
|
["curl", "--location", "--fail", "--silent", "--show-error", "--connect-timeout", "5", "--max-time", "10", "--output", "/dev/null", "https://cache.nixos.org/nix-cache-info"],
|
||||||
|
["curl", "--location", "--fail", "--silent", "--show-error", "--connect-timeout", "5", "--max-time", "10", "--output", "/dev/null", "https://nixos.org/"],
|
||||||
|
["ping", "-c", "1", "-W", "5", "1.1.1.1"],
|
||||||
|
]
|
||||||
|
|
||||||
|
for cmd in checks:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True
|
||||||
|
log(f"Connectivity check failed ({' '.join(cmd)}): {result.stderr.strip() or result.stdout.strip()}")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Connectivity check failed ({' '.join(cmd)}): {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def detect_virtualization():
|
||||||
|
"""Return the VM technology name when running inside a VM, else None."""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["ping", "-c", "1", "-W", "5", "nixos.org"],
|
["systemd-detect-virt", "--vm"],
|
||||||
capture_output=True, text=True
|
capture_output=True, text=True
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
return True
|
name = result.stdout.strip()
|
||||||
|
if name == "none":
|
||||||
|
return None
|
||||||
|
return name or "virtual machine"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Fallback: try a second host in case DNS for nixos.org is down
|
|
||||||
try:
|
# Fallback for minimal environments where systemd-detect-virt is absent.
|
||||||
result = subprocess.run(
|
dmi_paths = [
|
||||||
["ping", "-c", "1", "-W", "5", "1.1.1.1"],
|
"/sys/class/dmi/id/product_name",
|
||||||
capture_output=True, text=True
|
"/sys/class/dmi/id/sys_vendor",
|
||||||
|
"/sys/class/dmi/id/board_vendor",
|
||||||
|
]
|
||||||
|
markers = (
|
||||||
|
"kvm", "qemu", "virtualbox", "vmware", "hyper-v",
|
||||||
|
"bhyve", "parallels", "xen", "bochs", "virtual",
|
||||||
)
|
)
|
||||||
return result.returncode == 0
|
for dmi_path in dmi_paths:
|
||||||
except Exception:
|
try:
|
||||||
return False
|
value = open(dmi_path, "r", encoding="utf-8", errors="ignore").read().strip()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
lowered = value.lower()
|
||||||
|
if any(marker in lowered for marker in markers):
|
||||||
|
return value or "virtual machine"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def detect_boot_mode():
|
||||||
|
"""Return the firmware mode used to boot the installer ISO."""
|
||||||
|
return "uefi" if os.path.isdir("/sys/firmware/efi") else "bios"
|
||||||
|
|
||||||
|
def boot_mode_label(mode):
|
||||||
|
if mode == "uefi":
|
||||||
|
return "UEFI (systemd-boot)"
|
||||||
|
return "Legacy BIOS (GRUB)"
|
||||||
|
|
||||||
|
def nix_string(value):
|
||||||
|
"""Quote a Python string for a simple Nix string literal."""
|
||||||
|
escaped = value.replace("\\", "\\\\")
|
||||||
|
escaped = escaped.replace('"', '\\"')
|
||||||
|
escaped = escaped.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
||||||
|
escaped = escaped.replace("${", "\\${")
|
||||||
|
return f'"{escaped}"'
|
||||||
|
|
||||||
def symbolic_icon(name):
|
def symbolic_icon(name):
|
||||||
"""Create a crisp symbolic icon suitable for use as an ActionRow prefix."""
|
"""Create a crisp symbolic icon suitable for use as an ActionRow prefix."""
|
||||||
@@ -159,6 +215,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
self.data_size = None
|
self.data_size = None
|
||||||
self.data_drive_has_timechain = False
|
self.data_drive_has_timechain = False
|
||||||
self.free_password = None
|
self.free_password = None
|
||||||
|
self.virtualization = detect_virtualization()
|
||||||
|
self.boot_mode = detect_boot_mode()
|
||||||
|
|
||||||
|
log(f"Installer environment: boot_mode={self.boot_mode}, virtualization={self.virtualization or 'none'}")
|
||||||
|
|
||||||
# Root navigation view
|
# Root navigation view
|
||||||
self.nav = Adw.NavigationView()
|
self.nav = Adw.NavigationView()
|
||||||
@@ -343,7 +403,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
if os.path.exists(LOGO):
|
if os.path.exists(LOGO):
|
||||||
try:
|
try:
|
||||||
img = Gtk.Image.new_from_file(LOGO)
|
img = Gtk.Image.new_from_file(LOGO)
|
||||||
img.set_pixel_size(480)
|
img.set_pixel_size(320 if self.virtualization else 480)
|
||||||
hero.append(img)
|
hero.append(img)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -397,6 +457,42 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
notice_frame.set_child(notice_box)
|
notice_frame.set_child(notice_box)
|
||||||
outer.append(notice_frame)
|
outer.append(notice_frame)
|
||||||
|
|
||||||
|
if self.virtualization:
|
||||||
|
vm_frame = Gtk.Frame()
|
||||||
|
vm_frame.add_css_class("card")
|
||||||
|
vm_frame.set_margin_start(40)
|
||||||
|
vm_frame.set_margin_end(40)
|
||||||
|
vm_frame.set_margin_top(12)
|
||||||
|
vm_frame.set_margin_bottom(4)
|
||||||
|
|
||||||
|
vm_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||||
|
vm_box.set_margin_top(12)
|
||||||
|
vm_box.set_margin_bottom(12)
|
||||||
|
vm_box.set_margin_start(16)
|
||||||
|
vm_box.set_margin_end(16)
|
||||||
|
|
||||||
|
vm_icon = symbolic_icon("computer-symbolic")
|
||||||
|
vm_icon.set_valign(Gtk.Align.START)
|
||||||
|
vm_box.append(vm_icon)
|
||||||
|
|
||||||
|
vm_lbl = Gtk.Label()
|
||||||
|
vm_lbl.set_use_markup(True)
|
||||||
|
vm_lbl.set_wrap(True)
|
||||||
|
vm_lbl.set_xalign(0)
|
||||||
|
vm_lbl.set_halign(Gtk.Align.FILL)
|
||||||
|
vm_lbl.set_markup(
|
||||||
|
f"<span weight='bold'>Virtual machine detected: {GLib.markup_escape_text(self.virtualization)}</span>\n"
|
||||||
|
f"Boot mode: <span weight='bold'>{boot_mode_label(self.boot_mode)}</span>. "
|
||||||
|
"For the smoothest VM test, use 8+ GB RAM, NAT/bridged networking, "
|
||||||
|
"and a thin-provisioned virtual OS disk of at least 256 GB. "
|
||||||
|
"For Node or Server + Desktop, attach a second 2 TB virtual data disk; "
|
||||||
|
"otherwise choose Desktop Only."
|
||||||
|
)
|
||||||
|
vm_box.append(vm_lbl)
|
||||||
|
|
||||||
|
vm_frame.set_child(vm_box)
|
||||||
|
outer.append(vm_frame)
|
||||||
|
|
||||||
# Role label
|
# Role label
|
||||||
role_lbl = Gtk.Label()
|
role_lbl = Gtk.Label()
|
||||||
role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>")
|
role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>")
|
||||||
@@ -448,7 +544,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
available = has_second_drive or key not in NEEDS_DATA_DRIVE
|
available = has_second_drive or key not in NEEDS_DATA_DRIVE
|
||||||
if not available:
|
if not available:
|
||||||
card.set_subtitle(desc + "\n⚠ Requires a second internal drive (not detected)")
|
drive_hint = "⚠ Requires a second internal drive (not detected)"
|
||||||
|
if self.virtualization:
|
||||||
|
drive_hint += " — in a VM, attach a second virtual disk or choose Desktop Only"
|
||||||
|
card.set_subtitle(desc + f"\n{drive_hint}")
|
||||||
card.set_sensitive(False)
|
card.set_sensitive(False)
|
||||||
else:
|
else:
|
||||||
card.set_subtitle(desc)
|
card.set_subtitle(desc)
|
||||||
@@ -531,9 +630,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
# ── OS Drive group ────────────────────────────────────────────
|
# ── OS Drive group ────────────────────────────────────────────
|
||||||
os_group = Adw.PreferencesGroup()
|
os_group = Adw.PreferencesGroup()
|
||||||
os_group.set_title("OS Drive (NixOS Boot + Root)")
|
os_group.set_title("OS Drive (NixOS Boot + Root)")
|
||||||
os_group.set_description(
|
os_description = "Choose the drive for the NixOS installation. Minimum 256 GB required."
|
||||||
"Choose the drive for the NixOS installation. Minimum 256 GB required."
|
if self.virtualization:
|
||||||
)
|
os_description += " In a VM, a thin-provisioned virtual disk is OK."
|
||||||
|
os_group.set_description(os_description)
|
||||||
os_group.set_margin_top(24)
|
os_group.set_margin_top(24)
|
||||||
os_group.set_margin_start(40)
|
os_group.set_margin_start(40)
|
||||||
os_group.set_margin_end(40)
|
os_group.set_margin_end(40)
|
||||||
@@ -546,6 +646,8 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
row.set_title(f"/dev/{name}")
|
row.set_title(f"/dev/{name}")
|
||||||
type_label = tran.upper() if tran else "Disk"
|
type_label = tran.upper() if tran else "Disk"
|
||||||
meets = "✓ Meets 256 GB minimum" if size >= BYTES_256GB else "✗ Below 256 GB minimum"
|
meets = "✓ Meets 256 GB minimum" if size >= BYTES_256GB else "✗ Below 256 GB minimum"
|
||||||
|
if self.virtualization and size < BYTES_256GB:
|
||||||
|
meets += " (expand the virtual disk)"
|
||||||
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
||||||
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||||
|
|
||||||
@@ -570,11 +672,14 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
if self.role != "Desktop Only":
|
if self.role != "Desktop Only":
|
||||||
data_group = Adw.PreferencesGroup()
|
data_group = Adw.PreferencesGroup()
|
||||||
data_group.set_title("Bitcoin Timechain & Backups Drive")
|
data_group.set_title("Bitcoin Timechain & Backups Drive")
|
||||||
data_group.set_description(
|
data_description = (
|
||||||
"💡 Tip: Always assign your LARGEST drive here. "
|
"💡 Tip: Always assign your LARGEST drive here. "
|
||||||
"The full Bitcoin timechain is over 700 GB and grows continuously — "
|
"The full Bitcoin timechain is over 700 GB and grows continuously — "
|
||||||
"a 2 TB or larger drive is required."
|
"a 2 TB or larger drive is required."
|
||||||
)
|
)
|
||||||
|
if self.virtualization:
|
||||||
|
data_description += " In a VM, attach a second 2 TB thin-provisioned virtual disk for Node or Server + Desktop."
|
||||||
|
data_group.set_description(data_description)
|
||||||
data_group.set_margin_top(20)
|
data_group.set_margin_top(20)
|
||||||
data_group.set_margin_start(40)
|
data_group.set_margin_start(40)
|
||||||
data_group.set_margin_end(40)
|
data_group.set_margin_end(40)
|
||||||
@@ -599,6 +704,8 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
row.set_title(f"/dev/{name}")
|
row.set_title(f"/dev/{name}")
|
||||||
type_label = tran.upper() if tran else "Disk"
|
type_label = tran.upper() if tran else "Disk"
|
||||||
meets = "✓ Meets 2 TB minimum" if size >= BYTES_2TB else "✗ Below 2 TB minimum"
|
meets = "✓ Meets 2 TB minimum" if size >= BYTES_2TB else "✗ Below 2 TB minimum"
|
||||||
|
if self.virtualization and size < BYTES_2TB:
|
||||||
|
meets += " (use a 2 TB thin-provisioned virtual disk)"
|
||||||
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
||||||
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||||
|
|
||||||
@@ -655,10 +762,16 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
dlg = Adw.MessageDialog()
|
dlg = Adw.MessageDialog()
|
||||||
dlg.set_transient_for(self)
|
dlg.set_transient_for(self)
|
||||||
dlg.set_heading("OS Drive Too Small")
|
dlg.set_heading("OS Drive Too Small")
|
||||||
dlg.set_body(
|
body = (
|
||||||
f"The selected OS drive (/dev/{os_name}, {human_size(os_size)}) "
|
f"The selected OS drive (/dev/{os_name}, {human_size(os_size)}) "
|
||||||
f"does not meet the 256 GB minimum. Please choose a larger drive."
|
f"does not meet the 256 GB minimum. Please choose a larger drive."
|
||||||
)
|
)
|
||||||
|
if self.virtualization:
|
||||||
|
body += (
|
||||||
|
"\n\nVM tip: expand the virtual OS disk to at least 256 GB. "
|
||||||
|
"Thin-provisioned disks usually do not consume the full size immediately."
|
||||||
|
)
|
||||||
|
dlg.set_body(body)
|
||||||
dlg.add_response("ok", "OK")
|
dlg.add_response("ok", "OK")
|
||||||
dlg.present()
|
dlg.present()
|
||||||
return
|
return
|
||||||
@@ -668,12 +781,18 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
dlg = Adw.MessageDialog()
|
dlg = Adw.MessageDialog()
|
||||||
dlg.set_transient_for(self)
|
dlg.set_transient_for(self)
|
||||||
dlg.set_heading("Bitcoin Drive Too Small")
|
dlg.set_heading("Bitcoin Drive Too Small")
|
||||||
dlg.set_body(
|
body = (
|
||||||
f"The selected Bitcoin Timechain & Backups drive "
|
f"The selected Bitcoin Timechain & Backups drive "
|
||||||
f"(/dev/{data_name}, {human_size(data_size)}) "
|
f"(/dev/{data_name}, {human_size(data_size)}) "
|
||||||
f"does not meet the 2 TB minimum. "
|
f"does not meet the 2 TB minimum. "
|
||||||
f"Please choose a larger drive or select \"None\"."
|
f"Please choose a larger drive or select \"None\"."
|
||||||
)
|
)
|
||||||
|
if self.virtualization:
|
||||||
|
body += (
|
||||||
|
"\n\nVM tip: attach a second 2 TB thin-provisioned virtual disk "
|
||||||
|
"for Node or Server + Desktop test installs."
|
||||||
|
)
|
||||||
|
dlg.set_body(body)
|
||||||
dlg.add_response("ok", "OK")
|
dlg.add_response("ok", "OK")
|
||||||
dlg.present()
|
dlg.present()
|
||||||
return
|
return
|
||||||
@@ -829,7 +948,12 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
# ── Worker: partition ─────────────────────────────────────────────────
|
# ── Worker: partition ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def partition_path(self, dev_path, num):
|
def partition_path(self, dev_path, num):
|
||||||
return f"{dev_path}p{num}" if "nvme" in dev_path else f"{dev_path}{num}"
|
base = os.path.basename(dev_path)
|
||||||
|
needs_p = (
|
||||||
|
base.startswith(("nvme", "mmcblk", "loop", "md")) or
|
||||||
|
(base[-1:].isdigit())
|
||||||
|
)
|
||||||
|
return f"{dev_path}p{num}" if needs_p else f"{dev_path}{num}"
|
||||||
|
|
||||||
def detect_existing_timechain_data(self, data_path, buf=None):
|
def detect_existing_timechain_data(self, data_path, buf=None):
|
||||||
data_p1 = self.partition_path(data_path, 1)
|
data_p1 = self.partition_path(data_path, 1)
|
||||||
@@ -900,12 +1024,19 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
# ── Partition boot disk: 512M ESP + rest as root ──
|
# ── Partition boot disk ──
|
||||||
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk ===\n")
|
if self.boot_mode == "uefi":
|
||||||
|
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (UEFI) ===\n")
|
||||||
run_stream(["sudo", "sgdisk",
|
run_stream(["sudo", "sgdisk",
|
||||||
"-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP",
|
"-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP",
|
||||||
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
|
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
|
||||||
boot_path], buf)
|
boot_path], buf)
|
||||||
|
else:
|
||||||
|
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (Legacy BIOS) ===\n")
|
||||||
|
run_stream(["sudo", "sgdisk",
|
||||||
|
"-n", "1:1M:+1M", "-t", "1:EF02", "-c", "1:BIOS-boot",
|
||||||
|
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
|
||||||
|
boot_path], buf)
|
||||||
|
|
||||||
run_stream(["sudo", "partprobe", boot_path], buf)
|
run_stream(["sudo", "partprobe", boot_path], buf)
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
@@ -925,7 +1056,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
boot_p1 = self.partition_path(boot_path, 1)
|
boot_p1 = self.partition_path(boot_path, 1)
|
||||||
boot_p2 = self.partition_path(boot_path, 2)
|
boot_p2 = self.partition_path(boot_path, 2)
|
||||||
|
|
||||||
|
if self.boot_mode == "uefi":
|
||||||
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
|
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
|
||||||
|
else:
|
||||||
|
GLib.idle_add(append_text, buf, "Skipping ESP format for Legacy BIOS install.\n")
|
||||||
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
|
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
|
||||||
|
|
||||||
if data_path and not self.data_drive_has_timechain:
|
if data_path and not self.data_drive_has_timechain:
|
||||||
@@ -935,8 +1069,11 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
# ── Mount filesystems ──
|
# ── Mount filesystems ──
|
||||||
GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n")
|
GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n")
|
||||||
run_stream(["sudo", "mount", boot_p2, "/mnt"], buf)
|
run_stream(["sudo", "mount", boot_p2, "/mnt"], buf)
|
||||||
|
if self.boot_mode == "uefi":
|
||||||
run_stream(["sudo", "mkdir", "-p", "/mnt/boot/efi"], buf)
|
run_stream(["sudo", "mkdir", "-p", "/mnt/boot/efi"], buf)
|
||||||
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
|
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
|
||||||
|
else:
|
||||||
|
GLib.idle_add(append_text, buf, "Legacy BIOS install: /boot/efi mount not required.\n")
|
||||||
|
|
||||||
if data_path:
|
if data_path:
|
||||||
data_p1 = self.partition_path(data_path, 1)
|
data_p1 = self.partition_path(data_path, 1)
|
||||||
@@ -966,12 +1103,33 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
is_server = str(self.role == "Server+Desktop").lower()
|
is_server = str(self.role == "Server+Desktop").lower()
|
||||||
is_desktop = str(self.role == "Desktop Only").lower()
|
is_desktop = str(self.role == "Desktop Only").lower()
|
||||||
is_node = str(self.role == "Node (Bitcoin-only)").lower()
|
is_node = str(self.role == "Node (Bitcoin-only)").lower()
|
||||||
|
boot_overrides = ""
|
||||||
|
if self.boot_mode == "bios":
|
||||||
|
grub_device = nix_string(f"/dev/{self.boot_disk}")
|
||||||
|
boot_overrides = f"""
|
||||||
|
|
||||||
|
# The installer ISO was booted in Legacy BIOS mode, so install GRUB to the
|
||||||
|
# target disk instead of using the default UEFI systemd-boot configuration.
|
||||||
|
boot.loader.systemd-boot.enable = lib.mkForce false;
|
||||||
|
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||||
|
boot.loader.grub.enable = lib.mkForce true;
|
||||||
|
boot.loader.grub.device = lib.mkForce {grub_device};
|
||||||
|
fileSystems."/boot/efi".enable = lib.mkForce false;
|
||||||
|
"""
|
||||||
|
elif self.virtualization:
|
||||||
|
boot_overrides = """
|
||||||
|
|
||||||
|
# VM UEFI firmware can reject or forget NVRAM boot-entry writes. Keep the
|
||||||
|
# normal UEFI systemd-boot layout, but use the fallback removable path instead
|
||||||
|
# of depending on EFI variable updates.
|
||||||
|
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||||
|
"""
|
||||||
content = f"""# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
content = f"""# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
||||||
{{ config, lib, ... }}:
|
{{ config, lib, ... }}:
|
||||||
{{
|
{{
|
||||||
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault {is_server};
|
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault {is_server};
|
||||||
sovran_systemsOS.roles.desktop = lib.mkDefault {is_desktop};
|
sovran_systemsOS.roles.desktop = lib.mkDefault {is_desktop};
|
||||||
sovran_systemsOS.roles.node = lib.mkDefault {is_node};
|
sovran_systemsOS.roles.node = lib.mkDefault {is_node};{boot_overrides}
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
@@ -1008,6 +1166,11 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
boot_row.set_subtitle(f"/dev/{self.boot_disk} — {human_size(self.boot_size)}")
|
boot_row.set_subtitle(f"/dev/{self.boot_disk} — {human_size(self.boot_size)}")
|
||||||
details.add(boot_row)
|
details.add(boot_row)
|
||||||
|
|
||||||
|
boot_mode_row = Adw.ActionRow()
|
||||||
|
boot_mode_row.set_title("Boot Mode")
|
||||||
|
boot_mode_row.set_subtitle(boot_mode_label(self.boot_mode))
|
||||||
|
details.add(boot_mode_row)
|
||||||
|
|
||||||
if self.data_disk:
|
if self.data_disk:
|
||||||
data_row = Adw.ActionRow()
|
data_row = Adw.ActionRow()
|
||||||
data_row.set_title("Data Disk")
|
data_row.set_title("Data Disk")
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ Options:
|
|||||||
--deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install
|
--deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install
|
||||||
--headscale-server URL Headscale login server for post-install Tailnet
|
--headscale-server URL Headscale login server for post-install Tailnet
|
||||||
--headscale-key KEY Headscale pre-auth key for the installed OS
|
--headscale-key KEY Headscale pre-auth key for the installed OS
|
||||||
|
|
||||||
|
VM notes:
|
||||||
|
Desktop test installs still need a 256 GB OS disk (thin-provisioned is OK).
|
||||||
|
Node/server installs need a separate 2 TB data disk. UEFI is preferred; UEFI
|
||||||
|
VMs avoid NVRAM boot-entry writes, and legacy BIOS VMs use GRUB.
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +30,8 @@ DEPLOY_KEY=""
|
|||||||
HEADSCALE_SERVER=""
|
HEADSCALE_SERVER=""
|
||||||
HEADSCALE_KEY=""
|
HEADSCALE_KEY=""
|
||||||
DATA_DISK_HAS_TIMECHAIN=false
|
DATA_DISK_HAS_TIMECHAIN=false
|
||||||
|
BOOT_MODE=""
|
||||||
|
VIRTUALIZATION=""
|
||||||
|
|
||||||
FLAKE="/etc/sovran/flake"
|
FLAKE="/etc/sovran/flake"
|
||||||
LOG="/tmp/sovran-headless-install.log"
|
LOG="/tmp/sovran-headless-install.log"
|
||||||
@@ -69,6 +76,22 @@ case "$ROLE" in
|
|||||||
*) die "--role must be one of: server, desktop, node" ;;
|
*) die "--role must be one of: server, desktop, node" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# ── Detect firmware / VM environment ─────────────────────────────────────────
|
||||||
|
if [[ -d /sys/firmware/efi ]]; then
|
||||||
|
BOOT_MODE="uefi"
|
||||||
|
else
|
||||||
|
BOOT_MODE="bios"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v systemd-detect-virt >/dev/null 2>&1; then
|
||||||
|
if VIRT_RESULT=$(systemd-detect-virt --vm 2>/dev/null); then
|
||||||
|
[[ "$VIRT_RESULT" != "none" ]] && VIRTUALIZATION="$VIRT_RESULT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Boot mode: ${BOOT_MODE} ($([[ "$BOOT_MODE" == uefi ]] && echo systemd-boot || echo GRUB))"
|
||||||
|
[[ -n "$VIRTUALIZATION" ]] && log "Virtual machine detected: ${VIRTUALIZATION}"
|
||||||
|
|
||||||
# ── Validate disk existence and size ─────────────────────────────────────────
|
# ── Validate disk existence and size ─────────────────────────────────────────
|
||||||
log "=== Validating disks ==="
|
log "=== Validating disks ==="
|
||||||
|
|
||||||
@@ -96,13 +119,26 @@ fi
|
|||||||
# ── Helper: partition suffix ──────────────────────────────────────────────────
|
# ── Helper: partition suffix ──────────────────────────────────────────────────
|
||||||
part_suffix() {
|
part_suffix() {
|
||||||
local dev="$1" n="$2"
|
local dev="$1" n="$2"
|
||||||
if [[ "$dev" == *nvme* ]]; then
|
local base
|
||||||
|
base=$(basename "$dev")
|
||||||
|
if [[ "$base" == nvme* || "$base" == mmcblk* || "$base" == loop* || "$base" == md* || "$base" =~ [0-9]$ ]]; then
|
||||||
echo "${dev}p${n}"
|
echo "${dev}p${n}"
|
||||||
else
|
else
|
||||||
echo "${dev}${n}"
|
echo "${dev}${n}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nix_string() {
|
||||||
|
local value="$1"
|
||||||
|
value="${value//\\/\\\\}"
|
||||||
|
value="${value//\"/\\\"}"
|
||||||
|
value="${value//$'\n'/\\n}"
|
||||||
|
value="${value//$'\r'/\\r}"
|
||||||
|
value="${value//$'\t'/\\t}"
|
||||||
|
value="${value//\$\{/\\\$\{}"
|
||||||
|
printf '"%s"' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Detect existing Bitcoin timechain data on data disk ───────────────────────
|
# ── Detect existing Bitcoin timechain data on data disk ───────────────────────
|
||||||
if [[ -n "$DATA_DISK" ]]; then
|
if [[ -n "$DATA_DISK" ]]; then
|
||||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||||
@@ -142,12 +178,19 @@ partprobe "$DISK"
|
|||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
# ── Step 2: Partition OS disk ─────────────────────────────────────────────────
|
# ── Step 2: Partition OS disk ─────────────────────────────────────────────────
|
||||||
log "=== Partitioning OS disk ==="
|
if [[ "$BOOT_MODE" == "uefi" ]]; then
|
||||||
|
log "=== Partitioning OS disk (UEFI) ==="
|
||||||
sgdisk \
|
sgdisk \
|
||||||
-n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \
|
-n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \
|
||||||
-n "2:0:0" -t "2:8300" -c "2:root" \
|
-n "2:0:0" -t "2:8300" -c "2:root" \
|
||||||
"$DISK"
|
"$DISK"
|
||||||
|
else
|
||||||
|
log "=== Partitioning OS disk (Legacy BIOS) ==="
|
||||||
|
sgdisk \
|
||||||
|
-n "1:1M:+1M" -t "1:EF02" -c "1:BIOS-boot" \
|
||||||
|
-n "2:0:0" -t "2:8300" -c "2:root" \
|
||||||
|
"$DISK"
|
||||||
|
fi
|
||||||
|
|
||||||
partprobe "$DISK"
|
partprobe "$DISK"
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -168,7 +211,11 @@ log "=== Formatting partitions ==="
|
|||||||
BOOT_P1=$(part_suffix "$DISK" 1)
|
BOOT_P1=$(part_suffix "$DISK" 1)
|
||||||
BOOT_P2=$(part_suffix "$DISK" 2)
|
BOOT_P2=$(part_suffix "$DISK" 2)
|
||||||
|
|
||||||
mkfs.vfat -F 32 "$BOOT_P1"
|
if [[ "$BOOT_MODE" == "uefi" ]]; then
|
||||||
|
mkfs.vfat -F 32 "$BOOT_P1"
|
||||||
|
else
|
||||||
|
log "Skipping ESP format for Legacy BIOS install"
|
||||||
|
fi
|
||||||
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
|
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
|
||||||
|
|
||||||
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
|
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
|
||||||
@@ -180,8 +227,12 @@ fi
|
|||||||
log "=== Mounting filesystems ==="
|
log "=== Mounting filesystems ==="
|
||||||
|
|
||||||
mount "$BOOT_P2" /mnt
|
mount "$BOOT_P2" /mnt
|
||||||
mkdir -p /mnt/boot/efi
|
if [[ "$BOOT_MODE" == "uefi" ]]; then
|
||||||
mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi
|
mkdir -p /mnt/boot/efi
|
||||||
|
mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi
|
||||||
|
else
|
||||||
|
log "Legacy BIOS install: /boot/efi mount not required"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$DATA_DISK" ]]; then
|
if [[ -n "$DATA_DISK" ]]; then
|
||||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||||
@@ -219,16 +270,41 @@ case "$ROLE" in
|
|||||||
IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;;
|
IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
cat > /mnt/etc/nixos/role-state.nix <<EOF
|
{
|
||||||
|
cat <<EOF
|
||||||
# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
||||||
{ config, lib, ... }:
|
{ config, lib, ... }:
|
||||||
{
|
{
|
||||||
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER};
|
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER};
|
||||||
sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP};
|
sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP};
|
||||||
sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE};
|
sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE};
|
||||||
}
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
if [[ "$BOOT_MODE" == "bios" ]]; then
|
||||||
|
GRUB_DEVICE=$(nix_string "$DISK")
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
# The installer ISO was booted in Legacy BIOS mode, so install GRUB to the
|
||||||
|
# target disk instead of using the default UEFI systemd-boot configuration.
|
||||||
|
boot.loader.systemd-boot.enable = lib.mkForce false;
|
||||||
|
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||||
|
boot.loader.grub.enable = lib.mkForce true;
|
||||||
|
boot.loader.grub.device = lib.mkForce ${GRUB_DEVICE};
|
||||||
|
fileSystems."/boot/efi".enable = lib.mkForce false;
|
||||||
|
EOF
|
||||||
|
elif [[ -n "$VIRTUALIZATION" ]]; then
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
# VM UEFI firmware can reject or forget NVRAM boot-entry writes. Keep the
|
||||||
|
# normal UEFI systemd-boot layout, but use the fallback removable path instead
|
||||||
|
# of depending on EFI variable updates.
|
||||||
|
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "}"
|
||||||
|
} > /mnt/etc/nixos/role-state.nix
|
||||||
|
|
||||||
# ── Step 10: Write custom.nix with deploy config ──────────────────────────────
|
# ── Step 10: Write custom.nix with deploy config ──────────────────────────────
|
||||||
log "=== Writing custom.nix ==="
|
log "=== Writing custom.nix ==="
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
lib.mkIf config.sovran_systemsOS.features.bitcoin-core {
|
|
||||||
|
|
||||||
services.bitcoind.package = lib.mkForce config.nix-bitcoin.pkgs.bitcoind;
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# RPC calls that are safe for public use
|
||||||
|
# Vendored from nix-bitcoin - do not fetch from upstream at runtime
|
||||||
|
[
|
||||||
|
"echo"
|
||||||
|
"getinfo"
|
||||||
|
"getindexinfo"
|
||||||
|
"help"
|
||||||
|
"ping"
|
||||||
|
"uptime"
|
||||||
|
# Blockchain
|
||||||
|
"getbestblockhash"
|
||||||
|
"getblock"
|
||||||
|
"getblockchaininfo"
|
||||||
|
"getblockcount"
|
||||||
|
"getblockfilter"
|
||||||
|
"getblockfrompeer"
|
||||||
|
"getblockhash"
|
||||||
|
"getblockheader"
|
||||||
|
"getblockstats"
|
||||||
|
"getchaintips"
|
||||||
|
"getchaintxstats"
|
||||||
|
"getdeploymentinfo"
|
||||||
|
"getdifficulty"
|
||||||
|
"getmempoolancestors"
|
||||||
|
"getmempooldescendants"
|
||||||
|
"getmempoolentry"
|
||||||
|
"getmempoolinfo"
|
||||||
|
"getrawmempool"
|
||||||
|
"gettxout"
|
||||||
|
"gettxoutproof"
|
||||||
|
"gettxoutsetinfo"
|
||||||
|
"scantxoutset"
|
||||||
|
"verifytxoutproof"
|
||||||
|
# Mining
|
||||||
|
"getblocktemplate"
|
||||||
|
"getmininginfo"
|
||||||
|
"getnetworkhashps"
|
||||||
|
# Network
|
||||||
|
"getnetworkinfo"
|
||||||
|
"getnodeaddresses"
|
||||||
|
"getpeerinfo"
|
||||||
|
# Rawtransactions
|
||||||
|
"analyzepsbt"
|
||||||
|
"combinepsbt"
|
||||||
|
"combinerawtransaction"
|
||||||
|
"converttopsbt"
|
||||||
|
"createpsbt"
|
||||||
|
"createrawtransaction"
|
||||||
|
"decodepsbt"
|
||||||
|
"decoderawtransaction"
|
||||||
|
"decodescript"
|
||||||
|
"finalizepsbt"
|
||||||
|
"fundrawtransaction"
|
||||||
|
"getrawtransaction"
|
||||||
|
"joinpsbts"
|
||||||
|
"sendrawtransaction"
|
||||||
|
"signrawtransactionwithkey"
|
||||||
|
"testmempoolaccept"
|
||||||
|
"utxoupdatepsbt"
|
||||||
|
# Util
|
||||||
|
"createmultisig"
|
||||||
|
"deriveaddresses"
|
||||||
|
"estimatesmartfee"
|
||||||
|
"getdescriptorinfo"
|
||||||
|
"signmessagewithprivkey"
|
||||||
|
"validateaddress"
|
||||||
|
"verifymessage"
|
||||||
|
# Zmq
|
||||||
|
"getzmqnotifications"
|
||||||
|
]
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
options = {
|
||||||
|
services.bitcoind = {
|
||||||
|
enable = mkEnableOption "Bitcoin daemon";
|
||||||
|
address = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address to listen for peer connections.";
|
||||||
|
};
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = if !cfg.regtest then 8333 else 18444;
|
||||||
|
defaultText = "if !cfg.regtest then 8333 else 18444";
|
||||||
|
description = "Port to listen for peer connections.";
|
||||||
|
};
|
||||||
|
onionPort = mkOption {
|
||||||
|
type = types.nullOr types.port;
|
||||||
|
# When the bitcoind onion service is enabled, add an onion-tagged socket
|
||||||
|
# to distinguish local connections from Tor connections
|
||||||
|
default = if (config.nix-bitcoin.onionServices.bitcoind.enable or false) then 8334 else null;
|
||||||
|
description = ''
|
||||||
|
Port to listen for Tor peer connections.
|
||||||
|
If set, inbound connections to this port are tagged as onion peers.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
listen = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Listen for peer connections at `address:port`
|
||||||
|
and `address:onionPort` (if {option}`onionPort` is set).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
listenWhitelisted = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Listen for peer connections at `address:whitelistedPort`.
|
||||||
|
Peers connected through this socket are automatically whitelisted.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
whitelistedPort = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 8335;
|
||||||
|
description = "See `listenWhitelisted`.";
|
||||||
|
};
|
||||||
|
getPublicAddressCmd = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "";
|
||||||
|
description = ''
|
||||||
|
Bash expression which outputs the public service address to announce to peers.
|
||||||
|
If left empty, no address is announced.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = pkgs.bitcoind;
|
||||||
|
defaultText = "pkgs.bitcoind";
|
||||||
|
description = ''
|
||||||
|
The package providing bitcoind binaries.
|
||||||
|
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
extraConfig = mkOption {
|
||||||
|
type = types.lines;
|
||||||
|
default = "";
|
||||||
|
example = ''
|
||||||
|
par=16
|
||||||
|
logips=1
|
||||||
|
'';
|
||||||
|
description = "Extra lines appended to {file}`bitcoin.conf`.";
|
||||||
|
};
|
||||||
|
dataDir = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
default = "/var/lib/bitcoind";
|
||||||
|
description = "The data directory for bitcoind.";
|
||||||
|
};
|
||||||
|
rpc = {
|
||||||
|
address = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = ''
|
||||||
|
Address to listen for JSON-RPC connections.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = if !cfg.regtest then 8332 else 18443;
|
||||||
|
defaultText = "if !cfg.regtest then 8332 else 18443";
|
||||||
|
description = "Port to listen for JSON-RPC connections.";
|
||||||
|
};
|
||||||
|
threads = mkOption {
|
||||||
|
type = types.nullOr types.ints.u16;
|
||||||
|
default = null;
|
||||||
|
description = "The number of threads to service RPC calls.";
|
||||||
|
};
|
||||||
|
allowip = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ "127.0.0.1" ];
|
||||||
|
description = ''
|
||||||
|
Allow JSON-RPC connections from specified sources.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
users = mkOption {
|
||||||
|
default = {};
|
||||||
|
description = ''
|
||||||
|
Allowed users for JSON-RPC connections.
|
||||||
|
'';
|
||||||
|
example = {
|
||||||
|
alice = {
|
||||||
|
passwordHMAC = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
|
||||||
|
rpcwhitelist = [ "sendtoaddress" "getnewaddress" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
type = with types; attrsOf (submodule ({ name, ... }: {
|
||||||
|
options = {
|
||||||
|
name = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = name;
|
||||||
|
example = "alice";
|
||||||
|
description = ''
|
||||||
|
Username for JSON-RPC connections.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
passwordHMAC = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
example = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
|
||||||
|
description = ''
|
||||||
|
Password HMAC-SHA-256 for JSON-RPC connections. Must be a string of the
|
||||||
|
format `<SALT-HEX>$<HMAC-HEX>`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
passwordHMACFromFile = mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
internal = true;
|
||||||
|
default = false;
|
||||||
|
};
|
||||||
|
rpcwhitelist = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [];
|
||||||
|
description = ''
|
||||||
|
List of allowed rpc calls for each user.
|
||||||
|
If empty list, rpcwhitelist is disabled for that user.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
regtest = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable regtest mode.";
|
||||||
|
};
|
||||||
|
network = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = if cfg.regtest then "regtest" else "mainnet";
|
||||||
|
};
|
||||||
|
makeNetworkName = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = mainnet: regtest: if cfg.regtest then regtest else mainnet;
|
||||||
|
};
|
||||||
|
proxy = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
|
||||||
|
description = "Connect through SOCKS5 proxy";
|
||||||
|
};
|
||||||
|
i2p = mkOption {
|
||||||
|
type = types.enum [ false true "only-outgoing" ];
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Enable peer connections via i2p.
|
||||||
|
With `only-outgoing`, incoming i2p connections are disabled.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
dataDirReadableByGroup = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
If enabled, data dir content is readable by the bitcoind service group.
|
||||||
|
Warning: This disables bitcoind's wallet support.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
sysperms = mkOption {
|
||||||
|
type = types.nullOr types.bool;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Create new files with system default permissions, instead of umask 077
|
||||||
|
(only effective with disabled wallet functionality)
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
disablewallet = mkOption {
|
||||||
|
type = types.nullOr types.bool;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Do not load the wallet and disable wallet RPC calls
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
dbCache = mkOption {
|
||||||
|
type = types.nullOr (intAtLeast 4);
|
||||||
|
default = null;
|
||||||
|
example = 4000;
|
||||||
|
description = "Override the default database cache size in MiB.";
|
||||||
|
};
|
||||||
|
prune = mkOption {
|
||||||
|
type = types.ints.unsigned;
|
||||||
|
default = 0;
|
||||||
|
example = 10000;
|
||||||
|
description = ''
|
||||||
|
Automatically prune block files to stay under the specified target size in MiB.
|
||||||
|
Value 0 disables pruning.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
txindex = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable the transaction index.";
|
||||||
|
};
|
||||||
|
zmqpubrawblock = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
example = "tcp://127.0.0.1:28332";
|
||||||
|
description = "ZMQ address for zmqpubrawblock notifications";
|
||||||
|
};
|
||||||
|
zmqpubrawtx = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
example = "tcp://127.0.0.1:28333";
|
||||||
|
description = "ZMQ address for zmqpubrawtx notifications";
|
||||||
|
};
|
||||||
|
assumevalid = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
example = "00000000000000000000e5abc3a74fe27dc0ead9c70ea1deb456f11c15fd7bc6";
|
||||||
|
description = ''
|
||||||
|
If this block is in the chain assume that it and its ancestors are
|
||||||
|
valid and potentially skip their script verification.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
addnodes = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [];
|
||||||
|
example = [ "ecoc5q34tmbq54wl.onion" ];
|
||||||
|
description = "Add nodes to connect to and attempt to keep the connections open";
|
||||||
|
};
|
||||||
|
discover = mkOption {
|
||||||
|
type = types.nullOr types.bool;
|
||||||
|
default = null;
|
||||||
|
description = "Discover own IP addresses";
|
||||||
|
};
|
||||||
|
addresstype = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
example = "bech32";
|
||||||
|
description = "The type of addresses to use";
|
||||||
|
};
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "bitcoin";
|
||||||
|
description = "The user as which to run bitcoind.";
|
||||||
|
};
|
||||||
|
group = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = cfg.user;
|
||||||
|
description = "The group as which to run bitcoind.";
|
||||||
|
};
|
||||||
|
cli = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
type = types.package;
|
||||||
|
default = pkgs.writers.writeBashBin "bitcoin-cli" ''
|
||||||
|
exec ${cfg.package}/bin/bitcoin-cli -datadir='${cfg.dataDir}' "$@"
|
||||||
|
'';
|
||||||
|
defaultText = "(See source)";
|
||||||
|
description = "Binary to connect with the bitcoind instance.";
|
||||||
|
};
|
||||||
|
tor = nbLib.tor;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg = config.services.bitcoind;
|
||||||
|
nbLib = config.nix-bitcoin.lib;
|
||||||
|
secretsDir = config.nix-bitcoin.secretsDir;
|
||||||
|
|
||||||
|
i2pSAM = config.services.i2pd.proto.sam;
|
||||||
|
|
||||||
|
configFile = builtins.toFile "bitcoin.conf" ''
|
||||||
|
# We're already logging via journald
|
||||||
|
nodebuglogfile=1
|
||||||
|
logtimestamps=0
|
||||||
|
|
||||||
|
startupnotify=/run/current-system/systemd/bin/systemd-notify --ready
|
||||||
|
|
||||||
|
${optionalString cfg.regtest ''
|
||||||
|
regtest=1
|
||||||
|
[regtest]
|
||||||
|
''}
|
||||||
|
${optionalString (cfg.dbCache != null) "dbcache=${toString cfg.dbCache}"}
|
||||||
|
prune=${toString cfg.prune}
|
||||||
|
${optionalString cfg.txindex "txindex=1"}
|
||||||
|
${optionalString (cfg.sysperms != null) "sysperms=${if cfg.sysperms then "1" else "0"}"}
|
||||||
|
${optionalString (cfg.disablewallet != null) "disablewallet=${if cfg.disablewallet then "1" else "0"}"}
|
||||||
|
${optionalString (cfg.assumevalid != null) "assumevalid=${cfg.assumevalid}"}
|
||||||
|
|
||||||
|
# Connection options
|
||||||
|
listen=${if (cfg.listen || cfg.listenWhitelisted) then "1" else "0"}
|
||||||
|
${optionalString cfg.listen
|
||||||
|
"bind=${cfg.address}:${toString cfg.port}"}
|
||||||
|
${optionalString (cfg.listen && cfg.onionPort != null)
|
||||||
|
"bind=${cfg.address}:${toString cfg.onionPort}=onion"}
|
||||||
|
${optionalString cfg.listenWhitelisted
|
||||||
|
"whitebind=${cfg.address}:${toString cfg.whitelistedPort}"}
|
||||||
|
${optionalString (cfg.proxy != null) "proxy=${cfg.proxy}"}
|
||||||
|
${optionalString (cfg.i2p != false) "i2psam=${nbLib.addressWithPort i2pSAM.address i2pSAM.port}"}
|
||||||
|
${optionalString (cfg.i2p == "only-outgoing") "i2pacceptincoming=0"}
|
||||||
|
|
||||||
|
${optionalString (cfg.discover != null) "discover=${if cfg.discover then "1" else "0"}"}
|
||||||
|
${lib.concatMapStrings (node: "addnode=${node}\n") cfg.addnodes}
|
||||||
|
|
||||||
|
# RPC server options
|
||||||
|
rpcbind=${cfg.rpc.address}
|
||||||
|
rpcport=${toString cfg.rpc.port}
|
||||||
|
rpcconnect=${cfg.rpc.address}
|
||||||
|
${optionalString (cfg.rpc.threads != null) "rpcthreads=${toString cfg.rpc.threads}"}
|
||||||
|
rpcwhitelistdefault=0
|
||||||
|
${concatMapStrings (user: ''
|
||||||
|
${optionalString (!user.passwordHMACFromFile) "rpcauth=${user.name}:${user.passwordHMAC}"}
|
||||||
|
${optionalString (user.rpcwhitelist != [])
|
||||||
|
"rpcwhitelist=${user.name}:${lib.strings.concatStringsSep "," user.rpcwhitelist}"}
|
||||||
|
'') (builtins.attrValues cfg.rpc.users)
|
||||||
|
}
|
||||||
|
${lib.concatMapStrings (rpcallowip: "rpcallowip=${rpcallowip}\n") cfg.rpc.allowip}
|
||||||
|
|
||||||
|
# Wallet options
|
||||||
|
${optionalString (cfg.addresstype != null) "addresstype=${cfg.addresstype}"}
|
||||||
|
|
||||||
|
# ZMQ options
|
||||||
|
${optionalString (cfg.zmqpubrawblock != null) "zmqpubrawblock=${cfg.zmqpubrawblock}"}
|
||||||
|
${optionalString (cfg.zmqpubrawtx != null) "zmqpubrawtx=${cfg.zmqpubrawtx}"}
|
||||||
|
|
||||||
|
# Extra options
|
||||||
|
${cfg.extraConfig}
|
||||||
|
'';
|
||||||
|
|
||||||
|
zmqServerEnabled = (cfg.zmqpubrawblock != null) || (cfg.zmqpubrawtx != null);
|
||||||
|
|
||||||
|
intAtLeast = n: types.addCheck types.int (x: x >= n) // {
|
||||||
|
name = "intAtLeast";
|
||||||
|
description = "integer >= ${toString n}";
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
inherit options;
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
||||||
|
|
||||||
|
services.bitcoind = mkMerge [
|
||||||
|
(mkIf cfg.dataDirReadableByGroup {
|
||||||
|
disablewallet = true;
|
||||||
|
sysperms = true;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
rpc.users.privileged = {
|
||||||
|
passwordHMACFromFile = true;
|
||||||
|
};
|
||||||
|
rpc.users.public = {
|
||||||
|
passwordHMACFromFile = true;
|
||||||
|
rpcwhitelist = import ./bitcoind-rpc-public-whitelist.nix;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
services.i2pd = mkIf (cfg.i2p != false) {
|
||||||
|
enable = true;
|
||||||
|
proto.sam.enable = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.bitcoind = rec {
|
||||||
|
wants = [
|
||||||
|
"network-online.target"
|
||||||
|
# Use `wants` instead of `requires` for `nix-bitcoin-secrets.target`
|
||||||
|
# so that bitcoind and all dependent services are not restarted when
|
||||||
|
# the secrets target restarts.
|
||||||
|
# The secrets target always restarts when deploying with one of the methods
|
||||||
|
# in ./deployment.
|
||||||
|
#
|
||||||
|
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
||||||
|
# that propagates initial start failures but no restarts
|
||||||
|
"nix-bitcoin-secrets.target"
|
||||||
|
];
|
||||||
|
after = wants;
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
preStart = let
|
||||||
|
extraRpcauth = concatMapStrings (name: let
|
||||||
|
user = cfg.rpc.users.${name};
|
||||||
|
in optionalString user.passwordHMACFromFile ''
|
||||||
|
hmacPayload="$(readValidatedRpcHmac '${secretsDir}/bitcoin-HMAC-${name}')" || exit 1
|
||||||
|
printf '%s\n' "rpcauth=${user.name}:$hmacPayload"
|
||||||
|
''
|
||||||
|
) (builtins.attrNames cfg.rpc.users);
|
||||||
|
in ''
|
||||||
|
${optionalString cfg.dataDirReadableByGroup ''
|
||||||
|
if [[ -e '${cfg.dataDir}/blocks' ]]; then
|
||||||
|
chmod -R g+rX '${cfg.dataDir}/blocks'
|
||||||
|
fi
|
||||||
|
''}
|
||||||
|
|
||||||
|
readValidatedRpcHmac() {
|
||||||
|
local hmacFile="$1"
|
||||||
|
local hmacPayload
|
||||||
|
|
||||||
|
if [[ ! -e "$hmacFile" ]]; then
|
||||||
|
echo "Error: Bitcoin RPC HMAC file is missing: $hmacFile" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! -r "$hmacFile" ]]; then
|
||||||
|
echo "Error: Bitcoin RPC HMAC file is unreadable: $hmacFile" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
hmacPayload="$(<"$hmacFile")"
|
||||||
|
if [[ -z "$hmacPayload" ]]; then
|
||||||
|
echo "Error: Bitcoin RPC HMAC file is empty: $hmacFile" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$hmacPayload" =~ ^[[:xdigit:]]+\$[[:xdigit:]]+$ ]]; then
|
||||||
|
echo "Error: Bitcoin RPC HMAC file has invalid format: $hmacFile" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$hmacPayload"
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg=$(
|
||||||
|
cat ${configFile}
|
||||||
|
${extraRpcauth}
|
||||||
|
echo
|
||||||
|
${optionalString (cfg.getPublicAddressCmd != "") ''
|
||||||
|
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
||||||
|
''}
|
||||||
|
)
|
||||||
|
confFile='${cfg.dataDir}/bitcoin.conf'
|
||||||
|
if [[ ! -e $confFile || $cfg != $(cat $confFile) ]]; then
|
||||||
|
install -o '${cfg.user}' -g '${cfg.group}' -m 640 <(echo "$cfg") $confFile
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Enable RPC access for group
|
||||||
|
postStart = ''
|
||||||
|
chmod g=r '${cfg.dataDir}/${optionalString cfg.regtest "regtest/"}.cookie'
|
||||||
|
'' + (optionalString cfg.regtest) ''
|
||||||
|
chmod g=x '${cfg.dataDir}/regtest'
|
||||||
|
'';
|
||||||
|
|
||||||
|
serviceConfig = nbLib.defaultHardening // {
|
||||||
|
Type = "notify";
|
||||||
|
NotifyAccess = "all";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
TimeoutStartSec = "30min";
|
||||||
|
TimeoutStopSec = "30min";
|
||||||
|
ExecStart = "${cfg.package}/bin/bitcoind -datadir='${cfg.dataDir}'";
|
||||||
|
Restart = "on-failure";
|
||||||
|
UMask = mkIf cfg.dataDirReadableByGroup "0027";
|
||||||
|
ReadWritePaths = [ cfg.dataDir ];
|
||||||
|
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
||||||
|
// optionalAttrs zmqServerEnabled nbLib.allowNetlink;
|
||||||
|
};
|
||||||
|
|
||||||
|
users.users.${cfg.user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
};
|
||||||
|
users.groups.${cfg.group} = {};
|
||||||
|
users.groups.bitcoinrpc-public = {};
|
||||||
|
|
||||||
|
nix-bitcoin.operator.groups = [ cfg.group ];
|
||||||
|
|
||||||
|
nix-bitcoin.secrets = {
|
||||||
|
bitcoin-rpcpassword-privileged.user = cfg.user;
|
||||||
|
bitcoin-rpcpassword-public = {
|
||||||
|
user = cfg.user;
|
||||||
|
group = "bitcoinrpc-public";
|
||||||
|
};
|
||||||
|
|
||||||
|
bitcoin-HMAC-privileged.user = cfg.user;
|
||||||
|
bitcoin-HMAC-public.user = cfg.user;
|
||||||
|
};
|
||||||
|
nix-bitcoin.generateSecretsCmds.bitcoind = ''
|
||||||
|
makeBitcoinRPCPassword privileged
|
||||||
|
makeBitcoinRPCPassword public
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
options.services = {
|
||||||
|
nbxplorer = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Enable nbxplorer, a lightweight API for Bitcoin HD wallets.
|
||||||
|
|
||||||
|
Access API documentation here:
|
||||||
|
{option}`services.nbxplorer.address`:{option}`services.nbxplorer.port`
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
address = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address to listen on.";
|
||||||
|
};
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 24444;
|
||||||
|
description = "Port to listen on.";
|
||||||
|
};
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = pkgs.stable.nbxplorer;
|
||||||
|
defaultText = "pkgs.stable.nbxplorer";
|
||||||
|
description = "The package providing nbxplorer binaries.";
|
||||||
|
};
|
||||||
|
dataDir = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
default = "/var/lib/nbxplorer";
|
||||||
|
description = "The data directory for nbxplorer.";
|
||||||
|
};
|
||||||
|
# TODO-EXTERNAL:
|
||||||
|
# The shortcut link `main` in the datadir has changed to a directory
|
||||||
|
# in version 2.3.3.
|
||||||
|
# Add a dummy symlink, if it does not already exist, to be compatible with older modules.
|
||||||
|
# When the old system uses a link and the new system a directory, switching fails with:
|
||||||
|
# mv: cannot move '/var/lib/nbxplorer/Main' to '/var/lib/nbxplorer/.Main.tmp':
|
||||||
|
# No such file or directory
|
||||||
|
#
|
||||||
|
# Remove this option when it is irrelevant (i.e. when the old system will never
|
||||||
|
# be nix-bitcoin <=0.0.91)
|
||||||
|
addNetworkSymlink = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = pkgs.stable.nbxplorer != cfg.nbxplorer.package;
|
||||||
|
description = ''
|
||||||
|
Whether to add a compatibility symlink (like `${cfg.nbxplorer.dataDir}/Main`)
|
||||||
|
to the dataDir.
|
||||||
|
This is enabled by default if the nbxplorer package is set to the version-locked package.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "nbxplorer";
|
||||||
|
description = "The user as which to run NBXplorer.";
|
||||||
|
};
|
||||||
|
group = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = cfg.nbxplorer.user;
|
||||||
|
description = "The group as which to run NBXplorer.";
|
||||||
|
};
|
||||||
|
tor = nbLib.tor;
|
||||||
|
};
|
||||||
|
|
||||||
|
btcpayserver = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Enable BTCPay Server, a self-hosted, open-source payment processor.
|
||||||
|
|
||||||
|
Extra recommendations:
|
||||||
|
- Enable `services.btcpayserver.lightningBackend` to provide Lightning payment support.
|
||||||
|
- Secure this service if the instance is publically accessible. For example, set
|
||||||
|
{option}`services.btcpayserver.address` to `127.0.0.1` and use a reverse proxy
|
||||||
|
that enforces TLS (Transport Layer Security).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = pkgs.stable.btcpayserver;
|
||||||
|
defaultText = "pkgs.stable.btcpayserver";
|
||||||
|
description = "The package providing BTCPay Server binaries.";
|
||||||
|
};
|
||||||
|
address = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address to listen on.";
|
||||||
|
};
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 23000;
|
||||||
|
description = "Port to listen on.";
|
||||||
|
};
|
||||||
|
lightningBackend = mkOption {
|
||||||
|
type = types.nullOr (types.enum [ "lnd" ]);
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
The lightning node to use as a backend.
|
||||||
|
Enables the node service if not already enabled.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
lbtc = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Enable Liquid support.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
dataDir = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
default = "/var/lib/btcpayserver";
|
||||||
|
description = "The data directory for BTCPay Server.";
|
||||||
|
};
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "btcpayserver";
|
||||||
|
description = "The user as which to run BTCPay Server.";
|
||||||
|
};
|
||||||
|
group = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = cfg.btcpayserver.user;
|
||||||
|
description = "The group as which to run BTCPay Server.";
|
||||||
|
};
|
||||||
|
tor = nbLib.tor;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg = {
|
||||||
|
inherit (config.services)
|
||||||
|
nbxplorer
|
||||||
|
btcpayserver
|
||||||
|
bitcoind;
|
||||||
|
};
|
||||||
|
|
||||||
|
nbLib = config.nix-bitcoin.lib;
|
||||||
|
secretsDir = config.nix-bitcoin.secretsDir;
|
||||||
|
|
||||||
|
in {
|
||||||
|
inherit options;
|
||||||
|
|
||||||
|
config = mkMerge [
|
||||||
|
(mkIf cfg.nbxplorer.enable {
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d '${cfg.nbxplorer.dataDir}' 0770 ${cfg.nbxplorer.user} ${cfg.nbxplorer.group} - -"
|
||||||
|
] ++ optional cfg.nbxplorer.addNetworkSymlink
|
||||||
|
"L+ '${cfg.nbxplorer.dataDir}/Main' - - - - '${cfg.nbxplorer.dataDir}/main'";
|
||||||
|
|
||||||
|
systemd.services.nbxplorer = let
|
||||||
|
configFile = builtins.toFile "nbxplorer-config" ''
|
||||||
|
network=${cfg.bitcoind.network}
|
||||||
|
btcrpcuser=${cfg.bitcoind.rpc.users.btcpayserver.name}
|
||||||
|
btcrpcurl=http://${nbLib.addressWithPort cfg.bitcoind.rpc.address cfg.bitcoind.rpc.port}
|
||||||
|
btcnodeendpoint=${nbLib.addressWithPort cfg.bitcoind.address cfg.bitcoind.whitelistedPort}
|
||||||
|
bind=${cfg.nbxplorer.address}
|
||||||
|
port=${toString cfg.nbxplorer.port}
|
||||||
|
postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
|
||||||
|
'';
|
||||||
|
in rec {
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
requires = [ "postgresql.target" ];
|
||||||
|
wants = [ "bitcoind.service" ];
|
||||||
|
after = requires ++ wants ++ [ "nix-bitcoin-secrets.target" ];
|
||||||
|
preStart = ''
|
||||||
|
install -m 600 ${configFile} '${cfg.nbxplorer.dataDir}/settings.config'
|
||||||
|
printf '%s\n' "btcrpcpassword=$(<${secretsDir}/bitcoin-rpcpassword-btcpayserver)" \
|
||||||
|
>> '${cfg.nbxplorer.dataDir}/settings.config'
|
||||||
|
'';
|
||||||
|
serviceConfig = nbLib.defaultHardening // {
|
||||||
|
ExecStart = ''
|
||||||
|
${cfg.nbxplorer.package}/bin/nbxplorer --conf=${cfg.nbxplorer.dataDir}/settings.config \
|
||||||
|
--datadir='${cfg.nbxplorer.dataDir}'
|
||||||
|
'';
|
||||||
|
RuntimeDirectory = "nbxplorer";
|
||||||
|
StateDirectory = "nbxplorer";
|
||||||
|
User = cfg.nbxplorer.user;
|
||||||
|
Group = cfg.nbxplorer.group;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "10s";
|
||||||
|
ReadWritePaths = [ cfg.nbxplorer.dataDir ];
|
||||||
|
MemoryDenyWriteExecute = false;
|
||||||
|
} // nbLib.allowedIPAddresses cfg.nbxplorer.tor.enforce;
|
||||||
|
};
|
||||||
|
|
||||||
|
services.bitcoind = {
|
||||||
|
enable = true;
|
||||||
|
listenWhitelisted = true;
|
||||||
|
txindex = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
users.users.${cfg.nbxplorer.user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.nbxplorer.group;
|
||||||
|
home = cfg.nbxplorer.dataDir;
|
||||||
|
};
|
||||||
|
users.groups.${cfg.nbxplorer.group} = {};
|
||||||
|
})
|
||||||
|
|
||||||
|
(mkIf cfg.btcpayserver.enable {
|
||||||
|
services.nbxplorer.enable = true;
|
||||||
|
|
||||||
|
services.bitcoind = {
|
||||||
|
listenWhitelisted = true;
|
||||||
|
rpc.users.btcpayserver = {
|
||||||
|
name = "btcpayserver";
|
||||||
|
passwordHMACFromFile = true;
|
||||||
|
rpcwhitelist = [
|
||||||
|
"getblockchaininfo"
|
||||||
|
"getblock"
|
||||||
|
"getblockhash"
|
||||||
|
"getblockheader"
|
||||||
|
"getblockstats"
|
||||||
|
"gettransaction"
|
||||||
|
"getrawtransaction"
|
||||||
|
"sendrawtransaction"
|
||||||
|
"getblockcount"
|
||||||
|
"getbestblockhash"
|
||||||
|
"getnetworkinfo"
|
||||||
|
"getpeerinfo"
|
||||||
|
"estimatesmartfee"
|
||||||
|
"getmempoolinfo"
|
||||||
|
"getmempoolentry"
|
||||||
|
"getrawmempool"
|
||||||
|
"gettxout"
|
||||||
|
"scantxoutset"
|
||||||
|
"importmulti"
|
||||||
|
"listunspent"
|
||||||
|
"getwalletinfo"
|
||||||
|
"listtransactions"
|
||||||
|
"listreceivedbyaddress"
|
||||||
|
"getnewaddress"
|
||||||
|
"uptime"
|
||||||
|
"getrpcinfo"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d '${cfg.btcpayserver.dataDir}' 0770 ${cfg.btcpayserver.user} ${cfg.btcpayserver.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.btcpayserver = let
|
||||||
|
nbExplorerUrl = "http://${nbLib.addressWithPort cfg.nbxplorer.address cfg.nbxplorer.port}/";
|
||||||
|
nbExplorerCookie =
|
||||||
|
"${cfg.nbxplorer.dataDir}/${cfg.bitcoind.makeNetworkName "Main" "RegTest"}/.cookie";
|
||||||
|
configFile = builtins.toFile "btcpayserver-config" (
|
||||||
|
''
|
||||||
|
network=${cfg.bitcoind.network}
|
||||||
|
bind=${cfg.btcpayserver.address}
|
||||||
|
port=${toString cfg.btcpayserver.port}
|
||||||
|
socksendpoint=${config.nix-bitcoin.torClientAddressWithPort}
|
||||||
|
btcexplorerurl=${nbExplorerUrl}
|
||||||
|
btcexplorercookiefile=${nbExplorerCookie}
|
||||||
|
explorer.postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
|
||||||
|
postgres=User ID=${cfg.btcpayserver.user};Host=/run/postgresql;Database=btcpayserver
|
||||||
|
'' + optionalString (cfg.btcpayserver.lightningBackend == "lnd")
|
||||||
|
(
|
||||||
|
"btclightning=type=lnd-rest;"
|
||||||
|
+ "server=https://${nbLib.address config.services.lnd.restAddress}:${toString config.services.lnd.restPort}/;"
|
||||||
|
+ "macaroonfilepath=/run/lnd/btcpayserver.macaroon;"
|
||||||
|
+ "certfilepath=${config.services.lnd.certPath}\n"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
in rec {
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
requires = [ "postgresql.target" "nbxplorer.service" ];
|
||||||
|
wants = optional (cfg.btcpayserver.lightningBackend == "lnd") "lnd.service";
|
||||||
|
after = requires ++ wants;
|
||||||
|
serviceConfig = nbLib.defaultHardening // {
|
||||||
|
ExecStart = ''
|
||||||
|
${cfg.btcpayserver.package}/bin/btcpayserver --conf=${configFile} \
|
||||||
|
--datadir='${cfg.btcpayserver.dataDir}'
|
||||||
|
'';
|
||||||
|
WorkingDirectory = "${cfg.btcpayserver.package}/lib/btcpayserver";
|
||||||
|
RuntimeDirectory = "btcpayserver";
|
||||||
|
StateDirectory = "btcpayserver";
|
||||||
|
User = cfg.btcpayserver.user;
|
||||||
|
Group = cfg.btcpayserver.group;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "10s";
|
||||||
|
ReadWritePaths = [ cfg.btcpayserver.dataDir ];
|
||||||
|
MemoryDenyWriteExecute = false;
|
||||||
|
} // nbLib.allowedIPAddresses cfg.btcpayserver.tor.enforce;
|
||||||
|
};
|
||||||
|
|
||||||
|
services.postgresql = {
|
||||||
|
enable = true;
|
||||||
|
ensureDatabases = [ "btcpayserver" "nbxplorer" ];
|
||||||
|
ensureUsers = [
|
||||||
|
{ name = cfg.btcpayserver.user; ensureDBOwnership = true; }
|
||||||
|
{ name = cfg.nbxplorer.user; ensureDBOwnership = true; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
users.users.${cfg.btcpayserver.user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.btcpayserver.group;
|
||||||
|
home = cfg.btcpayserver.dataDir;
|
||||||
|
extraGroups = optional (cfg.btcpayserver.lightningBackend == "lnd") config.services.lnd.group;
|
||||||
|
};
|
||||||
|
users.groups.${cfg.btcpayserver.group} = {};
|
||||||
|
|
||||||
|
nix-bitcoin.secrets = {
|
||||||
|
bitcoin-rpcpassword-btcpayserver = {
|
||||||
|
user = cfg.bitcoind.user;
|
||||||
|
group = cfg.nbxplorer.group;
|
||||||
|
};
|
||||||
|
bitcoin-HMAC-btcpayserver.user = cfg.bitcoind.user;
|
||||||
|
};
|
||||||
|
nix-bitcoin.generateSecretsCmds.btcpayserver = ''
|
||||||
|
makeBitcoinRPCPassword btcpayserver
|
||||||
|
'';
|
||||||
|
})
|
||||||
|
|
||||||
|
(mkIf (cfg.btcpayserver.enable && cfg.btcpayserver.lightningBackend == "lnd") {
|
||||||
|
services.lnd = {
|
||||||
|
enable = true;
|
||||||
|
macaroons.btcpayserver = {
|
||||||
|
user = cfg.btcpayserver.user;
|
||||||
|
permissions = ''
|
||||||
|
{"entity":"address","action":"write"},{"entity":"info","action":"read"},{"entity":"invoices","action":"read"},{"entity":"invoices","action":"write"},{"entity":"offchain","action":"read"},{"entity":"offchain","action":"write"},{"entity":"onchain","action":"read"},{"entity":"onchain","action":"write"},{"entity":"peers","action":"read"},{"entity":"peers","action":"write"}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
users.users.${config.services.lnd.user}.extraGroups = [ cfg.btcpayserver.group ];
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Common Bitcoin infrastructure: secrets, onion services, nodeinfo, security
|
||||||
|
# Extracted from nix-bitcoin, tailored for Sovran (lnd-only)
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
./nix-bitcoin.nix
|
||||||
|
./secrets/secrets.nix
|
||||||
|
./operator.nix
|
||||||
|
./security.nix
|
||||||
|
./onion-addresses.nix
|
||||||
|
./onion-services.nix
|
||||||
|
./nodeinfo.nix
|
||||||
|
./versioning.nix
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Sovran Bitcoin stack - tailored from nix-bitcoin, lnd-only, nixpkgs packages
|
||||||
|
# Original: https://github.com/fort-nix/nix-bitcoin
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
./common.nix
|
||||||
|
./bitcoind.nix
|
||||||
|
./electrs.nix
|
||||||
|
./lnd.nix
|
||||||
|
./lndconnect.nix
|
||||||
|
./rtl.nix
|
||||||
|
./btcpayserver.nix
|
||||||
|
./mempool.nix
|
||||||
|
];
|
||||||
|
|
||||||
|
disabledModules = [ "services/networking/bitcoind.nix" ];
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
options.services.electrs = {
|
||||||
|
enable = mkEnableOption "electrs, an Electrum server implemented in Rust";
|
||||||
|
address = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address to listen for RPC connections.";
|
||||||
|
};
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 50001;
|
||||||
|
description = "Port to listen for RPC connections.";
|
||||||
|
};
|
||||||
|
dataDir = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
default = "/var/lib/electrs";
|
||||||
|
description = "The data directory for electrs.";
|
||||||
|
};
|
||||||
|
monitoringPort = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 4224;
|
||||||
|
description = "Prometheus monitoring port.";
|
||||||
|
};
|
||||||
|
extraArgs = mkOption {
|
||||||
|
type = types.separatedString " ";
|
||||||
|
default = "";
|
||||||
|
description = "Extra command line arguments passed to electrs.";
|
||||||
|
};
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "electrs";
|
||||||
|
description = "The user as which to run electrs.";
|
||||||
|
};
|
||||||
|
group = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = cfg.user;
|
||||||
|
description = "The group as which to run electrs.";
|
||||||
|
};
|
||||||
|
tor.enforce = nbLib.tor.enforce;
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg = config.services.electrs;
|
||||||
|
nbLib = config.nix-bitcoin.lib;
|
||||||
|
secretsDir = config.nix-bitcoin.secretsDir;
|
||||||
|
bitcoind = config.services.bitcoind;
|
||||||
|
in {
|
||||||
|
inherit options;
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{ assertion = bitcoind.prune == 0;
|
||||||
|
message = "electrs does not support bitcoind pruning.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
services.bitcoind = {
|
||||||
|
enable = true;
|
||||||
|
listenWhitelisted = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.electrs = {
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
requires = [ "bitcoind.service" ];
|
||||||
|
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
||||||
|
preStart = ''
|
||||||
|
echo "auth = \"${bitcoind.rpc.users.public.name}:$(cat ${secretsDir}/bitcoin-rpcpassword-public)\"" \
|
||||||
|
> electrs.toml
|
||||||
|
'';
|
||||||
|
serviceConfig = nbLib.defaultHardening // {
|
||||||
|
# electrs only uses the working directory for reading electrs.toml
|
||||||
|
WorkingDirectory = cfg.dataDir;
|
||||||
|
ExecStart = ''
|
||||||
|
${pkgs.electrs}/bin/electrs \
|
||||||
|
--log-filters=INFO \
|
||||||
|
--network=${bitcoind.makeNetworkName "bitcoin" "regtest"} \
|
||||||
|
--db-dir='${cfg.dataDir}' \
|
||||||
|
--daemon-dir='${bitcoind.dataDir}' \
|
||||||
|
--electrum-rpc-addr=${cfg.address}:${toString cfg.port} \
|
||||||
|
--monitoring-addr=${cfg.address}:${toString cfg.monitoringPort} \
|
||||||
|
--daemon-rpc-addr=${nbLib.addressWithPort bitcoind.rpc.address bitcoind.rpc.port} \
|
||||||
|
--daemon-p2p-addr=${nbLib.addressWithPort bitcoind.address bitcoind.whitelistedPort} \
|
||||||
|
${cfg.extraArgs}
|
||||||
|
'';
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "10s";
|
||||||
|
ReadWritePaths = [ cfg.dataDir ];
|
||||||
|
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
||||||
|
};
|
||||||
|
|
||||||
|
users.users.${cfg.user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
extraGroups = [ "bitcoinrpc-public" ];
|
||||||
|
};
|
||||||
|
users.groups.${cfg.group} = {};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
lib: pkgs: config:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
|
||||||
|
# See `man systemd.exec` and `man systemd.resource-control` for an explanation
|
||||||
|
# of the systemd-related options available through this file.
|
||||||
|
let self = {
|
||||||
|
# These settings roughly follow systemd's "strict" security profile
|
||||||
|
defaultHardening = {
|
||||||
|
PrivateTmp = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
PrivateDevices = true;
|
||||||
|
MemoryDenyWriteExecute = true;
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectKernelLogs = true;
|
||||||
|
ProtectClock = true;
|
||||||
|
ProtectProc = "invisible";
|
||||||
|
ProcSubset = "pid";
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
|
||||||
|
RestrictNamespaces = true;
|
||||||
|
LockPersonality = true;
|
||||||
|
IPAddressDeny = "any";
|
||||||
|
PrivateUsers = true;
|
||||||
|
RestrictSUIDSGID = true;
|
||||||
|
RemoveIPC = true;
|
||||||
|
RestrictRealtime = true;
|
||||||
|
ProtectHostname = true;
|
||||||
|
CapabilityBoundingSet = "";
|
||||||
|
# @system-service whitelist and docker seccomp blacklist (except for "clone"
|
||||||
|
# which is a core requirement for systemd services)
|
||||||
|
# @system-service is defined in src/shared/seccomp-util.c (systemd source)
|
||||||
|
SystemCallFilter = [ "@system-service" "~add_key kcmp keyctl mbind move_pages name_to_handle_at personality process_vm_readv process_vm_writev request_key setns unshare userfaultfd" ];
|
||||||
|
SystemCallArchitectures = "native";
|
||||||
|
};
|
||||||
|
|
||||||
|
allowNetlink = {
|
||||||
|
RestrictAddressFamilies = self.defaultHardening.RestrictAddressFamilies + " AF_NETLINK";
|
||||||
|
};
|
||||||
|
|
||||||
|
nodejs = {
|
||||||
|
# Required for JIT compilation
|
||||||
|
MemoryDenyWriteExecute = false;
|
||||||
|
# Required by nodejs >= 18
|
||||||
|
SystemCallFilter = self.defaultHardening.SystemCallFilter ++ [ "@pkey" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Allow takes precedence over Deny.
|
||||||
|
allowLocalIPAddresses = {
|
||||||
|
IPAddressAllow = [
|
||||||
|
"127.0.0.1/32"
|
||||||
|
"::1/128"
|
||||||
|
"169.254.0.0/16"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
allowAllIPAddresses = { IPAddressAllow = "any"; };
|
||||||
|
allowTor = self.allowLocalIPAddresses;
|
||||||
|
allowedIPAddresses = onlyLocal:
|
||||||
|
if onlyLocal
|
||||||
|
then self.allowLocalIPAddresses
|
||||||
|
else self.allowAllIPAddresses;
|
||||||
|
|
||||||
|
tor = {
|
||||||
|
proxy = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether to proxy outgoing connections with Tor.";
|
||||||
|
};
|
||||||
|
enforce = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Whether to enforce Tor on this service by only allowing connections
|
||||||
|
from and to localhost and link-local addresses.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
script = name: src: pkgs.writers.writeBash name ''
|
||||||
|
set -eo pipefail
|
||||||
|
${src}
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Used for ExecStart*
|
||||||
|
rootScript = name: src: "+${self.script name src}";
|
||||||
|
|
||||||
|
cliExec = mkOption {
|
||||||
|
# Used by netns-isolation to execute the cli in the service's private netns
|
||||||
|
internal = true;
|
||||||
|
type = types.str;
|
||||||
|
default = "exec";
|
||||||
|
};
|
||||||
|
|
||||||
|
mkOnionService = map: {
|
||||||
|
map = [ map ];
|
||||||
|
version = 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Convert a bind address, which may be a special INADDR_ANY address,
|
||||||
|
# to an actual IP address
|
||||||
|
address = addr:
|
||||||
|
if addr == "0.0.0.0" then
|
||||||
|
"127.0.0.1"
|
||||||
|
else if addr == "::" then
|
||||||
|
"::1"
|
||||||
|
else
|
||||||
|
addr;
|
||||||
|
|
||||||
|
addressWithPort = addr: port: "${self.address addr}:${toString port}";
|
||||||
|
|
||||||
|
optionalAttr = cond: name: if cond then name else null;
|
||||||
|
|
||||||
|
mkCertExtraAltNames = cert:
|
||||||
|
builtins.concatStringsSep "," (
|
||||||
|
(map (domain: "DNS:${domain}") cert.extraDomains) ++
|
||||||
|
(map (ip: "IP:${ip}") cert.extraIPs)
|
||||||
|
);
|
||||||
|
|
||||||
|
test = {
|
||||||
|
mkIfTest = test: mkIf (config.tests.${test} or false);
|
||||||
|
};
|
||||||
|
|
||||||
|
mkAlias = default: mkOption {
|
||||||
|
internal = true;
|
||||||
|
readOnly = true;
|
||||||
|
inherit default;
|
||||||
|
};
|
||||||
|
|
||||||
|
}; in self
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
options.services.lnd = {
|
||||||
|
enable = mkEnableOption "Lightning Network daemon, a Lightning Network implementation in Go";
|
||||||
|
address = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address to listen for peer connections";
|
||||||
|
};
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 9735;
|
||||||
|
description = "Port to listen for peer connections";
|
||||||
|
};
|
||||||
|
rpcAddress = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address to listen for RPC connections.";
|
||||||
|
};
|
||||||
|
rpcPort = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 10009;
|
||||||
|
description = "Port to listen for gRPC connections.";
|
||||||
|
};
|
||||||
|
restAddress = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address to listen for REST connections.";
|
||||||
|
};
|
||||||
|
restPort = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 8080;
|
||||||
|
description = "Port to listen for REST connections.";
|
||||||
|
};
|
||||||
|
dataDir = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
default = "/var/lib/lnd";
|
||||||
|
description = "The data directory for LND.";
|
||||||
|
};
|
||||||
|
networkDir = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = "${cfg.dataDir}/chain/bitcoin/${bitcoind.network}";
|
||||||
|
description = "The network data directory.";
|
||||||
|
};
|
||||||
|
tor-socks = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
|
||||||
|
description = "Socks proxy for connecting to Tor nodes";
|
||||||
|
};
|
||||||
|
macaroons = mkOption {
|
||||||
|
default = {};
|
||||||
|
type = with types; attrsOf (submodule {
|
||||||
|
options = {
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "User who owns the macaroon.";
|
||||||
|
};
|
||||||
|
permissions = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
example = ''
|
||||||
|
{"entity":"info","action":"read"},{"entity":"onchain","action":"read"}
|
||||||
|
'';
|
||||||
|
description = "List of granted macaroon permissions.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
description = ''
|
||||||
|
Extra macaroon definitions.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
certificate = {
|
||||||
|
extraIPs = mkOption {
|
||||||
|
type = with types; listOf str;
|
||||||
|
default = [];
|
||||||
|
example = [ "60.100.0.1" ];
|
||||||
|
description = ''
|
||||||
|
Extra `subjectAltName` IPs added to the certificate.
|
||||||
|
This works the same as lnd option {option}`tlsextraip`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
extraDomains = mkOption {
|
||||||
|
type = with types; listOf str;
|
||||||
|
default = [];
|
||||||
|
example = [ "example.com" ];
|
||||||
|
description = ''
|
||||||
|
Extra `subjectAltName` domain names added to the certificate.
|
||||||
|
This works the same as lnd option {option}`tlsextradomain`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
extraConfig = mkOption {
|
||||||
|
type = types.lines;
|
||||||
|
default = "";
|
||||||
|
example = ''
|
||||||
|
autopilot.active=1
|
||||||
|
'';
|
||||||
|
description = ''
|
||||||
|
Extra lines appended to {file}`lnd.conf`.
|
||||||
|
See here for all available options:
|
||||||
|
https://github.com/lightningnetwork/lnd/blob/master/sample-lnd.conf
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = pkgs.lnd;
|
||||||
|
defaultText = "pkgs.lnd";
|
||||||
|
description = "The package providing lnd binaries.";
|
||||||
|
};
|
||||||
|
cli = mkOption {
|
||||||
|
default = pkgs.writers.writeBashBin "lncli"
|
||||||
|
# Switch user because lnd makes datadir contents readable by user only
|
||||||
|
''
|
||||||
|
${runAsUser} ${cfg.user} ${cfg.package}/bin/lncli \
|
||||||
|
--rpcserver ${cfg.rpcAddress}:${toString cfg.rpcPort} \
|
||||||
|
--tlscertpath '${cfg.certPath}' \
|
||||||
|
--macaroonpath '${networkDir}/admin.macaroon' "$@"
|
||||||
|
'';
|
||||||
|
defaultText = "(See source)";
|
||||||
|
description = "Binary to connect with the lnd instance.";
|
||||||
|
};
|
||||||
|
getPublicAddressCmd = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "";
|
||||||
|
description = ''
|
||||||
|
Bash expression which outputs the public service address to announce to peers.
|
||||||
|
If left empty, no address is announced.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "lnd";
|
||||||
|
description = "The user as which to run LND.";
|
||||||
|
};
|
||||||
|
group = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = cfg.user;
|
||||||
|
description = "The group as which to run LND.";
|
||||||
|
};
|
||||||
|
certPath = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = "${secretsDir}/lnd-cert";
|
||||||
|
description = "LND TLS certificate path.";
|
||||||
|
};
|
||||||
|
tor = nbLib.tor;
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg = config.services.lnd;
|
||||||
|
nbLib = config.nix-bitcoin.lib;
|
||||||
|
secretsDir = config.nix-bitcoin.secretsDir;
|
||||||
|
runAsUser = config.nix-bitcoin.runAsUserCmd;
|
||||||
|
lndinit = "${(pkgs.callPackage ../../packages/lndinit {})}/bin/lndinit";
|
||||||
|
|
||||||
|
bitcoind = config.services.bitcoind;
|
||||||
|
|
||||||
|
bitcoindRpcAddress = nbLib.address bitcoind.rpc.address;
|
||||||
|
networkDir = cfg.networkDir;
|
||||||
|
configFile = pkgs.writeText "lnd.conf" ''
|
||||||
|
datadir=${cfg.dataDir}
|
||||||
|
tlscertpath=${cfg.certPath}
|
||||||
|
tlskeypath=${secretsDir}/lnd-key
|
||||||
|
|
||||||
|
# We're logging via journald
|
||||||
|
logging.file.disable=1
|
||||||
|
logging.console.no-timestamps=1
|
||||||
|
|
||||||
|
listen=${toString cfg.address}:${toString cfg.port}
|
||||||
|
rpclisten=${cfg.rpcAddress}:${toString cfg.rpcPort}
|
||||||
|
restlisten=${cfg.restAddress}:${toString cfg.restPort}
|
||||||
|
|
||||||
|
bitcoin.${bitcoind.network}=1
|
||||||
|
bitcoin.node=bitcoind
|
||||||
|
|
||||||
|
${optionalString (cfg.tor.proxy) "tor.active=true"}
|
||||||
|
${optionalString (cfg.tor-socks != null) "tor.socks=${cfg.tor-socks}"}
|
||||||
|
|
||||||
|
bitcoind.rpchost=${bitcoindRpcAddress}:${toString bitcoind.rpc.port}
|
||||||
|
bitcoind.rpcuser=${bitcoind.rpc.users.public.name}
|
||||||
|
bitcoind.zmqpubrawblock=${zmqHandleSpecialAddress bitcoind.zmqpubrawblock}
|
||||||
|
bitcoind.zmqpubrawtx=${zmqHandleSpecialAddress bitcoind.zmqpubrawtx}
|
||||||
|
|
||||||
|
wallet-unlock-password-file=${secretsDir}/lnd-wallet-password
|
||||||
|
|
||||||
|
${cfg.extraConfig}
|
||||||
|
'';
|
||||||
|
|
||||||
|
zmqHandleSpecialAddress = builtins.replaceStrings [ "0.0.0.0" "[::]" ] [ "127.0.0.1" "[::1]" ];
|
||||||
|
in {
|
||||||
|
|
||||||
|
inherit options;
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{ assertion =
|
||||||
|
!(config.services ? clightning)
|
||||||
|
|| true; # clightning enable/port check disabled - option structure differs between nixpkgs versions (f13ff45 has plugins only, 8b8c811 removed). Sovran uses lnd only, so no conflict.
|
||||||
|
message = ''
|
||||||
|
LND and clightning can't both bind to lightning port 9735. Either
|
||||||
|
disable LND/clightning or change services.clightning.port or
|
||||||
|
services.lnd.port to a port other than 9735.
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
services.bitcoind = {
|
||||||
|
enable = true;
|
||||||
|
|
||||||
|
# Increase rpc thread count due to reports that lightning implementations fail
|
||||||
|
# under high bitcoind rpc load
|
||||||
|
rpc.threads = 16;
|
||||||
|
|
||||||
|
zmqpubrawblock = mkDefault "tcp://${bitcoindRpcAddress}:28332";
|
||||||
|
zmqpubrawtx = mkDefault "tcp://${bitcoindRpcAddress}:28333";
|
||||||
|
};
|
||||||
|
|
||||||
|
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
services.lnd.certificate.extraIPs = mkIf (cfg.rpcAddress != "127.0.0.1") [ "${cfg.rpcAddress}" ];
|
||||||
|
|
||||||
|
systemd.services.lnd = {
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
requires = [ "bitcoind.service" ];
|
||||||
|
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
||||||
|
preStart = ''
|
||||||
|
install -m600 ${configFile} '${cfg.dataDir}/lnd.conf'
|
||||||
|
{
|
||||||
|
echo "bitcoind.rpcpass=$(cat ${secretsDir}/bitcoin-rpcpassword-public)"
|
||||||
|
${optionalString (cfg.getPublicAddressCmd != "") ''
|
||||||
|
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
||||||
|
''}
|
||||||
|
} >> '${cfg.dataDir}/lnd.conf'
|
||||||
|
|
||||||
|
if [[ ! -f ${networkDir}/wallet.db ]]; then
|
||||||
|
seed='${cfg.dataDir}/lnd-seed-mnemonic'
|
||||||
|
|
||||||
|
if [[ ! -f "$seed" ]]; then
|
||||||
|
echo "Create lnd seed"
|
||||||
|
(umask u=r,go=; ${lndinit} gen-seed > "$seed")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Create lnd wallet"
|
||||||
|
${lndinit} -v init-wallet \
|
||||||
|
--file.seed="$seed" \
|
||||||
|
--file.wallet-password='${secretsDir}/lnd-wallet-password' \
|
||||||
|
--init-file.output-wallet-dir='${cfg.networkDir}'
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
serviceConfig = nbLib.defaultHardening // {
|
||||||
|
Type = "notify";
|
||||||
|
RuntimeDirectory = "lnd"; # Only used to store custom macaroons
|
||||||
|
RuntimeDirectoryMode = "711";
|
||||||
|
ExecStart = "${cfg.package}/bin/lnd --configfile='${cfg.dataDir}/lnd.conf'";
|
||||||
|
User = cfg.user;
|
||||||
|
TimeoutSec = "15min";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "10s";
|
||||||
|
ReadWritePaths = [ cfg.dataDir ];
|
||||||
|
ExecStartPost = let
|
||||||
|
curl = "${pkgs.curl}/bin/curl -fsS --cacert ${cfg.certPath}";
|
||||||
|
restUrl = "https://${nbLib.addressWithPort cfg.restAddress cfg.restPort}/v1";
|
||||||
|
# Setting macaroon permissions for other users needs root permissions
|
||||||
|
script = nbLib.rootScript "lnd-create-macaroons" ''
|
||||||
|
umask ug=r,o=
|
||||||
|
${lib.concatMapStrings (macaroon: ''
|
||||||
|
echo "Create custom macaroon ${macaroon}"
|
||||||
|
macaroonPath="$RUNTIME_DIRECTORY/${macaroon}.macaroon"
|
||||||
|
${curl} \
|
||||||
|
-H "Grpc-Metadata-macaroon: $(${pkgs.xxd}/bin/xxd -ps -u -c 99999 '${networkDir}/admin.macaroon')" \
|
||||||
|
-X POST \
|
||||||
|
-d '{"permissions":[${cfg.macaroons.${macaroon}.permissions}]}' \
|
||||||
|
${restUrl}/macaroon |\
|
||||||
|
${pkgs.jq}/bin/jq -c '.macaroon' | ${pkgs.xxd}/bin/xxd -p -r > "$macaroonPath"
|
||||||
|
chown ${cfg.macaroons.${macaroon}.user}: "$macaroonPath"
|
||||||
|
'') (attrNames cfg.macaroons)}
|
||||||
|
'';
|
||||||
|
in [
|
||||||
|
script
|
||||||
|
];
|
||||||
|
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
||||||
|
};
|
||||||
|
|
||||||
|
users.users.${cfg.user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
extraGroups = [ "bitcoinrpc-public" ];
|
||||||
|
home = cfg.dataDir; # lnd creates .lnd dir in HOME
|
||||||
|
};
|
||||||
|
users.groups.${cfg.group} = {};
|
||||||
|
nix-bitcoin.operator = {
|
||||||
|
groups = [ cfg.group ];
|
||||||
|
allowRunAsUsers = [ cfg.user ];
|
||||||
|
};
|
||||||
|
|
||||||
|
nix-bitcoin.secrets = {
|
||||||
|
lnd-wallet-password.user = cfg.user;
|
||||||
|
lnd-key.user = cfg.user;
|
||||||
|
lnd-cert.user = cfg.user;
|
||||||
|
lnd-cert.permissions = "444"; # world readable
|
||||||
|
};
|
||||||
|
# Advantages of manually pre-generating certs:
|
||||||
|
# - Reduces dynamic state
|
||||||
|
# - Enables deployment of a mesh of server plus client nodes with predefined certs
|
||||||
|
nix-bitcoin.generateSecretsCmds.lnd = ''
|
||||||
|
makePasswordSecret lnd-wallet-password
|
||||||
|
makeCert lnd '${nbLib.mkCertExtraAltNames cfg.certificate}'
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
cfg = config.services.lnd;
|
||||||
|
operatorName = config.nix-bitcoin.operator.name;
|
||||||
|
nbLib = config.nix-bitcoin.lib;
|
||||||
|
|
||||||
|
mkLndconnect = { name, isClightning ? false, enableOnion, onionService, port, certPath, authSecretPath }:
|
||||||
|
let
|
||||||
|
lnd = config.services.lnd;
|
||||||
|
getOnionAddress = "cat ${config.nix-bitcoin.secretsDir}/onion-address-${onionService} 2>/dev/null || echo ${onionService}.onion";
|
||||||
|
in pkgs.writeScriptBin name ''
|
||||||
|
#!${pkgs.bash}/bin/bash
|
||||||
|
set -e
|
||||||
|
certPath="${certPath}"
|
||||||
|
authSecretPath="${authSecretPath}"
|
||||||
|
if [ "${toString enableOnion}" = "1" ]; then
|
||||||
|
host=$(cat /var/lib/tor/onion/${onionService}/hostname 2>/dev/null || echo "${onionService}.onion")
|
||||||
|
port="${toString port}"
|
||||||
|
else
|
||||||
|
host="${nbLib.address lnd.restAddress}"
|
||||||
|
port="${toString lnd.restPort}"
|
||||||
|
fi
|
||||||
|
# lndconnect is provided by pkgs.lndconnect
|
||||||
|
${getExe pkgs.lndconnect} --host="$host" --port="$port" --cert="$certPath" --macaroon="$authSecretPath" "$@"
|
||||||
|
'';
|
||||||
|
in {
|
||||||
|
options.services.lnd.lndconnect = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable lndconnect for LND";
|
||||||
|
};
|
||||||
|
onion = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Expose lndconnect via Tor onion service";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = mkIf cfg.enable (mkMerge [
|
||||||
|
(mkIf cfg.lndconnect.enable {
|
||||||
|
environment.systemPackages = [
|
||||||
|
(mkLndconnect {
|
||||||
|
name = "lndconnect";
|
||||||
|
enableOnion = cfg.lndconnect.onion;
|
||||||
|
onionService = "${operatorName}/lnd";
|
||||||
|
port = cfg.restPort;
|
||||||
|
certPath = cfg.certPath;
|
||||||
|
authSecretPath = "${cfg.networkDir}/admin.macaroon";
|
||||||
|
})
|
||||||
|
];
|
||||||
|
})
|
||||||
|
(mkIf (cfg.lndconnect.enable && cfg.lndconnect.onion) {
|
||||||
|
services.tor.relay.onionServices.lnd = nbLib.mkOnionService {
|
||||||
|
port = cfg.restPort;
|
||||||
|
target = { addr = nbLib.address cfg.restAddress; port = cfg.restPort; };
|
||||||
|
};
|
||||||
|
nix-bitcoin.onionAddresses.access.${operatorName} = [ "lnd" ];
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
options.services = {
|
||||||
|
mempool = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Enable Mempool, a fully featured Bitcoin visualizer, explorer, and API service.
|
||||||
|
|
||||||
|
Note: Mempool enables `txindex` in bitcoind (this is a requirement).
|
||||||
|
|
||||||
|
This module has two components:
|
||||||
|
- A backend service (systemd service `mempool`)
|
||||||
|
|
||||||
|
- An optional web interface run by nginx, defined by options `services.mempool.frontend.*`.
|
||||||
|
The frontend is enabled by default when mempool is enabled.
|
||||||
|
For details, see `services.mempool.frontend.enable`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
frontend = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = cfg.enable;
|
||||||
|
description = ''
|
||||||
|
Enable the mempool frontend (web interface).
|
||||||
|
This starts a simple nginx instance, configured for local usage with
|
||||||
|
settings similar to the `mempool/frontend` Docker image.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
If you want to expose the mempool frontend to the internet, you
|
||||||
|
should create a custom nginx config that includes TLS, backend caching, rate limiting
|
||||||
|
and performance tuning.
|
||||||
|
For this task, reuse the config snippets from option `services.mempool.frontend.nginxConfig`.
|
||||||
|
See also: https://github.com/fort-nix/nixbitcoin.org/blob/master/website/mempool.nix,
|
||||||
|
which contains a mempool nginx config for public hosting (running at
|
||||||
|
https://mempool.nixbitcoin.org).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
address = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "HTTP server address.";
|
||||||
|
};
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 60845; # A random private port
|
||||||
|
description = "HTTP server port.";
|
||||||
|
};
|
||||||
|
settings = mkOption {
|
||||||
|
type = with types; attrsOf anything;
|
||||||
|
default = {};
|
||||||
|
example = {
|
||||||
|
TESTNET_ENABLED = true;
|
||||||
|
MEMPOOL_WEBSITE_URL = "mempool.mynode.org";
|
||||||
|
};
|
||||||
|
description = ''
|
||||||
|
Mempool frontend settings.
|
||||||
|
See here for available options:
|
||||||
|
https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
|
||||||
|
(`interface Env` and `defaultEnv`)
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
staticContentRoot = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-frontend.withConfig cfg.frontend.settings;
|
||||||
|
defaultText = "mempoolPkgs.mempool-frontend";
|
||||||
|
description = "
|
||||||
|
Path of the static frontend content root.
|
||||||
|
";
|
||||||
|
};
|
||||||
|
nginxConfig = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = frontend.nginxConfig;
|
||||||
|
defaultText = "(See source)";
|
||||||
|
description = "
|
||||||
|
An attrset of nginx config snippets for assembling a custom
|
||||||
|
mempool nginx config.
|
||||||
|
For details, see the source comments at the point of definition.
|
||||||
|
";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
address = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Mempool backend address.";
|
||||||
|
};
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 8999;
|
||||||
|
description = "Mempool backend port.";
|
||||||
|
};
|
||||||
|
electrumServer = mkOption {
|
||||||
|
type = types.enum [ "electrs" ];
|
||||||
|
default = "electrs";
|
||||||
|
description = ''
|
||||||
|
The Electrum server to use for fetching address information.
|
||||||
|
|
||||||
|
Possible options:
|
||||||
|
- electrs:
|
||||||
|
Small database size, slow when querying new addresses.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
settings = mkOption {
|
||||||
|
type = with types; attrsOf (attrsOf anything);
|
||||||
|
example = {
|
||||||
|
MEMPOOL = {
|
||||||
|
POLL_RATE_MS = 3000;
|
||||||
|
STDOUT_LOG_MIN_PRIORITY = "debug";
|
||||||
|
};
|
||||||
|
PRICE_DATA_SERVER = {
|
||||||
|
CLEARNET_URL = "https://myserver.org/prices";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
description = ''
|
||||||
|
Mempool backend settings.
|
||||||
|
See here for available options:
|
||||||
|
https://github.com/mempool/mempool/blob/master/backend/src/config.ts
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
database = {
|
||||||
|
name = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "mempool";
|
||||||
|
description = "Database name.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-backend;
|
||||||
|
defaultText = "mempoolPkgs.mempool-backend";
|
||||||
|
description = "The package providing mempool binaries.";
|
||||||
|
};
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "mempool";
|
||||||
|
description = "The user as which to run Mempool.";
|
||||||
|
};
|
||||||
|
group = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = cfg.user;
|
||||||
|
description = "The group as which to run Mempool.";
|
||||||
|
};
|
||||||
|
tor = nbLib.tor;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Internal read-only options used by `./nodeinfo.nix` and `./onion-services.nix`
|
||||||
|
mempool-frontend = let
|
||||||
|
inherit (nbLib) mkAlias;
|
||||||
|
in {
|
||||||
|
enable = mkAlias cfg.frontend.enable;
|
||||||
|
address = mkAlias cfg.frontend.address;
|
||||||
|
port = mkAlias cfg.frontend.port;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg = config.services.mempool;
|
||||||
|
nbLib = config.nix-bitcoin.lib;
|
||||||
|
nbPkgs = pkgs; # vendored: now alias to pkgs
|
||||||
|
secretsDir = config.nix-bitcoin.secretsDir;
|
||||||
|
|
||||||
|
configFile = builtins.toFile "mempool-config" (builtins.toJSON cfg.settings);
|
||||||
|
cacheDir = "/var/cache/mempool";
|
||||||
|
|
||||||
|
inherit (config.services)
|
||||||
|
bitcoind
|
||||||
|
electrs;
|
||||||
|
|
||||||
|
torSocket = config.services.tor.client.socksListenAddress;
|
||||||
|
# Vendored mempool package
|
||||||
|
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
|
||||||
|
mempoolPkgs = pkgs.callPackage ../../packages/mempool { inherit fetchNodeModules; };
|
||||||
|
|
||||||
|
# See the `services.nginx` definition further below
|
||||||
|
# on how to use these snippets.
|
||||||
|
frontend.nginxConfig = {
|
||||||
|
# This must be added to `services.nginx.commonHttpConfig` when
|
||||||
|
# `mempool/location-static.conf` is used
|
||||||
|
httpConfig = ''
|
||||||
|
include ${mempoolPkgs.mempool-nginx-conf}/http-language.conf;
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Config for static website content.
|
||||||
|
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
|
||||||
|
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-redirects.conf
|
||||||
|
staticContent = ''
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
add_header Cache-Control "public, no-transform";
|
||||||
|
add_header Vary Accept-Language;
|
||||||
|
add_header Vary Cookie;
|
||||||
|
|
||||||
|
include ${mempoolPkgs.mempool-nginx-conf}/location-static.conf;
|
||||||
|
|
||||||
|
# Redirect /api to /docs/api
|
||||||
|
location = /api {
|
||||||
|
return 308 https://$host/docs/api;
|
||||||
|
}
|
||||||
|
location = /api/ {
|
||||||
|
return 308 https://$host/docs/api;
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Config for backend API.
|
||||||
|
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
|
||||||
|
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-api.conf.
|
||||||
|
proxyApi = let
|
||||||
|
backend = "http://${nbLib.addressWithPort cfg.address cfg.port}";
|
||||||
|
in ''
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass ${backend}/api/v1/;
|
||||||
|
}
|
||||||
|
location /api/v1 {
|
||||||
|
proxy_pass ${backend};
|
||||||
|
}
|
||||||
|
# Websocket API
|
||||||
|
location /api/v1/ws {
|
||||||
|
proxy_pass ${backend};
|
||||||
|
|
||||||
|
# Websocket header settings
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
|
||||||
|
# Relevant settings from `recommendedProxyConfig` (nixos/nginx/default.nix)
|
||||||
|
# (In the above api locations, these are inherited from the parent scope)
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
in {
|
||||||
|
inherit options;
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
services.bitcoind.txindex = true;
|
||||||
|
services.electrs.enable = true;
|
||||||
|
services.mysql = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.mariadb;
|
||||||
|
ensureDatabases = [ cfg.database.name ];
|
||||||
|
ensureUsers = [
|
||||||
|
{
|
||||||
|
name = cfg.user;
|
||||||
|
ensurePermissions."${cfg.database.name}.*" = "ALL PRIVILEGES";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Available options:
|
||||||
|
# https://github.com/mempool/mempool/blob/master/backend/src/config.ts
|
||||||
|
services.mempool.settings = {
|
||||||
|
MEMPOOL = {
|
||||||
|
# mempool doesn't support regtest
|
||||||
|
NETWORK = "mainnet";
|
||||||
|
BACKEND = "electrum";
|
||||||
|
HTTP_PORT = cfg.port;
|
||||||
|
CACHE_DIR = "${cacheDir}/cache";
|
||||||
|
STDOUT_LOG_MIN_PRIORITY = mkDefault "info";
|
||||||
|
AUTOMATIC_POOLS_UPDATE = true;
|
||||||
|
};
|
||||||
|
CORE_RPC = {
|
||||||
|
HOST = bitcoind.rpc.address;
|
||||||
|
PORT = bitcoind.rpc.port;
|
||||||
|
USERNAME = bitcoind.rpc.users.public.name;
|
||||||
|
PASSWORD = "@btcRpcPassword@";
|
||||||
|
};
|
||||||
|
ELECTRUM = let
|
||||||
|
server = config.services.${cfg.electrumServer};
|
||||||
|
in {
|
||||||
|
HOST = server.address;
|
||||||
|
PORT = server.port;
|
||||||
|
TLS_ENABLED = false;
|
||||||
|
};
|
||||||
|
DATABASE = {
|
||||||
|
ENABLED = true;
|
||||||
|
DATABASE = cfg.database.name;
|
||||||
|
SOCKET = "/run/mysqld/mysqld.sock";
|
||||||
|
PID_DIR = cacheDir;
|
||||||
|
};
|
||||||
|
} // optionalAttrs (cfg.tor.proxy) {
|
||||||
|
# Use Tor for rate fetching and pool updating
|
||||||
|
SOCKS5PROXY = {
|
||||||
|
ENABLED = true;
|
||||||
|
USE_ONION = true;
|
||||||
|
HOST = torSocket.addr;
|
||||||
|
PORT = torSocket.port;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.mempool = rec {
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
requires = [ "mysql.service" ];
|
||||||
|
wants = [ "${cfg.electrumServer}.service" ];
|
||||||
|
after = requires ++ wants;
|
||||||
|
preStart = ''
|
||||||
|
mkdir -p '${cacheDir}/cache'
|
||||||
|
<${configFile} sed \
|
||||||
|
-e "s|@btcRpcPassword@|$(cat ${secretsDir}/bitcoin-rpcpassword-public)|" \
|
||||||
|
> '${cacheDir}/config.json'
|
||||||
|
'';
|
||||||
|
environment.MEMPOOL_CONFIG_FILE = "${cacheDir}/config.json";
|
||||||
|
serviceConfig = nbLib.defaultHardening // {
|
||||||
|
ExecStart = "${cfg.package}/bin/mempool-backend";
|
||||||
|
CacheDirectory = "mempool";
|
||||||
|
CacheDirectoryMode = "770";
|
||||||
|
# Show "mempool" instead of "node" in the journal
|
||||||
|
SyslogIdentifier = "mempool";
|
||||||
|
User = cfg.user;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "10s";
|
||||||
|
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
||||||
|
// nbLib.nodejs;
|
||||||
|
};
|
||||||
|
|
||||||
|
services.nginx = mkIf cfg.frontend.enable {
|
||||||
|
enable = true;
|
||||||
|
enableReload = true;
|
||||||
|
recommendedBrotliSettings = true;
|
||||||
|
recommendedGzipSettings = true;
|
||||||
|
recommendedOptimisation = true;
|
||||||
|
recommendedProxySettings = true;
|
||||||
|
recommendedTlsSettings = true;
|
||||||
|
commonHttpConfig = frontend.nginxConfig.httpConfig;
|
||||||
|
virtualHosts."mempool" = {
|
||||||
|
serverName = "_";
|
||||||
|
listen = [ { addr = cfg.frontend.address; port = cfg.frontend.port; } ];
|
||||||
|
root = cfg.frontend.staticContentRoot;
|
||||||
|
extraConfig =
|
||||||
|
frontend.nginxConfig.staticContent +
|
||||||
|
frontend.nginxConfig.proxyApi;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
users.users.${cfg.user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
extraGroups = [ "bitcoinrpc-public" ];
|
||||||
|
};
|
||||||
|
users.groups.${cfg.group} = {};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
{
|
||||||
|
options = {
|
||||||
|
nix-bitcoin = {
|
||||||
|
# Kept for compatibility, now simply aliases system pkgs
|
||||||
|
pkgs = mkOption {
|
||||||
|
type = types.attrs;
|
||||||
|
default = pkgs;
|
||||||
|
defaultText = "pkgs";
|
||||||
|
description = "Alias to system pkgs (vendored nix-bitcoin now uses nixpkgs directly).";
|
||||||
|
};
|
||||||
|
|
||||||
|
useVersionLockedPkgs = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Deprecated — vendored modules always use system pkgs.";
|
||||||
|
};
|
||||||
|
|
||||||
|
pkgOverlays = mkOption {
|
||||||
|
internal = true;
|
||||||
|
type = with types; functionTo attrs;
|
||||||
|
default = _: _: {};
|
||||||
|
description = "Deprecated stub.";
|
||||||
|
};
|
||||||
|
|
||||||
|
lib = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = import ./lib.nix lib pkgs config;
|
||||||
|
defaultText = "vendor/nix-bitcoin/lib.nix";
|
||||||
|
};
|
||||||
|
|
||||||
|
torClientAddressWithPort = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = with config.services.tor.client.socksListenAddress;
|
||||||
|
"${addr}:${toString port}";
|
||||||
|
defaultText = "(See source)";
|
||||||
|
};
|
||||||
|
|
||||||
|
torify = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = pkgs.writers.writeBashBin "torify" ''
|
||||||
|
${pkgs.tor}/bin/torify \
|
||||||
|
--address ${config.services.tor.client.socksListenAddress.addr} \
|
||||||
|
"$@"
|
||||||
|
'';
|
||||||
|
defaultText = "(See source)";
|
||||||
|
};
|
||||||
|
|
||||||
|
runAsUserCmd = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = if config.security.doas.enable
|
||||||
|
then "doas -u"
|
||||||
|
else "sudo -u";
|
||||||
|
defaultText = "(See source)";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
options = {
|
||||||
|
nix-bitcoin.nodeinfo = {
|
||||||
|
enable = mkEnableOption "nodeinfo";
|
||||||
|
|
||||||
|
program = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = script;
|
||||||
|
defaultText = "(See source)";
|
||||||
|
};
|
||||||
|
|
||||||
|
services = mkOption {
|
||||||
|
internal = true;
|
||||||
|
type = types.attrs;
|
||||||
|
default = {};
|
||||||
|
defaultText = "(See source)";
|
||||||
|
description = ''
|
||||||
|
Nodeinfo service definitions.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
lib = mkOption {
|
||||||
|
internal = true;
|
||||||
|
readOnly = true;
|
||||||
|
default = nodeinfoLib;
|
||||||
|
defaultText = "(See source)";
|
||||||
|
description = ''
|
||||||
|
Helper functions for defining nodeinfo services.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg = config.nix-bitcoin.nodeinfo;
|
||||||
|
nbLib = config.nix-bitcoin.lib;
|
||||||
|
|
||||||
|
script = pkgs.writeScriptBin "nodeinfo" ''
|
||||||
|
#!${pkgs.python3}/bin/python
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
def success(*args):
|
||||||
|
return subprocess.call(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
|
||||||
|
|
||||||
|
def is_active(unit):
|
||||||
|
return success("systemctl", "is-active", "--quiet", unit)
|
||||||
|
|
||||||
|
def is_enabled(unit):
|
||||||
|
return success("systemctl", "is-enabled", "--quiet", unit)
|
||||||
|
|
||||||
|
def cmd(*args):
|
||||||
|
return subprocess.run(args, stdout=subprocess.PIPE).stdout.decode('utf-8')
|
||||||
|
|
||||||
|
def shell(*args):
|
||||||
|
return cmd("bash", "-c", *args).strip()
|
||||||
|
|
||||||
|
infos = OrderedDict()
|
||||||
|
operator = "${config.nix-bitcoin.operator.name}"
|
||||||
|
|
||||||
|
def get_onion_address(name, port):
|
||||||
|
path = f"/var/lib/onion-addresses/{operator}/{name}"
|
||||||
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
onion_address = f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
print(f"error reading file {path}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
return f"{onion_address}:{port}"
|
||||||
|
|
||||||
|
def add_service(service, make_info, systemd_service = None):
|
||||||
|
systemd_service = systemd_service or service
|
||||||
|
if not is_active(systemd_service):
|
||||||
|
infos[service] = f"'{systemd_service}.service' is not running"
|
||||||
|
else:
|
||||||
|
info = OrderedDict()
|
||||||
|
exec(make_info, globals(), locals())
|
||||||
|
infos[service] = info
|
||||||
|
|
||||||
|
if is_enabled("onion-addresses") and not is_active("onion-addresses"):
|
||||||
|
print("error: service 'onion-addresses' is not running")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
${concatStrings infos}
|
||||||
|
|
||||||
|
print(json.dumps(infos, indent=2))
|
||||||
|
'';
|
||||||
|
|
||||||
|
infos = map (serviceName:
|
||||||
|
let serviceCfg = config.services.${serviceName};
|
||||||
|
in optionalString serviceCfg.enable (cfg.services.${serviceName} serviceName serviceCfg)
|
||||||
|
) (builtins.attrNames cfg.services);
|
||||||
|
|
||||||
|
nodeinfoLib = rec {
|
||||||
|
mkInfo = extraCode: name: cfg:
|
||||||
|
mkInfoLong {
|
||||||
|
inherit extraCode name cfg;
|
||||||
|
};
|
||||||
|
|
||||||
|
mkInfoLong = { extraCode ? "", name, cfg, systemdServiceName ? name }: ''
|
||||||
|
add_service("${name}", """
|
||||||
|
info["local_address"] = "${nbLib.addressWithPort cfg.address cfg.port}"
|
||||||
|
'' + mkIfOnionPort name (onionPort: ''
|
||||||
|
info["onion_address"] = get_onion_address("${name}", ${onionPort})
|
||||||
|
'') + extraCode + ''
|
||||||
|
|
||||||
|
""", "${systemdServiceName}")
|
||||||
|
'';
|
||||||
|
|
||||||
|
mkIfOnionPort = name: fn:
|
||||||
|
if onionServices ? ${name} then
|
||||||
|
fn (toString (builtins.elemAt onionServices.${name}.map 0).port)
|
||||||
|
else
|
||||||
|
"";
|
||||||
|
};
|
||||||
|
|
||||||
|
inherit (config.services.tor.relay) onionServices;
|
||||||
|
in {
|
||||||
|
inherit options;
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
environment.systemPackages = [ script ];
|
||||||
|
|
||||||
|
nix-bitcoin.operator.enable = true;
|
||||||
|
|
||||||
|
nix-bitcoin.nodeinfo.services = with nodeinfoLib; {
|
||||||
|
bitcoind = mkInfo "";
|
||||||
|
lnd = name: cfg: mkInfo (''
|
||||||
|
info["rest_address"] = "${nbLib.addressWithPort cfg.restAddress cfg.restPort}"
|
||||||
|
'' + mkIfOnionPort "lnd-rest" (onionPort: ''
|
||||||
|
info["onion_rest_address"] = get_onion_address("lnd-rest", ${onionPort})
|
||||||
|
'') + ''
|
||||||
|
info["nodeid"] = shell("lncli getinfo | jq -r '.identity_pubkey'")
|
||||||
|
'') name cfg;
|
||||||
|
electrs = mkInfo "";
|
||||||
|
btcpayserver = mkInfo "";
|
||||||
|
rtl = mkInfo "";
|
||||||
|
mempool = mkInfo "";
|
||||||
|
mempool-frontend = name: cfg: mkInfoLong {
|
||||||
|
inherit name cfg;
|
||||||
|
systemdServiceName = "nginx";
|
||||||
|
};
|
||||||
|
# Only add sshd when it has an onion service
|
||||||
|
sshd = name: cfg: mkIfOnionPort "sshd" (onionPort: ''
|
||||||
|
add_service("sshd", """info["onion_address"] = get_onion_address("sshd", ${onionPort})""")
|
||||||
|
'');
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# This module enables unprivileged users to read onion addresses.
|
||||||
|
# By default, onion addresses in /var/lib/tor/onion are only readable by the
|
||||||
|
# tor user.
|
||||||
|
# The included service copies onion addresses to /var/lib/onion-addresses/<user>/
|
||||||
|
# and sets permissions according to option 'access'.
|
||||||
|
|
||||||
|
{ config, lib, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
options.nix-bitcoin.onionAddresses = {
|
||||||
|
access = mkOption {
|
||||||
|
type = with types; attrsOf (listOf str);
|
||||||
|
default = {};
|
||||||
|
description = ''
|
||||||
|
This option controls who is allowed to access onion addresses.
|
||||||
|
For example, the following allows user 'myuser' to access bitcoind
|
||||||
|
and clightning onion addresses:
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
"myuser" = [ "bitcoind" "clightning" ];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
The onion hostnames can then be read from
|
||||||
|
{file}`/var/lib/onion-addresses/myuser`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
services = mkOption {
|
||||||
|
type = with types; listOf str;
|
||||||
|
default = [];
|
||||||
|
description = ''
|
||||||
|
Services that can access their onion address via file
|
||||||
|
{file}`/var/lib/onion-addresses/<service>`
|
||||||
|
The file is readable only by the service user.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
dataDir = mkOption {
|
||||||
|
readOnly = true;
|
||||||
|
default = "/var/lib/onion-addresses";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg = config.nix-bitcoin.onionAddresses;
|
||||||
|
nbLib = config.nix-bitcoin.lib;
|
||||||
|
in {
|
||||||
|
inherit options;
|
||||||
|
|
||||||
|
config = mkIf (cfg.access != {} || cfg.services != []) {
|
||||||
|
systemd.services.onion-addresses = {
|
||||||
|
wantedBy = [ "tor.service" ];
|
||||||
|
bindsTo = [ "tor.service" ];
|
||||||
|
after = [ "tor.service" ];
|
||||||
|
serviceConfig = nbLib.defaultHardening // {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
StateDirectory = "onion-addresses";
|
||||||
|
StateDirectoryMode = "771";
|
||||||
|
PrivateNetwork = true; # This service needs no network access
|
||||||
|
PrivateUsers = false;
|
||||||
|
CapabilityBoundingSet = "CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER";
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
waitForFile() {
|
||||||
|
file=$1
|
||||||
|
for ((i=0; i<300; i++)); do
|
||||||
|
if [[ -e $file ]]; then
|
||||||
|
return;
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
echo "Error: File $file did not appear after 30 sec."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wait until tor is up
|
||||||
|
waitForFile /var/lib/tor/state
|
||||||
|
|
||||||
|
cd ${cfg.dataDir}
|
||||||
|
rm -rf ./*
|
||||||
|
|
||||||
|
${concatMapStrings
|
||||||
|
(user: ''
|
||||||
|
mkdir -p -m 0700 ${user}
|
||||||
|
chown ${user} ${user}
|
||||||
|
${concatMapStrings
|
||||||
|
(service: ''
|
||||||
|
onionFile='/var/lib/tor/onion/${service}/hostname'
|
||||||
|
waitForFile "$onionFile"
|
||||||
|
cp "$onionFile" '${user}/${service}'
|
||||||
|
chown '${user}' '${user}/${service}'
|
||||||
|
'')
|
||||||
|
cfg.access.${user}
|
||||||
|
}
|
||||||
|
'')
|
||||||
|
(builtins.attrNames cfg.access)
|
||||||
|
}
|
||||||
|
|
||||||
|
${concatMapStrings (service: ''
|
||||||
|
onionFile=/var/lib/tor/onion/${service}/hostname
|
||||||
|
waitForFile "$onionFile"
|
||||||
|
install -D -o ${config.systemd.services.${service}.serviceConfig.User} -m 400 "$onionFile" services/${service}
|
||||||
|
'') cfg.services}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# This module creates onion-services for NixOS services.
|
||||||
|
# An onion service can be enabled for every service that defines
|
||||||
|
# options 'address', 'port' and optionally 'getPublicAddressCmd'.
|
||||||
|
#
|
||||||
|
# See it in use at ./presets/enable-tor.nix
|
||||||
|
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
options.nix-bitcoin.onionServices = mkOption {
|
||||||
|
default = {};
|
||||||
|
type = with types; attrsOf (submodule (
|
||||||
|
{ config, ... }: {
|
||||||
|
options = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = config.public;
|
||||||
|
description = ''
|
||||||
|
Create an onion service for the given service.
|
||||||
|
The service must define options {option}`address` and {option}`onionPort` (or `port`).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
public = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Make the onion address accessible to the service.
|
||||||
|
If enabled, the onion service is automatically enabled.
|
||||||
|
Only available for services that define option {option}`getPublicAddressCmd`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
externalPort = mkOption {
|
||||||
|
type = types.nullOr types.port;
|
||||||
|
default = null;
|
||||||
|
description = "Override the external port of the onion service.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg = config.nix-bitcoin.onionServices;
|
||||||
|
nbLib = config.nix-bitcoin.lib;
|
||||||
|
|
||||||
|
onionServices = builtins.attrNames cfg;
|
||||||
|
|
||||||
|
activeServices = builtins.filter (service:
|
||||||
|
config.services.${service}.enable && cfg.${service}.enable
|
||||||
|
) onionServices;
|
||||||
|
|
||||||
|
publicServices = builtins.filter (service: cfg.${service}.public) activeServices;
|
||||||
|
in {
|
||||||
|
inherit options;
|
||||||
|
|
||||||
|
config = mkMerge [
|
||||||
|
(mkIf (activeServices != []) {
|
||||||
|
# Define hidden services
|
||||||
|
services.tor = {
|
||||||
|
enable = true;
|
||||||
|
relay.onionServices = genAttrs activeServices (name:
|
||||||
|
let
|
||||||
|
service = config.services.${name};
|
||||||
|
inherit (cfg.${name}) externalPort;
|
||||||
|
in nbLib.mkOnionService {
|
||||||
|
port = if externalPort != null then externalPort else service.port;
|
||||||
|
target.port = service.onionPort or service.port;
|
||||||
|
target.addr = nbLib.address service.address;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
nix-bitcoin.onionAddresses = {
|
||||||
|
# Enable public services to access their own onion addresses
|
||||||
|
services = publicServices;
|
||||||
|
|
||||||
|
# Allow the operator user to access onion addresses for all active services
|
||||||
|
access.${config.nix-bitcoin.operator.name} = mkIf config.nix-bitcoin.operator.enable activeServices;
|
||||||
|
};
|
||||||
|
systemd.services = let
|
||||||
|
onionAddresses = [ "onion-addresses.service" ];
|
||||||
|
in genAttrs publicServices (service: {
|
||||||
|
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
||||||
|
# that propagates initial start failures but no restarts
|
||||||
|
wants = onionAddresses;
|
||||||
|
after = onionAddresses;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
# Set getPublicAddressCmd for public services
|
||||||
|
{
|
||||||
|
services = let
|
||||||
|
# publicServices' doesn't depend on config.services.*.enable,
|
||||||
|
# so we can use it to define config.services without causing infinite recursion
|
||||||
|
publicServices' = builtins.filter (service:
|
||||||
|
let srv = cfg.${service};
|
||||||
|
in srv.public && srv.enable
|
||||||
|
) onionServices;
|
||||||
|
in genAttrs publicServices' (service: {
|
||||||
|
getPublicAddressCmd = "cat ${config.nix-bitcoin.onionAddresses.dataDir}/services/${service}";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set sensible defaults for some services
|
||||||
|
{
|
||||||
|
nix-bitcoin.onionServices = {
|
||||||
|
btcpayserver = {
|
||||||
|
externalPort = 80;
|
||||||
|
};
|
||||||
|
rtl = {
|
||||||
|
externalPort = 80;
|
||||||
|
};
|
||||||
|
mempool-frontend = {
|
||||||
|
externalPort = 80;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
options.nix-bitcoin.operator = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Whether to define a user named `operator` for convenient interactive access
|
||||||
|
to nix-bitcoin features (like `bitcoin-cli`).
|
||||||
|
|
||||||
|
When using nix-bitcoin as part of a larger system config, it makes sense
|
||||||
|
to set your main system user as the operator, by setting option
|
||||||
|
`nix-bitcoin.operator.name = "MAIN_USER_NAME";`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
name = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "operator";
|
||||||
|
description = "Name of the operator user.";
|
||||||
|
};
|
||||||
|
groups = mkOption {
|
||||||
|
type = with types; listOf str;
|
||||||
|
default = [];
|
||||||
|
description = "Extra groups of the operatur user.";
|
||||||
|
};
|
||||||
|
allowRunAsUsers = mkOption {
|
||||||
|
type = with types; listOf str;
|
||||||
|
default = [];
|
||||||
|
description = "Users as which the operator is allowed to run commands.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg = config.nix-bitcoin.operator;
|
||||||
|
in {
|
||||||
|
inherit options;
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
users.users.${cfg.name} = {
|
||||||
|
isNormalUser = true;
|
||||||
|
extraGroups = [
|
||||||
|
"systemd-journal"
|
||||||
|
"proc" # Enable full /proc access and systemd-status
|
||||||
|
] ++ cfg.groups;
|
||||||
|
};
|
||||||
|
|
||||||
|
security = mkIf (cfg.allowRunAsUsers != []) {
|
||||||
|
# Use doas instead of sudo if enabled
|
||||||
|
doas.extraConfig = mkIf config.security.doas.enable ''
|
||||||
|
${lib.concatMapStrings (user: "permit nopass ${cfg.name} as ${user}\n") cfg.allowRunAsUsers}
|
||||||
|
'';
|
||||||
|
sudo.extraConfig = mkIf (!config.security.doas.enable) ''
|
||||||
|
${cfg.name} ALL=(${builtins.concatStringsSep "," cfg.allowRunAsUsers}) NOPASSWD: ALL
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
options.services.rtl = {
|
||||||
|
enable = mkEnableOption "RTL, a web interface for LND";
|
||||||
|
|
||||||
|
address = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Address to listen for HTTP connections.";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 3000;
|
||||||
|
description = "Port to listen for HTTP connections.";
|
||||||
|
};
|
||||||
|
|
||||||
|
dataDir = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
default = "/var/lib/rtl";
|
||||||
|
description = "The data directory for RTL.";
|
||||||
|
};
|
||||||
|
|
||||||
|
nightTheme = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable night theme by default.";
|
||||||
|
};
|
||||||
|
|
||||||
|
extraCurrency = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
example = "USD";
|
||||||
|
description = ''
|
||||||
|
Additional currency for displaying amounts.
|
||||||
|
When set, Tor is disabled for the RTL service to allow currency rate fetching.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes = {
|
||||||
|
lnd = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable LND node in RTL.";
|
||||||
|
};
|
||||||
|
loop = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable Lightning Loop integration (requires loopd).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
clightning = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable Core Lightning node in RTL (not supported in Sovran).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "rtl";
|
||||||
|
description = "The user as which to run RTL.";
|
||||||
|
};
|
||||||
|
|
||||||
|
group = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = cfg.user;
|
||||||
|
description = "The group as which to run RTL.";
|
||||||
|
};
|
||||||
|
|
||||||
|
tor = nbLib.tor;
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg = config.services.rtl;
|
||||||
|
nbLib = config.nix-bitcoin.lib;
|
||||||
|
secretsDir = config.nix-bitcoin.secretsDir;
|
||||||
|
|
||||||
|
# Vendored RTL package
|
||||||
|
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
|
||||||
|
rtlPackage = pkgs.callPackage ../../packages/rtl { inherit fetchNodeModules; };
|
||||||
|
|
||||||
|
runePath = "${cfg.dataDir}/CLN-Rune.env";
|
||||||
|
|
||||||
|
rtlConfig = {
|
||||||
|
multiPass = "@multiPass@";
|
||||||
|
port = cfg.port;
|
||||||
|
host = cfg.address;
|
||||||
|
defaultNodeIndex = 1;
|
||||||
|
dbDirectoryPath = cfg.dataDir;
|
||||||
|
SSO = {
|
||||||
|
rtlSSO = 0;
|
||||||
|
rtlCookiePath = "";
|
||||||
|
logoutRedirectLink = "";
|
||||||
|
};
|
||||||
|
nodes = optional cfg.nodes.lnd.enable ({
|
||||||
|
index = 1;
|
||||||
|
lnNode = "lnd";
|
||||||
|
lnImplementation = "LND";
|
||||||
|
authentication = {
|
||||||
|
macaroonPath = "${cfg.dataDir}/macaroons";
|
||||||
|
swapMacaroonPath = if cfg.nodes.lnd.loop then "${cfg.dataDir}/loop-macaroons" else "";
|
||||||
|
boltzMacaroonPath = "";
|
||||||
|
};
|
||||||
|
settings = {
|
||||||
|
userPersona = "OPERATOR";
|
||||||
|
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
|
||||||
|
themeColor = "PURPLE";
|
||||||
|
channelBackupPath = "${cfg.dataDir}/backup";
|
||||||
|
logLevel = "INFO";
|
||||||
|
lnServerUrl = "https://${lnd.restAddress}:${toString lnd.restPort}";
|
||||||
|
swapServerUrl = if cfg.nodes.lnd.loop then "https://127.0.0.1:8081" else "";
|
||||||
|
boltzServerUrl = "";
|
||||||
|
fiatConversion = cfg.extraCurrency != null;
|
||||||
|
unannouncedChannels = true;
|
||||||
|
} // optionalAttrs (cfg.extraCurrency != null) {
|
||||||
|
currencyUnit = cfg.extraCurrency;
|
||||||
|
};
|
||||||
|
}) ++ optional cfg.nodes.clightning.enable {
|
||||||
|
index = 2;
|
||||||
|
lnNode = "clightning";
|
||||||
|
lnImplementation = "CLN";
|
||||||
|
authentication = {
|
||||||
|
runePath = runePath;
|
||||||
|
};
|
||||||
|
settings = {
|
||||||
|
userPersona = "OPERATOR";
|
||||||
|
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
|
||||||
|
themeColor = "PURPLE";
|
||||||
|
logLevel = "INFO";
|
||||||
|
fiatConversion = cfg.extraCurrency != null;
|
||||||
|
} // optionalAttrs (cfg.extraCurrency != null) {
|
||||||
|
currencyUnit = cfg.extraCurrency;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
configFile = builtins.toFile "config" (builtins.toJSON rtlConfig);
|
||||||
|
|
||||||
|
inherit (config.services)
|
||||||
|
bitcoind
|
||||||
|
lnd;
|
||||||
|
|
||||||
|
lndLoopEnabled = cfg.nodes.lnd.enable && cfg.nodes.lnd.loop;
|
||||||
|
in {
|
||||||
|
inherit options;
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{ assertion = cfg.nodes.lnd.enable;
|
||||||
|
message = ''
|
||||||
|
RTL: At least one node must be enabled. Sovran supports LND only.
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
{ assertion = !cfg.nodes.clightning.enable;
|
||||||
|
message = ''
|
||||||
|
RTL: Core Lightning (clightning) is not supported in Sovran. Use LND instead.
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
services.lnd.enable = mkIf cfg.nodes.lnd.enable true;
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
services.rtl.tor.enforce = mkIf (cfg.extraCurrency != null) false;
|
||||||
|
|
||||||
|
systemd.services.rtl = rec {
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
wants = optional cfg.nodes.lnd.enable "lnd.service";
|
||||||
|
after = wants ++ [ "nix-bitcoin-secrets.target" ];
|
||||||
|
environment.RTL_CONFIG_PATH = cfg.dataDir;
|
||||||
|
environment.DB_DIRECTORY_PATH = cfg.dataDir;
|
||||||
|
serviceConfig = nbLib.defaultHardening // {
|
||||||
|
ExecStartPre = [
|
||||||
|
(nbLib.script "rtl-setup-config" ''
|
||||||
|
<${configFile} sed "s|@multiPass@|$(cat ${secretsDir}/rtl-password)|" \
|
||||||
|
> '${cfg.dataDir}/RTL-Config.json'
|
||||||
|
'')
|
||||||
|
]
|
||||||
|
++ optional cfg.nodes.lnd.enable
|
||||||
|
# The lnd admin macaroon is not readable by group `lnd`, so copy it
|
||||||
|
(nbLib.rootScript "rtl-copy-macaroon" ''
|
||||||
|
install --compare -m 640 -o ${cfg.user} -g ${cfg.group} -D ${lnd.networkDir}/admin.macaroon \
|
||||||
|
'${cfg.dataDir}/macaroons/admin.macaroon'
|
||||||
|
'');
|
||||||
|
ExecStart = "${rtlPackage}/bin/rtl";
|
||||||
|
# Show "rtl" instead of "node" in the journal
|
||||||
|
SyslogIdentifier = "rtl";
|
||||||
|
User = cfg.user;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "10s";
|
||||||
|
ReadWritePaths = [ cfg.dataDir ];
|
||||||
|
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
||||||
|
// nbLib.nodejs;
|
||||||
|
};
|
||||||
|
|
||||||
|
users.users.${cfg.user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
extraGroups = optional lndLoopEnabled lnd.group;
|
||||||
|
};
|
||||||
|
users.groups.${cfg.group} = {};
|
||||||
|
|
||||||
|
nix-bitcoin.secrets.rtl-password.user = cfg.user;
|
||||||
|
nix-bitcoin.generateSecretsCmds.rtl = ''
|
||||||
|
makePasswordSecret rtl-password
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
options.nix-bitcoin = {
|
||||||
|
secretsDir = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
default = "/etc/nix-bitcoin-secrets";
|
||||||
|
description = "Directory to store secrets";
|
||||||
|
};
|
||||||
|
|
||||||
|
setupSecrets = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Set permissions for existing secrets in {option}`nix-bitcoin.secretsDir`
|
||||||
|
before services are started.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
generateSecrets = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Automatically generate all required secrets before services are started.
|
||||||
|
Note: Make sure to create a backup of the generated secrets.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
generateSecretsCmds = mkOption {
|
||||||
|
type = types.attrsOf types.lines;
|
||||||
|
default = {};
|
||||||
|
description = ''
|
||||||
|
Bash expressions for generating secrets.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Currently, this is used only by ../deployment/nixops.nix
|
||||||
|
deployment.secretsDir = mkOption {
|
||||||
|
type = types.path;
|
||||||
|
description = ''
|
||||||
|
Directory of local secrets that are transferred to the nix-bitcoin node on deployment
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
secrets = mkOption {
|
||||||
|
default = {};
|
||||||
|
type = with types; attrsOf (submodule (
|
||||||
|
{ config, ... }: {
|
||||||
|
options = {
|
||||||
|
user = mkOption {
|
||||||
|
type = str;
|
||||||
|
default = "root";
|
||||||
|
};
|
||||||
|
group = mkOption {
|
||||||
|
type = str;
|
||||||
|
default = config.user;
|
||||||
|
};
|
||||||
|
permissions = mkOption {
|
||||||
|
type = str;
|
||||||
|
default = "440";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
secretsSetupMethod = mkOption {
|
||||||
|
type = with types; nullOr str;
|
||||||
|
default = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
generateSecretsScript = mkOption {
|
||||||
|
internal = true;
|
||||||
|
default = let
|
||||||
|
rpcauthSrc = pkgs.fetchurl {
|
||||||
|
url = "https://raw.githubusercontent.com/bitcoin/bitcoin/d6cde007db9d3e6ee93bd98a9bbfdce9bfa9b15b/share/rpcauth/rpcauth.py";
|
||||||
|
sha256 = "189mpplam6yzizssrgiyv70c9899ggh8cac76j4n7v0xqzfip07n";
|
||||||
|
};
|
||||||
|
rpcauth = pkgs.writers.writeBash "rpcauth" ''
|
||||||
|
exec ${pkgs.python3}/bin/python ${rpcauthSrc} "$@"
|
||||||
|
'';
|
||||||
|
# Writes secrets to PWD
|
||||||
|
in pkgs.writers.writeBash "generate-secrets" ''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export PATH=${lib.makeBinPath (with pkgs; [ coreutils gnugrep ])}
|
||||||
|
|
||||||
|
makePasswordSecret() {
|
||||||
|
# Passwords have alphabet {a-z, A-Z, 0-9} and ~119 bits of entropy
|
||||||
|
[[ -e $1 ]] || ${pkgs.pwgen}/bin/pwgen -s 20 1 > "$1"
|
||||||
|
}
|
||||||
|
makeBitcoinRPCPassword() {
|
||||||
|
user=$1
|
||||||
|
file=bitcoin-rpcpassword-$user
|
||||||
|
HMACfile=bitcoin-HMAC-$user
|
||||||
|
makePasswordSecret "$file"
|
||||||
|
if [[ $file -nt $HMACfile ]]; then
|
||||||
|
${rpcauth} $user $(cat "$file") | grep rpcauth | cut -d ':' -f 2 > "$HMACfile"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
makeCert() {
|
||||||
|
name=$1
|
||||||
|
# Add leading comma if not empty
|
||||||
|
extraAltNames=''${2:+,}''${2:-}
|
||||||
|
if [[ ! -e $name-key ]]; then
|
||||||
|
# Create new key and cert
|
||||||
|
doMakeCert "-newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout $name-key"
|
||||||
|
elif [[ ! -e $name-cert \
|
||||||
|
|| $(cat "$name-cert-alt-names" 2>/dev/null) != $extraAltNames ]]; then
|
||||||
|
# Create cert from existing key
|
||||||
|
doMakeCert "-key $name-key"
|
||||||
|
fi;
|
||||||
|
}
|
||||||
|
doMakeCert() {
|
||||||
|
# This fn uses global variables `name` and `extraAltNames`
|
||||||
|
keyOpts=$1
|
||||||
|
${pkgs.openssl}/bin/openssl req -x509 \
|
||||||
|
-sha256 -days 3650 $keyOpts -out "$name-cert" \
|
||||||
|
-subj "/CN=localhost/O=$name" \
|
||||||
|
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1$extraAltNames"
|
||||||
|
echo "$extraAltNames" > "$name-cert-alt-names"
|
||||||
|
}
|
||||||
|
|
||||||
|
umask u=rw,go=
|
||||||
|
${builtins.concatStringsSep "\n" (builtins.attrValues cfg.generateSecretsCmds)}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
cfg = config.nix-bitcoin;
|
||||||
|
in {
|
||||||
|
inherit options;
|
||||||
|
|
||||||
|
config = {
|
||||||
|
assertions = [
|
||||||
|
{ assertion = cfg.secretsSetupMethod != null;
|
||||||
|
message = ''
|
||||||
|
No secrets setup method has been defined.
|
||||||
|
To fix this, choose one of the following:
|
||||||
|
|
||||||
|
- Use one of the deployment methods in ${toString ./../deployment}
|
||||||
|
|
||||||
|
- Set `nix-bitcoin.generateSecrets = true` to automatically generate secrets
|
||||||
|
|
||||||
|
- Set `nix-bitcoin.secretsSetupMethod = "manual"` if you want to manually setup secrets
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
# This target is active when secrets have been setup successfully.
|
||||||
|
systemd.targets.nix-bitcoin-secrets = mkIf (cfg.secretsSetupMethod != "manual") {
|
||||||
|
# This ensures that the secrets target is always activated when switching
|
||||||
|
# configurations.
|
||||||
|
# In this way `switch-to-configuration` is guaranteed to show an error
|
||||||
|
# when activating the secrets target fails on deployment.
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
nix-bitcoin.setupSecrets = mkIf cfg.generateSecrets true;
|
||||||
|
|
||||||
|
nix-bitcoin.secretsSetupMethod = mkIf cfg.setupSecrets "setup-secrets";
|
||||||
|
|
||||||
|
# Operation of this service:
|
||||||
|
# - Set owner and permissions for all used secrets
|
||||||
|
# - Make all other secrets accessible to root only
|
||||||
|
# For all steps make sure that no secrets are copied to the nix store.
|
||||||
|
#
|
||||||
|
systemd.services.setup-secrets = mkIf cfg.setupSecrets {
|
||||||
|
requiredBy = [ "nix-bitcoin-secrets.target" ];
|
||||||
|
before = [ "nix-bitcoin-secrets.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
# Use the same sort order for globbing and sorting as in Nix attrsets.
|
||||||
|
# Required for `comm` below.
|
||||||
|
export LC_COLLATE=C
|
||||||
|
|
||||||
|
${optionalString cfg.generateSecrets ''
|
||||||
|
mkdir -p "${cfg.secretsDir}"
|
||||||
|
cd "${cfg.secretsDir}"
|
||||||
|
chown root: .
|
||||||
|
chmod 0700 .
|
||||||
|
${cfg.generateSecretsScript}
|
||||||
|
''}
|
||||||
|
|
||||||
|
setupSecret() {
|
||||||
|
file="$1"
|
||||||
|
user="$2"
|
||||||
|
group="$3"
|
||||||
|
permissions="$4"
|
||||||
|
if [[ ! -e $file ]]; then
|
||||||
|
echo "Error: Secret file '$file' is missing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chown "$user:$group" "$file"
|
||||||
|
chmod "$permissions" "$file"
|
||||||
|
processedFiles+=("$file")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir="${cfg.secretsDir}"
|
||||||
|
if [[ ! -e $dir ]]; then
|
||||||
|
echo "Error: Secrets dir '$dir' is missing"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chown root: "$dir"
|
||||||
|
cd "$dir"
|
||||||
|
|
||||||
|
processedFiles=()
|
||||||
|
${
|
||||||
|
concatStrings (mapAttrsToList (n: v: ''
|
||||||
|
setupSecret ${n} ${v.user} ${v.group} ${v.permissions}
|
||||||
|
'') cfg.secrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make all other files accessible to root only
|
||||||
|
unprocessedFiles=$(
|
||||||
|
comm -23 <(shopt -s nullglob; printf '%s\n' *) <(printf '%s\n' "''${processedFiles[@]}")
|
||||||
|
)
|
||||||
|
if [[ $unprocessedFiles ]]; then
|
||||||
|
IFS=$'\n'
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
chown root: $unprocessedFiles
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
chmod 0440 $unprocessedFiles
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Now make the secrets dir accessible to other users
|
||||||
|
chmod 0751 "$dir"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
{
|
||||||
|
options = {
|
||||||
|
nix-bitcoin.security.dbusHideProcessInformation = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Only allow users with group `proc` to retrieve systemd unit information like
|
||||||
|
cgroup paths (i.e. (sub)process command lines) via D-Bus.
|
||||||
|
|
||||||
|
This mitigates a systemd security issue where (sub)process command lines can
|
||||||
|
be retrieved by services even when their access to /proc is restricted
|
||||||
|
(via ProtectProc).
|
||||||
|
|
||||||
|
This option works by restricting the D-Bus method `GetUnitProcesses`, which
|
||||||
|
is also used internally by {command}`systemctl status`.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = mkIf config.nix-bitcoin.security.dbusHideProcessInformation {
|
||||||
|
users.groups.proc = {};
|
||||||
|
nix-bitcoin.operator.groups = [ "proc" ]; # Enable operator access to systemd-status
|
||||||
|
|
||||||
|
services.dbus.packages = lib.mkAfter [ # Apply at the end to override the default policy
|
||||||
|
(pkgs.writeTextDir "etc/dbus-1/system.d/dbus.conf" ''
|
||||||
|
<busconfig>
|
||||||
|
<policy context="default">
|
||||||
|
<deny
|
||||||
|
send_destination="org.freedesktop.systemd1"
|
||||||
|
send_interface="org.freedesktop.systemd1.Manager"
|
||||||
|
send_member="GetUnitProcesses"
|
||||||
|
/>
|
||||||
|
</policy>
|
||||||
|
<policy group="proc">
|
||||||
|
<allow
|
||||||
|
send_destination="org.freedesktop.systemd1"
|
||||||
|
send_interface="org.freedesktop.systemd1.Manager"
|
||||||
|
send_member="GetUnitProcesses"
|
||||||
|
/>
|
||||||
|
</policy>
|
||||||
|
</busconfig>
|
||||||
|
'')
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{ config, lib, ... }:
|
||||||
|
with lib;
|
||||||
|
let
|
||||||
|
options.nix-bitcoin.configVersion = mkOption {
|
||||||
|
type = with types; nullOr str;
|
||||||
|
default = null;
|
||||||
|
description = "Vendored stub — no version migration needed.";
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
inherit options;
|
||||||
|
config = {};
|
||||||
|
}
|
||||||
@@ -4,7 +4,12 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|||||||
|
|
||||||
services.bitcoind = {
|
services.bitcoind = {
|
||||||
enable = true;
|
enable = true;
|
||||||
package = pkgs.bitcoind-knots;
|
# Keep the normal loopback P2P socket available for local clients such as
|
||||||
|
# Bisq. Because `address` defaults to 127.0.0.1 this does not expose a
|
||||||
|
# clearnet or LAN listener. When the existing bitcoind onion service is
|
||||||
|
# enabled, this also creates its Tor-tagged loopback target on port 8334.
|
||||||
|
listen = true;
|
||||||
|
package = pkgs.bitcoind;
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||||
txindex = true;
|
txindex = true;
|
||||||
tor.proxy = true;
|
tor.proxy = true;
|
||||||
@@ -16,7 +21,13 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
nix-bitcoin.onionServices.bitcoind.enable = true;
|
nix-bitcoin.onionServices.bitcoind = {
|
||||||
|
enable = true;
|
||||||
|
# This is a locally vendored option namespace, not an upstream dependency.
|
||||||
|
# The onion listener remains available to peers that already know it;
|
||||||
|
# advertising its address through Bitcoin peer gossip is opt-in in the Hub.
|
||||||
|
public = config.sovran_systemsOS.features.bitcoin-tor-gossip;
|
||||||
|
};
|
||||||
nix-bitcoin.onionServices.electrs.enable = true;
|
nix-bitcoin.onionServices.electrs.enable = true;
|
||||||
nix-bitcoin.onionServices.rtl.enable = true;
|
nix-bitcoin.onionServices.rtl.enable = true;
|
||||||
|
|
||||||
@@ -68,6 +79,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|||||||
name = "free";
|
name = "free";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# vendored: now no-op (always uses nixpkgs)
|
||||||
nix-bitcoin.useVersionLockedPkgs = false;
|
nix-bitcoin.useVersionLockedPkgs = false;
|
||||||
|
|
||||||
systemd.services.bitcoind = {
|
systemd.services.bitcoind = {
|
||||||
|
|||||||
+109
-17
@@ -1,32 +1,124 @@
|
|||||||
{ 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" ];
|
||||||
|
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 once
|
||||||
|
public_ip = ""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
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
|
||||||
'';
|
'';
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
{
|
{
|
||||||
config = lib.mkMerge [
|
config = lib.mkMerge [
|
||||||
|
|
||||||
# nix-bitcoin is globally imported by the flake (nixosModules.Sovran_SystemsOS).
|
# Vendored nix-bitcoin is always imported via modules/vendor/nix-bitcoin/modules.nix.
|
||||||
# This default satisfies nix-bitcoin's generateSecrets assertion so that Desktop
|
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
||||||
# Only systems can evaluate without enabling any Bitcoin services.
|
# without enabling any Bitcoin services.
|
||||||
{
|
{
|
||||||
nix-bitcoin.generateSecrets = lib.mkDefault true;
|
nix-bitcoin.generateSecrets = lib.mkDefault true;
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
haven = lib.mkForce false;
|
haven = lib.mkForce false;
|
||||||
mempool = lib.mkForce false;
|
mempool = lib.mkForce false;
|
||||||
element-calling = lib.mkForce false;
|
element-calling = lib.mkForce false;
|
||||||
bitcoin-core = lib.mkForce false;
|
bitcoin-tor-gossip = lib.mkForce false;
|
||||||
"nwc-wallets" = lib.mkForce false;
|
"nwc-wallets" = lib.mkForce false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+9
-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) ──────────────────
|
||||||
@@ -103,14 +100,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
-26
@@ -3,6 +3,16 @@
|
|||||||
let
|
let
|
||||||
cfg = config.sovran_systemsOS;
|
cfg = config.sovran_systemsOS;
|
||||||
|
|
||||||
|
# Read the OS version from the repo-root VERSION file at eval time so the
|
||||||
|
# Hub always ships a real, baked-in version string instead of relying on
|
||||||
|
# a runtime file lookup (which returns "dev" when /etc/nixos/VERSION is
|
||||||
|
# missing, e.g. in a dev checkout — this is the "vdev" bug in the Hub UI).
|
||||||
|
versionFile = ../../VERSION;
|
||||||
|
sovranVersion =
|
||||||
|
if builtins.pathExists versionFile
|
||||||
|
then builtins.replaceStrings [ "v" "\n" "\r" ] [ "" "" "" ] (builtins.readFile versionFile)
|
||||||
|
else "1.0.0";
|
||||||
|
|
||||||
monitoredServices =
|
monitoredServices =
|
||||||
# ── Infrastructure — System Passwords (always present) ─────
|
# ── Infrastructure — System Passwords (always present) ─────
|
||||||
[
|
[
|
||||||
@@ -27,13 +37,10 @@ let
|
|||||||
{ label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above"; }
|
{ label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above"; }
|
||||||
]; }
|
]; }
|
||||||
]
|
]
|
||||||
# ── Bitcoin Base (node implementations) ────────────────────
|
# ── Bitcoin Base ────────────────────────────────────────────
|
||||||
++ lib.optionals cfg.services.bitcoin [
|
++ lib.optionals cfg.services.bitcoin [
|
||||||
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
|
||||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
{ label = "Tor Bitcoin P2P Address — Reachable only through Tor"; file = "/var/lib/tor/onion/bitcoind/hostname"; suffix = ":8333"; }
|
||||||
]; }
|
|
||||||
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
|
||||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
|
||||||
]; }
|
]; }
|
||||||
]
|
]
|
||||||
# ── Bitcoin Apps (services on top of the node) ─────────────
|
# ── Bitcoin Apps (services on top of the node) ─────────────
|
||||||
@@ -55,7 +62,6 @@ let
|
|||||||
]; }
|
]; }
|
||||||
{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||||
{ label = "QR Code"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; qronly = true; }
|
{ label = "QR Code"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; qronly = true; }
|
||||||
{ label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above"; }
|
|
||||||
]; }
|
]; }
|
||||||
{ name = "Sparrow Auto-Link"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
{ name = "Sparrow Auto-Link"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||||
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
||||||
@@ -116,19 +122,29 @@ let
|
|||||||
role = activeRole;
|
role = activeRole;
|
||||||
services = monitoredServices;
|
services = monitoredServices;
|
||||||
feature_manager = true;
|
feature_manager = true;
|
||||||
|
feature_states = {
|
||||||
|
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
|
||||||
|
};
|
||||||
|
sovran_version = sovranVersion;
|
||||||
});
|
});
|
||||||
|
|
||||||
generatedVersions = pkgs.writeText "sovran-hub-versions.json" (builtins.toJSON {
|
generatedVersions = pkgs.writeText "sovran-hub-versions.json" (builtins.toJSON {
|
||||||
"caddy.service" = if pkgs ? caddy then pkgs.caddy.version else "2.8.4";
|
"caddy.service" = if pkgs ? caddy then pkgs.caddy.version else "2.8.4";
|
||||||
"tor.service" = if pkgs ? tor then pkgs.tor.version else "0.4.8.12";
|
"tor.service" = if pkgs ? tor then pkgs.tor.version else "0.4.8.12";
|
||||||
"gnome-remote-desktop.service" = if pkgs ? gnome-remote-desktop then pkgs.gnome-remote-desktop.version else "46.0";
|
"gnome-remote-desktop.service" = if pkgs ? gnome-remote-desktop then pkgs.gnome-remote-desktop.version else "46.0";
|
||||||
"bitcoind.service" = if pkgs ? bitcoind-knots then pkgs.bitcoind-knots.version else (if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0");
|
"bitcoind.service" = if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0";
|
||||||
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
|
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
|
||||||
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
||||||
"rtl.service" = if pkgs ? clightning-rtl then pkgs.clightning-rtl.version else (if pkgs ? rtl then pkgs.rtl.version else "0.15.2");
|
# Keep the fallbacks aligned with the vendored packages used by the
|
||||||
"btcpayserver.service" = if pkgs ? btcpayserver then pkgs.btcpayserver.version else "2.0.0";
|
# service modules (RTL 0.15.8 and Mempool 3.2.1). The nixpkgs attrs are
|
||||||
|
# optional because these packages are built locally in this repository.
|
||||||
|
"rtl.service" = if pkgs ? clightning-rtl then pkgs.clightning-rtl.version else (if pkgs ? rtl then pkgs.rtl.version else "0.15.8");
|
||||||
|
# BTCPay Server is intentionally sourced from pkgs.stable by the service
|
||||||
|
# module. Read the configured package here rather than pkgs.btcpayserver
|
||||||
|
# (unstable), otherwise the Hub can advertise a version that is not running.
|
||||||
|
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
||||||
"albyhub.service" = if pkgs ? albyhub then pkgs.albyhub.version else "1.8.0";
|
"albyhub.service" = if pkgs ? albyhub then pkgs.albyhub.version else "1.8.0";
|
||||||
"mempool.service" = if pkgs ? mempool then pkgs.mempool.version else "3.0.0";
|
"mempool.service" = if pkgs ? mempool then pkgs.mempool.version else "3.2.1";
|
||||||
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
||||||
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
||||||
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
||||||
@@ -169,13 +185,14 @@ 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
|
||||||
@@ -224,20 +241,23 @@ let
|
|||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo ""
|
echo ""
|
||||||
echo "── Rebuilding system configuration ──────────────"
|
echo "── Rebuilding system configuration ──────────────"
|
||||||
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
# Stream output straight into $LOG (tee'd by the exec redirect above) so
|
||||||
|
# the Hub UI shows live progress. Capturing the output in a variable
|
||||||
|
# kept the log empty for the entire build+activation, which made long
|
||||||
|
# rebuilds can otherwise look like a hang.
|
||||||
|
nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||||
--option connect-timeout 10 \
|
--option connect-timeout 10 \
|
||||||
--option stalled-download-timeout 90 \
|
--option stalled-download-timeout 90 \
|
||||||
--option download-attempts 7 \
|
--option download-attempts 7 \
|
||||||
--option fallback true 2>&1)
|
--option fallback true
|
||||||
SWITCH_RC=$?
|
SWITCH_RC=$?
|
||||||
echo "$SWITCH_OUT"
|
|
||||||
if [ "$SWITCH_RC" -eq 0 ]; then
|
if [ "$SWITCH_RC" -eq 0 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo " ✓ Rebuild completed successfully"
|
echo " ✓ Rebuild completed successfully"
|
||||||
echo "══════════════════════════════════════════════════"
|
echo "══════════════════════════════════════════════════"
|
||||||
echo "SUCCESS" > "$STATUS"
|
echo "SUCCESS" > "$STATUS"
|
||||||
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then
|
elif grep -q "switchInhibitors\|Pre-switch checks failed" "$LOG"; then
|
||||||
echo ""
|
echo ""
|
||||||
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
||||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||||
@@ -262,17 +282,31 @@ let
|
|||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# ── Brave launcher wrapper: stable profile dir so Wayland app_id is
|
# ── Brave Origin launcher wrapper: a *persistent* per-user profile dir.
|
||||||
# deterministic and GNOME Shell can match the window to the .desktop
|
# It must NOT be wiped on exit: the Hub's logout marker cookie
|
||||||
# entry (fixes generic gear icon appearing in the dock).
|
# (hub_manual_logout) and the session cookie live in this profile.
|
||||||
|
# Launching from a fresh/ephemeral profile every time throws away the
|
||||||
|
# marker, so /auto-login would mint a new session and silently log the
|
||||||
|
# user straight back in after they signed out and reopened the window.
|
||||||
|
# A stable directory also keeps the Wayland app_id deterministic so
|
||||||
|
# GNOME Shell can match the window to the .desktop entry (dock icon).
|
||||||
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
|
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
|
||||||
export PATH="${lib.makeBinPath [ pkgs.brave pkgs.coreutils ]}:$PATH"
|
export PATH="${lib.makeBinPath [ pkgs.brave-origin pkgs.coreutils ]}:$PATH"
|
||||||
HUB_DATA="/tmp/sovran-hub-brave-$(id -u)"
|
# Per-user, persistent browser state. $XDG_STATE_HOME keeps it out of the
|
||||||
|
# way of backups and survives reboots and window close/reopen.
|
||||||
|
if [ -n "$XDG_STATE_HOME" ]; then
|
||||||
|
HUB_DATA="$XDG_STATE_HOME/sovran-hub-browser"
|
||||||
|
else
|
||||||
|
HUB_DATA="$HOME/.local/state/sovran-hub-browser"
|
||||||
|
fi
|
||||||
mkdir -p "$HUB_DATA"
|
mkdir -p "$HUB_DATA"
|
||||||
trap '[ -n "$HUB_DATA" ] && rm -rf "$HUB_DATA"' EXIT INT TERM
|
|
||||||
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
||||||
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
|
||||||
brave --app=http://localhost:8937/auto-login \
|
# With a persistent profile Brave Origin's one-time "Proceed with Origin
|
||||||
|
# for free on Linux" onboarding dialog only appears once; keep skipping it
|
||||||
|
# anyway so it can never block auto-login (Linux-only switch).
|
||||||
|
brave-origin --app=http://localhost:8937/auto-login \
|
||||||
|
--skip-origin-startup-dialog \
|
||||||
--class=sovran-hub \
|
--class=sovran-hub \
|
||||||
--user-data-dir="$HUB_DATA" \
|
--user-data-dir="$HUB_DATA" \
|
||||||
--password-store=basic \
|
--password-store=basic \
|
||||||
@@ -307,7 +341,8 @@ let
|
|||||||
|
|
||||||
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
|
sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
|
||||||
pname = "sovran-systemsos-hub-web";
|
pname = "sovran-systemsos-hub-web";
|
||||||
version = "1.0.0";
|
# Keep the package metadata in lockstep with the version shown in the Hub.
|
||||||
|
version = sovranVersion;
|
||||||
format = "other";
|
format = "other";
|
||||||
|
|
||||||
src = ../../app;
|
src = ../../app;
|
||||||
@@ -331,6 +366,7 @@ let
|
|||||||
|
|
||||||
cp ${generatedConfig} $out/lib/sovran-hub-web/config.json
|
cp ${generatedConfig} $out/lib/sovran-hub-web/config.json
|
||||||
cp ${generatedVersions} $out/lib/sovran-hub-web/versions.json
|
cp ${generatedVersions} $out/lib/sovran-hub-web/versions.json
|
||||||
|
printf '%s' "${sovranVersion}" > $out/lib/sovran-hub-web/VERSION
|
||||||
|
|
||||||
install -d $out/share/sovran-hub/icons
|
install -d $out/share/sovran-hub/icons
|
||||||
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
||||||
|
|||||||
@@ -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" ]; }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -111,7 +111,15 @@ in
|
|||||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||||
chmod 600 "$SECRET_FILE"
|
chmod 600 "$SECRET_FILE"
|
||||||
fi
|
fi
|
||||||
echo "root:$(cat "$SECRET_FILE")" | chpasswd
|
# If the file contains a scrypt hash (salt:hash), skip chpasswd — the
|
||||||
|
# password was already set via the Hub security reset endpoint and this
|
||||||
|
# service is only re-running as a manual recovery step.
|
||||||
|
CONTENT="$(cat "$SECRET_FILE")"
|
||||||
|
if echo "$CONTENT" | grep -qE '^[0-9a-f]{32}:[0-9a-f]{64,}$'; then
|
||||||
|
echo "root-password-setup: stored value is already hashed — skipping chpasswd" >&2
|
||||||
|
else
|
||||||
|
echo "root:$CONTENT" | chpasswd
|
||||||
|
fi
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
./nwc-wallets.nix
|
./nwc-wallets.nix
|
||||||
./element-calling.nix
|
./element-calling.nix
|
||||||
./mempool.nix
|
./mempool.nix
|
||||||
./bitcoin-core.nix
|
|
||||||
./rdp.nix
|
./rdp.nix
|
||||||
./sshd.nix
|
./sshd.nix
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -229,6 +229,30 @@ CREDS
|
|||||||
chmod 600 "$CREDS_FILE"
|
chmod 600 "$CREDS_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Provision a private, random Matrix administrator for the Hub's Admin
|
||||||
|
# API calls. This is deliberately separate from the visible bootstrap
|
||||||
|
# admin account: installations where @admin pre-dated Sovran do not have
|
||||||
|
# its password, but Hub user management must still work. Keeping the
|
||||||
|
# generated localpart in this root-only file makes the operation
|
||||||
|
# idempotent without exposing this service credential in the Hub UI.
|
||||||
|
HUB_ADMIN_CREDS="/var/lib/secrets/matrix-hub-admin"
|
||||||
|
if [ ! -s "$HUB_ADMIN_CREDS" ]; then
|
||||||
|
HUB_ADMIN_USER="sovran-hub-$(pwgen -sA0 20 1)"
|
||||||
|
HUB_ADMIN_PASS=$(pwgen -s 32 1)
|
||||||
|
if register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
|
||||||
|
-u "$HUB_ADMIN_USER" -p "$HUB_ADMIN_PASS" -a http://localhost:8008; then
|
||||||
|
(umask 077; cat > "$HUB_ADMIN_CREDS" <<CREDS
|
||||||
|
username=$HUB_ADMIN_USER
|
||||||
|
password=$HUB_ADMIN_PASS
|
||||||
|
CREDS
|
||||||
|
)
|
||||||
|
echo "Created private Matrix Hub service administrator."
|
||||||
|
else
|
||||||
|
echo "Failed to create the private Matrix Hub service administrator." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Always write individual credential files for the hub UI, even if the bulk
|
# Always write individual credential files for the hub UI, even if the bulk
|
||||||
# credentials file already existed from a prior run (umask 077 ensures mode 600).
|
# credentials file already existed from a prior run (umask 077 ensures mode 600).
|
||||||
# If passwords were not freshly generated above, parse them from the bulk file.
|
# If passwords were not freshly generated above, parse them from the bulk file.
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Vendored from fort-nix/nix-bitcoin commit 360e30fee.
|
||||||
|
# This local copy does not fetch or import nix-bitcoin.
|
||||||
|
# This is a modified version of
|
||||||
|
# https://github.com/NixOS/nixpkgs/pull/128749
|
||||||
|
|
||||||
|
{ lib, stdenvNoCC, makeWrapper, nodejs, cacert }:
|
||||||
|
|
||||||
|
{ src
|
||||||
|
, hash ? ""
|
||||||
|
, runScripts ? false
|
||||||
|
, preferLocalBuild ? true
|
||||||
|
, npmFlags ? ""
|
||||||
|
, ...
|
||||||
|
} @ args:
|
||||||
|
stdenvNoCC.mkDerivation ({
|
||||||
|
inherit src preferLocalBuild;
|
||||||
|
|
||||||
|
name = "${src.name}-node_modules";
|
||||||
|
nativeBuildInputs = [
|
||||||
|
makeWrapper
|
||||||
|
(if args ? nodejs then args.nodejs else nodejs)
|
||||||
|
];
|
||||||
|
|
||||||
|
outputHashMode = "recursive";
|
||||||
|
|
||||||
|
impureEnvVars = lib.fetchers.proxyImpureEnvVars;
|
||||||
|
|
||||||
|
phases = "unpackPhase patchPhase buildPhase installPhase";
|
||||||
|
|
||||||
|
# npm doesn't support var `SSL_CERT_FILE`.
|
||||||
|
NODE_EXTRA_CA_CERTS = "${cacert}/etc/ssl/certs/ca-bundle.crt";
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
|
||||||
|
if [[ ! -f package.json ]]; then
|
||||||
|
echo "Error: file `package.json` doesn't exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f package-lock.json ]]; then
|
||||||
|
echo "Error: file `package-lock.json` doesn't exist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export SOURCE_DATE_EPOCH=1
|
||||||
|
export npm_config_cache=/tmp
|
||||||
|
NPM_FLAGS="--omit=dev --omit=optional --no-update-notifier $npmFlags"
|
||||||
|
# Scripts may result in non-deterministic behavior.
|
||||||
|
# Some packages (e.g., Puppeteer) use postinstall scripts to download extra data.
|
||||||
|
if [[ ! $runScripts ]]; then
|
||||||
|
NPM_FLAGS+=" --ignore-scripts"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running npm ci $NPM_FLAGS"
|
||||||
|
npm ci $NPM_FLAGS
|
||||||
|
|
||||||
|
cp package.json \
|
||||||
|
package-lock.json node_modules/
|
||||||
|
rm -f node_modules/.package-lock.json
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
mkdir -p $out/lib
|
||||||
|
cp -r node_modules $out/lib
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
} // (
|
||||||
|
if hash == "" then {
|
||||||
|
outputHashAlgo = "sha256";
|
||||||
|
outputHash = "";
|
||||||
|
} else {
|
||||||
|
outputHash = hash;
|
||||||
|
}
|
||||||
|
) // (builtins.removeAttrs args [ "hash" ]))
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{ lib, buildGoModule, fetchFromGitHub }:
|
||||||
|
|
||||||
|
buildGoModule rec {
|
||||||
|
pname = "lndinit";
|
||||||
|
version = "0.1.3-beta";
|
||||||
|
src = fetchFromGitHub {
|
||||||
|
owner = "lightninglabs";
|
||||||
|
repo = pname;
|
||||||
|
rev = "v${version}";
|
||||||
|
sha256 = "sha256-sO1DpbppCurxr9g9nUl9Vx82FJK1mTcUw3rY1Fm1wEU=";
|
||||||
|
};
|
||||||
|
vendorHash = "sha256-El44BS5Bu0K/klMxkajciU/R6uqiXBMOiLN536QztbE=";
|
||||||
|
subPackages = [ "." ];
|
||||||
|
meta = with lib; {
|
||||||
|
description = "Wallet initializer for lnd (from nix-bitcoin)";
|
||||||
|
homepage = "https://github.com/lightninglabs/lndinit";
|
||||||
|
license = licenses.mit;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
From e4b3ebaf0451c1bddbd7dcf8527c296938ebb607 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Erik Arvstedt <erik.arvstedt@gmail.com>
|
||||||
|
Date: Sun, 1 Jun 2025 11:17:22 +0200
|
||||||
|
Subject: [PATCH] allow disabling mining pool fetching in offline environments
|
||||||
|
|
||||||
|
Previously, Mempool strictly required fetching mining pool data from
|
||||||
|
Github and failed when this was not possible, e.g. in offline
|
||||||
|
environments.
|
||||||
|
|
||||||
|
This patch allows disabling pool fetching.
|
||||||
|
When disabled, empty pool data is inserted into the DB, which
|
||||||
|
effectively turns off block pool classification.
|
||||||
|
---
|
||||||
|
backend/src/tasks/pools-updater.ts | 5 ++++-
|
||||||
|
1 file changed, 4 insertions(+), 1 deletion(-)
|
||||||
|
|
||||||
|
diff --git a/backend/src/tasks/pools-updater.ts b/backend/src/tasks/pools-updater.ts
|
||||||
|
index 6b0520dfc..a74259b95 100644
|
||||||
|
--- a/backend/src/tasks/pools-updater.ts
|
||||||
|
+++ b/backend/src/tasks/pools-updater.ts
|
||||||
|
@@ -75,7 +75,7 @@ class PoolsUpdater {
|
||||||
|
} else {
|
||||||
|
logger.warn(`pools-v2.json is outdated, fetching latest from ${this.poolsUrl} over ${network}`, this.tag);
|
||||||
|
}
|
||||||
|
- const poolsJson = await this.query(this.poolsUrl);
|
||||||
|
+ const poolsJson = (githubSha == "disable-pool-fetching") ? [] : await this.query(this.poolsUrl);
|
||||||
|
if (poolsJson === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
@@ -136,6 +136,9 @@ class PoolsUpdater {
|
||||||
|
* Fetch our latest pools-v2.json sha from github
|
||||||
|
*/
|
||||||
|
private async fetchPoolsSha(): Promise<string | null> {
|
||||||
|
+ if (this.poolsUrl == "disable-pool-fetching") {
|
||||||
|
+ return "disable-pool-fetching";
|
||||||
|
+ }
|
||||||
|
const response = await this.query(this.treeUrl);
|
||||||
|
|
||||||
|
if (response !== undefined) {
|
||||||
|
--
|
||||||
|
2.47.2
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
||||||
|
# This local copy does not fetch or import nix-bitcoin.
|
||||||
|
{ lib
|
||||||
|
, stdenvNoCC
|
||||||
|
, nodejs_22
|
||||||
|
, nodejs-slim_22
|
||||||
|
, fetchFromGitHub
|
||||||
|
, fetchNodeModules
|
||||||
|
, runCommand
|
||||||
|
, makeWrapper
|
||||||
|
, curl
|
||||||
|
, cacert
|
||||||
|
, rsync
|
||||||
|
# for rust-gbt (backend module)
|
||||||
|
, cargo
|
||||||
|
, rustc
|
||||||
|
, rustPlatform
|
||||||
|
, napi-rs-cli
|
||||||
|
}:
|
||||||
|
rec {
|
||||||
|
nodejs = nodejs_22;
|
||||||
|
nodejsRuntime = nodejs-slim_22;
|
||||||
|
|
||||||
|
version = "3.2.1";
|
||||||
|
|
||||||
|
src = fetchFromGitHub {
|
||||||
|
owner = "mempool";
|
||||||
|
repo = "mempool";
|
||||||
|
tag = "v${version}";
|
||||||
|
hash = "sha256-O2XPD1/BXQnzuOP/vMVyRfmFZEgjA85r+PShWne0vqU=";
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeModules = {
|
||||||
|
frontend = fetchNodeModules {
|
||||||
|
inherit src nodejs;
|
||||||
|
sourceRoot = "source/frontend";
|
||||||
|
hash = "sha256-+jfgsAkDdYvgso8uSHaBj/sQL3fC/ABQWzVTXfdZcU0=";
|
||||||
|
};
|
||||||
|
backend = fetchNodeModules {
|
||||||
|
inherit src nodejs;
|
||||||
|
sourceRoot = "source/backend";
|
||||||
|
hash = "sha256-y5l2SYZYK9SKSy6g0+mtTWD6JFkkdQHHBboECpEvWZ4=";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
frontendAssets = fetchFiles {
|
||||||
|
name = "mempool-frontend-assets";
|
||||||
|
hash = "sha256-r6GfOY8Pdh15o2OQMk8syfvWMV6WMCReToAEkQm7tqQ=";
|
||||||
|
fetcher = ./frontend-assets-fetch.sh;
|
||||||
|
};
|
||||||
|
|
||||||
|
mempool-backend = mkDerivationMempool {
|
||||||
|
pname = "mempool-backend";
|
||||||
|
|
||||||
|
patches = [ ./0001-allow-disabling-mining-pool-fetching.patch ];
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
cd backend
|
||||||
|
${sync} --chmod=+w ${nodeModules.backend}/lib/node_modules .
|
||||||
|
patchShebangs node_modules
|
||||||
|
|
||||||
|
${sync} ${mempool-rust-gbt}/ rust-gbt
|
||||||
|
npm run package
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out/lib/mempool-backend
|
||||||
|
${sync} package/ $out/lib/mempool-backend
|
||||||
|
|
||||||
|
makeWrapper ${nodejsRuntime}/bin/node $out/bin/mempool-backend \
|
||||||
|
--add-flags $out/lib/mempool-backend/index.js
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
passthru = {
|
||||||
|
inherit nodejs nodejsRuntime;
|
||||||
|
nodeModules = nodeModules.backend;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
mempool-frontend = mkFrontend {};
|
||||||
|
|
||||||
|
# Argument `config` (type: attrset) defines the mempool frontend config.
|
||||||
|
# If `{}`, the default config is used.
|
||||||
|
# See here for available options:
|
||||||
|
# https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
|
||||||
|
# (`interface Env` and `defaultEnv`)
|
||||||
|
mkFrontend = config: mkDerivationMempool {
|
||||||
|
pname = "mempool-frontend";
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
${sync} --chmod=+w ${nodeModules.frontend}/lib/node_modules .
|
||||||
|
patchShebangs node_modules
|
||||||
|
|
||||||
|
# sync-assets.js is called during `npm run build` and downloads assets from the
|
||||||
|
# internet. Disable this script and instead add the assets manually after building.
|
||||||
|
: > sync-assets.js
|
||||||
|
|
||||||
|
${lib.optionalString (config != {}) ''
|
||||||
|
ln -s ${builtins.toFile "mempool-frontend-config" (builtins.toJSON config)} mempool-frontend-config.json
|
||||||
|
''}
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Add assets that would otherwise be downloaded by sync-assets.js
|
||||||
|
${sync} ${frontendAssets}/ dist/mempool/browser/resources
|
||||||
|
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
${sync} dist/mempool/browser/ $out
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
passthru = {
|
||||||
|
withConfig = mkFrontend;
|
||||||
|
assets = frontendAssets;
|
||||||
|
nodeModules = nodeModules.frontend;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
mempool-rust-gbt = stdenvNoCC.mkDerivation rec {
|
||||||
|
pname = "mempool-rust-gbt";
|
||||||
|
inherit version src meta;
|
||||||
|
|
||||||
|
sourceRoot = "source/rust/gbt";
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
rustPlatform.cargoSetupHook
|
||||||
|
cargo
|
||||||
|
rustc
|
||||||
|
napi-rs-cli
|
||||||
|
];
|
||||||
|
|
||||||
|
cargoDeps = rustPlatform.fetchCargoVendor {
|
||||||
|
inherit src;
|
||||||
|
name = "${pname}-${version}";
|
||||||
|
inherit sourceRoot;
|
||||||
|
hash = "sha256-eox/K3ipjAqNyFt87lZnxaU/okQLF/KIhqXrX86n+qw=";
|
||||||
|
};
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
runHook preBuild
|
||||||
|
# napi doesn't accept an absolute path as dest dir, so we can't directly write to $out
|
||||||
|
napi build --platform --release --strip out
|
||||||
|
runHook postBuild
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
mv out $out
|
||||||
|
cp package.json $out
|
||||||
|
'';
|
||||||
|
|
||||||
|
passthru = { inherit cargoDeps; };
|
||||||
|
};
|
||||||
|
|
||||||
|
mempool-nginx-conf = runCommand "mempool-nginx-conf" {} ''
|
||||||
|
${sync} --chmod=u+w ${./nginx-conf}/ $out
|
||||||
|
${sync} ${src}/production/nginx/http-language.conf $out
|
||||||
|
'';
|
||||||
|
|
||||||
|
sync = "${rsync}/bin/rsync -a --inplace";
|
||||||
|
|
||||||
|
mkDerivationMempool = args: stdenvNoCC.mkDerivation ({
|
||||||
|
inherit version src meta;
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
makeWrapper
|
||||||
|
nodejs
|
||||||
|
rsync
|
||||||
|
];
|
||||||
|
|
||||||
|
phases = "unpackPhase patchPhase buildPhase installPhase";
|
||||||
|
} // args);
|
||||||
|
|
||||||
|
fetchFiles = { name, hash, fetcher }: stdenvNoCC.mkDerivation {
|
||||||
|
inherit name;
|
||||||
|
outputHashMode = "recursive";
|
||||||
|
outputHashAlgo = "sha256";
|
||||||
|
outputHash = hash;
|
||||||
|
nativeBuildInputs = [ curl cacert ];
|
||||||
|
buildCommand = ''
|
||||||
|
mkdir $out
|
||||||
|
cd $out
|
||||||
|
${builtins.readFile fetcher}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
meta = with lib; {
|
||||||
|
description = "Bitcoin blockchain and mempool explorer";
|
||||||
|
homepage = "https://github.com/mempool/mempool/";
|
||||||
|
license = licenses.agpl3Plus;
|
||||||
|
maintainers = with maintainers; [ erikarvstedt ];
|
||||||
|
platforms = platforms.unix;
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Fetch hash-locked versions of assets that are dynamically fetched via
|
||||||
|
# https://github.com/mempool/mempool/blob/master/frontend/sync-assets.js
|
||||||
|
# when running `npm run build` in the frontend.
|
||||||
|
#
|
||||||
|
# This file is updated by ./frontend-assets-update.sh
|
||||||
|
|
||||||
|
declare -A revs=(
|
||||||
|
["mempool/mining-pool-logos"]=53972ebbd08373cf4910cbb3e6421a1f3bba4563
|
||||||
|
)
|
||||||
|
|
||||||
|
fetchFile() {
|
||||||
|
repo=$1
|
||||||
|
file=$2
|
||||||
|
rev=${revs["$repo"]}
|
||||||
|
curl -fsS "https://raw.githubusercontent.com/$repo/$rev/$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRepo() {
|
||||||
|
repo=$1
|
||||||
|
rev=${revs["$repo"]}
|
||||||
|
curl -fsSL "https://github.com/$repo/archive/$rev.tar.gz"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir mining-pools
|
||||||
|
fetchRepo "mempool/mining-pool-logos" | tar xz --strip-components=1 -C mining-pools
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Settings adapted from
|
||||||
|
# https://github.com/mempool/mempool/blob/v3.2.1/production/nginx/server-common.conf
|
||||||
|
|
||||||
|
# see order of nginx location rules
|
||||||
|
# https://stackoverflow.com/questions/5238377/nginx-location-priority
|
||||||
|
|
||||||
|
# for exact / requests, redirect based on $lang
|
||||||
|
# cache redirect for 5 minutes
|
||||||
|
location = / {
|
||||||
|
if ($lang != '') {
|
||||||
|
return 302 $scheme://$host/$lang/;
|
||||||
|
}
|
||||||
|
try_files /en-US/index.html =404;
|
||||||
|
expires 5m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# cache /<lang>/main.f40e91d908a068a2.js forever since they never change
|
||||||
|
location ~ ^/([a-z][a-z])/(.+\..+\.(js|css))$ {
|
||||||
|
try_files $uri =404;
|
||||||
|
expires 1y;
|
||||||
|
}
|
||||||
|
# cache everything else for 5 minutes
|
||||||
|
location ~ ^/([a-z][a-z])$ {
|
||||||
|
try_files $uri /$1/index.html /en-US/index.html =404;
|
||||||
|
expires 5m;
|
||||||
|
}
|
||||||
|
location ~ ^/([a-z][a-z])/ {
|
||||||
|
try_files $uri /$1/index.html /en-US/index.html =404;
|
||||||
|
expires 5m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# cache /resources/** for 1 week since they don't change often
|
||||||
|
location /resources {
|
||||||
|
try_files $uri /en-US/index.html;
|
||||||
|
expires 1w;
|
||||||
|
}
|
||||||
|
# cache /main.f40e91d908a068a2.js forever since they never change
|
||||||
|
location ~* ^/.+\..+\.(js|css)$ {
|
||||||
|
try_files /$lang/$uri /en-US/$uri =404;
|
||||||
|
expires 1y;
|
||||||
|
}
|
||||||
|
# catch-all for all URLs i.e. /address/foo /tx/foo /block/000
|
||||||
|
# cache 5 minutes since they change frequently
|
||||||
|
location / {
|
||||||
|
try_files /$lang/$uri $uri /en-US/$uri /en-US/index.html =404;
|
||||||
|
expires 5m;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
||||||
|
# This local copy does not fetch or import nix-bitcoin.
|
||||||
|
{ lib
|
||||||
|
, stdenvNoCC
|
||||||
|
, nodejs_22
|
||||||
|
, nodejs-slim_22
|
||||||
|
, fetchNodeModules
|
||||||
|
, fetchurl
|
||||||
|
, makeWrapper
|
||||||
|
}:
|
||||||
|
let self = stdenvNoCC.mkDerivation {
|
||||||
|
pname = "rtl";
|
||||||
|
version = "0.15.8";
|
||||||
|
|
||||||
|
src = fetchurl {
|
||||||
|
url = "https://github.com/Ride-The-Lightning/RTL/archive/refs/tags/v${self.version}.tar.gz";
|
||||||
|
hash = "sha256-8XdGyORxB2dkZRB/Yl7zh+Quqo4L/Y0VmC6Brbr/hqU=";
|
||||||
|
};
|
||||||
|
|
||||||
|
passthru = {
|
||||||
|
nodejs = nodejs_22;
|
||||||
|
nodejsRuntime = nodejs-slim_22;
|
||||||
|
|
||||||
|
nodeModules = fetchNodeModules {
|
||||||
|
inherit (self) src nodejs;
|
||||||
|
# TODO-EXTERNAL: Remove `npmFlags` when no longer required
|
||||||
|
# See: https://github.com/Ride-The-Lightning/RTL/issues/1182
|
||||||
|
npmFlags = "--legacy-peer-deps";
|
||||||
|
hash = "sha256-oMqd6nLzS6iQ9w4z2yzpR2unA5qhOq5YdvfoS8IgYLY=";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
makeWrapper
|
||||||
|
];
|
||||||
|
|
||||||
|
phases = "unpackPhase patchPhase installPhase";
|
||||||
|
|
||||||
|
# `src` already contains the precompiled frontend and backend.
|
||||||
|
# Copy all files required for packaging, like in
|
||||||
|
# https://github.com/Ride-The-Lightning/RTL/blob/master/dockerfiles/Dockerfile
|
||||||
|
installPhase = ''
|
||||||
|
dest=$out/lib/node_modules/rtl
|
||||||
|
mkdir -p $dest
|
||||||
|
cp -r \
|
||||||
|
rtl.js \
|
||||||
|
package.json \
|
||||||
|
frontend \
|
||||||
|
backend \
|
||||||
|
${self.nodeModules}/lib/node_modules \
|
||||||
|
$dest
|
||||||
|
|
||||||
|
makeWrapper ${self.nodejsRuntime}/bin/node "$out/bin/rtl" \
|
||||||
|
--add-flags "$dest/rtl.js"
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = with lib; {
|
||||||
|
description = "A web interface for LND, c-lightning and Eclair";
|
||||||
|
homepage = "https://github.com/Ride-The-Lightning/RTL";
|
||||||
|
license = licenses.mit;
|
||||||
|
maintainers = with maintainers; [ nixbitcoin erikarvstedt ];
|
||||||
|
platforms = platforms.unix;
|
||||||
|
};
|
||||||
|
}; in self
|
||||||
+379
-119
@@ -20,6 +20,11 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Always operate from the repository root, regardless of the caller's cwd.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
@@ -29,11 +34,31 @@ CYAN='\033[0;36m'
|
|||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
GITEA_REMOTE="gitea"
|
GITEA_REMOTE_DEFAULT="gitea"
|
||||||
GITHUB_REMOTE="origin"
|
GITHUB_REMOTE_DEFAULT="origin"
|
||||||
GITEA_API_URL="https://git.sovransystems.com/api/v1"
|
GITEA_API_URL="https://git.sovransystems.com/api/v1"
|
||||||
CHANGELOG_FILE="CHANGELOG.md"
|
CHANGELOG_FILE="CHANGELOG.md"
|
||||||
|
|
||||||
|
# Auto-detect remotes
|
||||||
|
detect_remote() {
|
||||||
|
local preferred="$1"
|
||||||
|
local keyword="$2"
|
||||||
|
if git remote | grep -q -x "$preferred"; then
|
||||||
|
echo "$preferred"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local found
|
||||||
|
found=$(git remote -v | grep -i "$keyword" | head -n 1 | awk '{print $1}')
|
||||||
|
if [[ -n "$found" ]]; then
|
||||||
|
echo "$found"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "$preferred"
|
||||||
|
}
|
||||||
|
|
||||||
|
GITEA_REMOTE=$(detect_remote "$GITEA_REMOTE_DEFAULT" "sovransystems\|gitea")
|
||||||
|
GITHUB_REMOTE=$(detect_remote "$GITHUB_REMOTE_DEFAULT" "github")
|
||||||
|
|
||||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||||
echo -e "${BLUE}║ Sovran_SystemsOS Automated Stable Release Script ║${NC}"
|
echo -e "${BLUE}║ Sovran_SystemsOS Automated Stable Release Script ║${NC}"
|
||||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||||
@@ -42,8 +67,28 @@ echo
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Helper: Get latest tag
|
# Helper: Get latest tag
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# GitHub release tags are fetched into a private namespace instead of
|
||||||
|
# refs/tags/. GitHub and Gitea contain some same-named historical tags that
|
||||||
|
# point to different objects; sharing refs/tags/ would make either fetch fail
|
||||||
|
# with "would clobber existing tag".
|
||||||
|
GITHUB_TAG_NAMESPACE="refs/release-tags/github"
|
||||||
|
|
||||||
get_latest_tag() {
|
get_latest_tag() {
|
||||||
git tag --list 'v*' --sort=-version:refname | head -1 || echo ""
|
local exclude="${1:-}"
|
||||||
|
local tag
|
||||||
|
|
||||||
|
while IFS= read -r tag; do
|
||||||
|
if [[ -n "$exclude" && "$tag" == "v${exclude#v}" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$tag"
|
||||||
|
return 0
|
||||||
|
done < <(git for-each-ref \
|
||||||
|
--sort=-version:refname \
|
||||||
|
--format='%(refname:strip=3)' \
|
||||||
|
"${GITHUB_TAG_NAMESPACE}/v*")
|
||||||
|
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -65,10 +110,84 @@ suggest_next_version() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Step 0: Fetch everything
|
# Step 0: Preflight and fetch everything
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo -e "${BLUE}Step 0: Fetching latest from all remotes...${NC}"
|
echo -e "${BLUE}Step 0: Running release preflight...${NC}"
|
||||||
git fetch --all --tags --prune --force || git fetch --all --tags --prune --force 2>/dev/null || true
|
|
||||||
|
# A release must start from an exact, committed, reproducible tree. This also
|
||||||
|
# prevents release metadata generated below from being mixed with local work.
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
echo -e "${RED}Error: the working tree is not clean. Commit, stash, or remove local changes first.${NC}" >&2
|
||||||
|
git status --short >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for remote in "$GITHUB_REMOTE" "$GITEA_REMOTE"; do
|
||||||
|
if ! git remote | grep -q -x "$remote"; then
|
||||||
|
echo -e "${RED}Error: required remote '$remote' is not configured.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
for command_name in gh curl; do
|
||||||
|
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: required command '$command_name' is not installed.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if ! gh auth status >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: GitHub CLI is not authenticated. Run 'gh auth login' first.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||||
|
echo
|
||||||
|
read -rsp "Enter your Gitea API token (input hidden): " GITEA_TOKEN
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||||
|
echo -e "${RED}Error: a GITEA_TOKEN with write:repository scope is required.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e " Fetching GitHub (${GITHUB_REMOTE}) and Gitea (${GITEA_REMOTE})..."
|
||||||
|
# Never fetch either host's tags into refs/tags/. Historical tags with the same
|
||||||
|
# name differ between the hosts, and Git correctly refuses to clobber them.
|
||||||
|
git fetch "$GITHUB_REMOTE" --prune --no-tags
|
||||||
|
git fetch "$GITEA_REMOTE" --prune --no-tags
|
||||||
|
git fetch "$GITHUB_REMOTE" --prune --no-tags \
|
||||||
|
"+refs/tags/*:${GITHUB_TAG_NAMESPACE}/*"
|
||||||
|
|
||||||
|
GITHUB_MAIN_REF="refs/remotes/${GITHUB_REMOTE}/main"
|
||||||
|
GITEA_STAGING_REF="refs/remotes/${GITEA_REMOTE}/staging-dev"
|
||||||
|
|
||||||
|
if ! git rev-parse --verify "$GITHUB_MAIN_REF" >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: cannot resolve GitHub main at ${GITHUB_MAIN_REF}.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! git rev-parse --verify "$GITEA_STAGING_REF" >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: cannot resolve Gitea staging-dev at ${GITEA_STAGING_REF}.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
HEAD_COMMIT=$(git rev-parse HEAD)
|
||||||
|
GITHUB_MAIN_COMMIT=$(git rev-parse "$GITHUB_MAIN_REF")
|
||||||
|
GITEA_STAGING_COMMIT=$(git rev-parse "$GITEA_STAGING_REF")
|
||||||
|
|
||||||
|
if [[ "$GITHUB_MAIN_COMMIT" != "$GITEA_STAGING_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: GitHub main and Gitea staging-dev are not synchronized.${NC}" >&2
|
||||||
|
echo " GitHub main : $GITHUB_MAIN_COMMIT" >&2
|
||||||
|
echo " Gitea staging-dev : $GITEA_STAGING_COMMIT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$HEAD_COMMIT" != "$GITHUB_MAIN_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: local HEAD is not the synchronized release candidate.${NC}" >&2
|
||||||
|
echo " Local HEAD : $HEAD_COMMIT" >&2
|
||||||
|
echo " Remote HEAD : $GITHUB_MAIN_COMMIT" >&2
|
||||||
|
echo "Update/check out the synchronized commit, then run the script again." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} Clean tree; GitHub main and Gitea staging-dev match local HEAD"
|
||||||
|
|
||||||
LATEST_TAG=$(get_latest_tag)
|
LATEST_TAG=$(get_latest_tag)
|
||||||
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
|
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
|
||||||
@@ -92,6 +211,23 @@ fi
|
|||||||
VERSION="${VERSION#v}"
|
VERSION="${VERSION#v}"
|
||||||
TAG="v${VERSION}"
|
TAG="v${VERSION}"
|
||||||
|
|
||||||
|
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo -e "${RED}Error: version must use MAJOR.MINOR.PATCH format (for example, 1.1.1).${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
remote_has_tag() {
|
||||||
|
local remote="$1"
|
||||||
|
[[ -n "$(git ls-remote --tags "$remote" "refs/tags/${TAG}" "refs/tags/${TAG}^{}")" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
if git show-ref --verify --quiet "refs/tags/${TAG}" || \
|
||||||
|
git show-ref --verify --quiet "${GITHUB_TAG_NAMESPACE}/${TAG}" || \
|
||||||
|
remote_has_tag "$GITHUB_REMOTE" || remote_has_tag "$GITEA_REMOTE"; then
|
||||||
|
echo -e "${RED}Error: tag ${TAG} already exists locally or on a remote.${NC}" >&2
|
||||||
|
echo "Refusing to move or overwrite an existing release tag." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Get release message
|
# Get release message
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -110,7 +246,7 @@ done
|
|||||||
|
|
||||||
if [[ -z "$RELEASE_MESSAGE" ]]; then
|
if [[ -z "$RELEASE_MESSAGE" ]]; then
|
||||||
echo
|
echo
|
||||||
read -rp "Enter release message (or press Enter for default): " input_msg
|
read -rp "Enter release headline (or press Enter for default): " input_msg
|
||||||
if [[ -n "$input_msg" ]]; then
|
if [[ -n "$input_msg" ]]; then
|
||||||
RELEASE_MESSAGE="$input_msg"
|
RELEASE_MESSAGE="$input_msg"
|
||||||
else
|
else
|
||||||
@@ -118,6 +254,98 @@ if [[ -z "$RELEASE_MESSAGE" ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Helper: Generate categorized release notes from commit history
|
||||||
|
# Groups commits since the last tag into Keep-a-Changelog sections based on
|
||||||
|
# conventional-commit prefixes (feat/fix/docs/security/etc.) and keywords.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
generate_release_notes() {
|
||||||
|
local range="$1"
|
||||||
|
|
||||||
|
local added="" changed="" fixed="" security="" docs=""
|
||||||
|
|
||||||
|
while IFS= read -r subject; do
|
||||||
|
# Skip noise commits
|
||||||
|
case "$subject" in
|
||||||
|
"Initial plan"|"initial plan") continue ;;
|
||||||
|
"Merge pull request"*|"Merge branch"*) continue ;;
|
||||||
|
"chore: bump VERSION"*|"docs: update CHANGELOG"*) continue ;;
|
||||||
|
"Address code review"*|"Address review"*|"Address validation"*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Strip conventional-commit prefix for display
|
||||||
|
local clean
|
||||||
|
clean="$(echo "$subject" | sed -E 's/^(feat|fix|docs|chore|refactor|test|security|perf|style|ci|build)(\([^)]*\))?!?:[[:space:]]*//')"
|
||||||
|
# Capitalize first letter
|
||||||
|
clean="$(echo "${clean:0:1}" | tr '[:lower:]' '[:upper:]')${clean:1}"
|
||||||
|
|
||||||
|
case "$subject" in
|
||||||
|
security:*|security\(*) security+="- ${clean}"$'\n' ;;
|
||||||
|
feat:*|feat\(*) added+="- ${clean}"$'\n' ;;
|
||||||
|
fix:*|fix\(*|Fix\ *|fixed\ *) fixed+="- ${clean}"$'\n' ;;
|
||||||
|
docs:*|docs\(*) docs+="- ${clean}"$'\n' ;;
|
||||||
|
refactor:*|refactor\(*|chore:*|chore\(*|removed\ *|Updated\ *|updated\ *) changed+="- ${clean}"$'\n' ;;
|
||||||
|
test:*|test\(*) continue ;;
|
||||||
|
*) added+="- ${clean}"$'\n' ;;
|
||||||
|
esac
|
||||||
|
done < <(git log --no-merges --format='%s' "$range" 2>/dev/null | awk '!seen[$0]++')
|
||||||
|
|
||||||
|
local notes=""
|
||||||
|
if [[ -n "$added" ]]; then notes+=$'### Added\n'"$added"$'\n'; fi
|
||||||
|
if [[ -n "$changed" ]]; then notes+=$'### Changed\n'"$changed"$'\n'; fi
|
||||||
|
if [[ -n "$fixed" ]]; then notes+=$'### Fixed\n'"$fixed"$'\n'; fi
|
||||||
|
if [[ -n "$security" ]]; then notes+=$'### Security\n'"$security"$'\n'; fi
|
||||||
|
if [[ -n "$docs" ]]; then notes+=$'### Documentation\n'"$docs"$'\n'; fi
|
||||||
|
|
||||||
|
if [[ -z "$notes" ]]; then
|
||||||
|
notes=$'### Changed\n- Incremental stable updates\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$notes"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build notes from the previous canonical GitHub tag. Use its private ref so a
|
||||||
|
# conflicting local or Gitea tag with the same name cannot select the wrong
|
||||||
|
# commit.
|
||||||
|
PREV_TAG=$(get_latest_tag "$TAG")
|
||||||
|
PREV_TAG_REF="${GITHUB_TAG_NAMESPACE}/${PREV_TAG}"
|
||||||
|
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG_REF" >/dev/null; then
|
||||||
|
COMMIT_RANGE="${PREV_TAG_REF}..HEAD"
|
||||||
|
COMMIT_RANGE_DISPLAY="${PREV_TAG}..HEAD"
|
||||||
|
else
|
||||||
|
COMMIT_RANGE="HEAD"
|
||||||
|
COMMIT_RANGE_DISPLAY="HEAD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE_DISPLAY}...${NC}"
|
||||||
|
RELEASE_NOTES="$(generate_release_notes "$COMMIT_RANGE")"
|
||||||
|
|
||||||
|
# Let the user review/edit the generated notes before publishing
|
||||||
|
NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")"
|
||||||
|
trap 'rm -f "$NOTES_FILE"' EXIT
|
||||||
|
{
|
||||||
|
echo "## Sovran_SystemsOS ${TAG}"
|
||||||
|
echo
|
||||||
|
echo "${RELEASE_MESSAGE}"
|
||||||
|
echo
|
||||||
|
echo "$RELEASE_NOTES"
|
||||||
|
echo "**Full changelog:** [CHANGELOG.md](https://github.com/naturallaw777/Sovran_SystemsOS/blob/main/CHANGELOG.md)"
|
||||||
|
} > "$NOTES_FILE"
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} Draft notes written to: ${CYAN}${NOTES_FILE}${NC}"
|
||||||
|
echo
|
||||||
|
echo "──────────────── Draft Release Notes ────────────────"
|
||||||
|
cat "$NOTES_FILE"
|
||||||
|
echo "──────────────────────────────────────────────────────"
|
||||||
|
echo
|
||||||
|
read -rp "Edit the notes before publishing? (y/N): " edit_confirm
|
||||||
|
if [[ "$edit_confirm" =~ ^[Yy]$ ]]; then
|
||||||
|
"${EDITOR:-nano}" "$NOTES_FILE"
|
||||||
|
RELEASE_NOTES="$(sed -n '/^###/,$p' "$NOTES_FILE" | sed '/^\*\*Full changelog/d')"
|
||||||
|
fi
|
||||||
|
RELEASE_BODY="$(cat "$NOTES_FILE")"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}"
|
echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}"
|
||||||
echo -e "${YELLOW} Preparing Release${NC}"
|
echo -e "${YELLOW} Preparing Release${NC}"
|
||||||
@@ -135,60 +363,45 @@ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Step 1: Push main to stable
|
# Step 1: Prepare and commit all release metadata
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Step 1: Pushing main → stable on Gitea...${NC}"
|
echo -e "${BLUE}Step 1: Preparing release metadata for ${TAG}...${NC}"
|
||||||
git push "${GITEA_REMOTE}" main:stable --force-with-lease
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# VERSION drives ISO naming.
|
||||||
# Step 2: Create annotated tag
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
echo
|
|
||||||
echo -e "${BLUE}Step 2: Creating annotated tag ${TAG}...${NC}"
|
|
||||||
git tag -a "${TAG}" -m "${RELEASE_MESSAGE}
|
|
||||||
|
|
||||||
- Stable release of Sovran_SystemsOS
|
|
||||||
- See CHANGELOG.md for full details"
|
|
||||||
|
|
||||||
git push "${GITEA_REMOTE}" "${TAG}"
|
|
||||||
|
|
||||||
# Update VERSION file for ISO builds
|
|
||||||
echo "${VERSION}" > VERSION
|
echo "${VERSION}" > VERSION
|
||||||
git add VERSION
|
|
||||||
git commit -m "chore: bump VERSION to ${TAG} for ISO naming" || true
|
|
||||||
echo -e " ${GREEN}✓${NC} VERSION file updated to ${VERSION}"
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# Update every versioned ISO filename in README.md.
|
||||||
# Step 3: Auto-update CHANGELOG.md
|
README_FILE="README.md"
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
if [[ ! -f "$README_FILE" ]]; then
|
||||||
echo
|
echo -e "${RED}Error: ${README_FILE} not found.${NC}" >&2
|
||||||
echo -e "${BLUE}Step 3: Updating ${CHANGELOG_FILE}...${NC}"
|
exit 1
|
||||||
|
fi
|
||||||
|
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" \
|
||||||
|
| head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
||||||
|
if [[ -z "$OLD_ISO_VER" ]]; then
|
||||||
|
echo -e "${RED}Error: no versioned ISO reference found in ${README_FILE}.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$OLD_ISO_VER" != "$VERSION" ]]; then
|
||||||
|
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
|
||||||
|
"$README_FILE" > "$README_FILE.tmp"
|
||||||
|
mv "$README_FILE.tmp" "$README_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add the changelog entry before tagging so the tag and stable branch contain it.
|
||||||
TODAY=$(date +%Y-%m-%d)
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
# Create new changelog entry
|
|
||||||
NEW_ENTRY="## [${VERSION}] - ${TODAY}
|
NEW_ENTRY="## [${VERSION}] - ${TODAY}
|
||||||
|
|
||||||
### Added
|
${RELEASE_NOTES}
|
||||||
- (Add new features here)
|
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
|
||||||
|
|
||||||
### Changed
|
|
||||||
- (Add changes here)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- (Add bug fixes here)
|
|
||||||
|
|
||||||
[${VERSION}]: ${GITEA_API_URL%/*}/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
|
|
||||||
"
|
"
|
||||||
|
|
||||||
# Prepend to changelog (after the header)
|
if [[ ! -f "$CHANGELOG_FILE" ]]; then
|
||||||
if [ -f "$CHANGELOG_FILE" ]; then
|
echo -e "${RED}Error: ${CHANGELOG_FILE} not found.${NC}" >&2
|
||||||
# Backup
|
exit 1
|
||||||
cp "$CHANGELOG_FILE" "${CHANGELOG_FILE}.bak"
|
fi
|
||||||
|
awk -v new_entry="$NEW_ENTRY" '
|
||||||
# Insert new section after the first --- line
|
|
||||||
awk -v new_entry="$NEW_ENTRY" '
|
|
||||||
BEGIN { printed=0 }
|
BEGIN { printed=0 }
|
||||||
/^---$/ && !printed {
|
/^---$/ && !printed {
|
||||||
print
|
print
|
||||||
@@ -198,93 +411,134 @@ if [ -f "$CHANGELOG_FILE" ]; then
|
|||||||
next
|
next
|
||||||
}
|
}
|
||||||
{ print }
|
{ print }
|
||||||
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" && mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
END { if (!printed) exit 2 }
|
||||||
|
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" || {
|
||||||
|
rm -f "${CHANGELOG_FILE}.tmp"
|
||||||
|
echo -e "${RED}Error: could not find the changelog insertion marker.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
||||||
|
|
||||||
echo -e " ${GREEN}✓${NC} CHANGELOG.md updated with new section for ${TAG}"
|
git add VERSION "$README_FILE" "$CHANGELOG_FILE"
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} CHANGELOG.md not found — skipping"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Commit the changelog update
|
|
||||||
git add "$CHANGELOG_FILE"
|
|
||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
echo " (No changes to commit in changelog)"
|
echo -e "${RED}Error: release preparation produced no changes.${NC}" >&2
|
||||||
else
|
exit 1
|
||||||
git commit -m "docs: update CHANGELOG.md for ${TAG}"
|
|
||||||
echo -e " ${GREEN}✓${NC} Committed changelog update"
|
|
||||||
|
|
||||||
# Ask if user wants to push
|
|
||||||
echo
|
|
||||||
read -rp "Push the changelog commit to GitHub now? (y/N): " push_confirm
|
|
||||||
if [[ "$push_confirm" =~ ^[Yy]$ ]]; then
|
|
||||||
echo -e "${BLUE}Pushing changelog commit...${NC}"
|
|
||||||
git push "${GITHUB_REMOTE}" main
|
|
||||||
echo -e " ${GREEN}✓${NC} Changelog pushed to GitHub"
|
|
||||||
else
|
|
||||||
echo " (Changelog commit left local — remember to push later)"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
git commit -m "chore(release): prepare ${TAG}"
|
||||||
|
RELEASE_COMMIT=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
echo -e " ${GREEN}✓${NC} VERSION, README, and CHANGELOG committed"
|
||||||
|
echo -e " Release commit: ${CYAN}${RELEASE_COMMIT}${NC}"
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Step 4: Create GitHub Release via gh CLI
|
# Step 2: Publish the final release commit to every release branch
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Step 4: Creating GitHub Release...${NC}"
|
echo -e "${BLUE}Step 2: Publishing the final release commit...${NC}"
|
||||||
|
|
||||||
if command -v gh &>/dev/null; then
|
# GitHub main and Gitea staging-dev were verified equal during preflight, so
|
||||||
if gh release create "${TAG}" \
|
# these are normal fast-forward pushes. Stable is an intentional promotion and
|
||||||
|
# uses a lease to prevent overwriting a branch changed since the fetch.
|
||||||
|
git push "$GITHUB_REMOTE" HEAD:main
|
||||||
|
git push "$GITEA_REMOTE" HEAD:staging-dev
|
||||||
|
git push "$GITEA_REMOTE" HEAD:stable --force-with-lease
|
||||||
|
|
||||||
|
# Verify all three branch tips before creating an immutable release tag.
|
||||||
|
remote_branch_commit() {
|
||||||
|
local remote="$1"
|
||||||
|
local branch="$2"
|
||||||
|
git ls-remote "$remote" "refs/heads/${branch}" | awk 'NR == 1 { print $1 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
PUBLISHED_GITHUB=$(remote_branch_commit "$GITHUB_REMOTE" main)
|
||||||
|
PUBLISHED_STAGING=$(remote_branch_commit "$GITEA_REMOTE" staging-dev)
|
||||||
|
PUBLISHED_STABLE=$(remote_branch_commit "$GITEA_REMOTE" stable)
|
||||||
|
if [[ "$PUBLISHED_GITHUB" != "$RELEASE_COMMIT" || \
|
||||||
|
"$PUBLISHED_STAGING" != "$RELEASE_COMMIT" || \
|
||||||
|
"$PUBLISHED_STABLE" != "$RELEASE_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: post-push verification failed; no release tag was created.${NC}" >&2
|
||||||
|
echo " Expected : $RELEASE_COMMIT" >&2
|
||||||
|
echo " GitHub main : ${PUBLISHED_GITHUB:-missing}" >&2
|
||||||
|
echo " Gitea staging-dev : ${PUBLISHED_STAGING:-missing}" >&2
|
||||||
|
echo " Gitea stable : ${PUBLISHED_STABLE:-missing}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e " ${GREEN}✓${NC} GitHub main, Gitea staging-dev, and Gitea stable all match"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 3: Tag the final release commit and publish the tag
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 3: Creating annotated tag ${TAG} on ${RELEASE_COMMIT}...${NC}"
|
||||||
|
git tag -a "$TAG" -m "${RELEASE_MESSAGE}
|
||||||
|
|
||||||
|
- Stable release of Sovran_SystemsOS
|
||||||
|
- See CHANGELOG.md for full details" "$RELEASE_COMMIT"
|
||||||
|
|
||||||
|
git push "$GITHUB_REMOTE" "$TAG"
|
||||||
|
git push "$GITEA_REMOTE" "$TAG"
|
||||||
|
|
||||||
|
# For an annotated tag, ^{} resolves the commit referenced by the tag object.
|
||||||
|
remote_tag_commit() {
|
||||||
|
local remote="$1"
|
||||||
|
git ls-remote "$remote" "refs/tags/${TAG}^{}" | awk 'NR == 1 { print $1 }'
|
||||||
|
}
|
||||||
|
GITHUB_TAG_COMMIT=$(remote_tag_commit "$GITHUB_REMOTE")
|
||||||
|
GITEA_TAG_COMMIT=$(remote_tag_commit "$GITEA_REMOTE")
|
||||||
|
if [[ "$GITHUB_TAG_COMMIT" != "$RELEASE_COMMIT" || "$GITEA_TAG_COMMIT" != "$RELEASE_COMMIT" ]]; then
|
||||||
|
echo -e "${RED}Error: published tag verification failed.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e " ${GREEN}✓${NC} ${TAG} points to the final release commit on GitHub and Gitea"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 4: Create GitHub release via gh CLI
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 4: Creating GitHub release...${NC}"
|
||||||
|
|
||||||
|
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
||||||
|
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
||||||
|
gh release create "$TAG" \
|
||||||
--repo naturallaw777/Sovran_SystemsOS \
|
--repo naturallaw777/Sovran_SystemsOS \
|
||||||
--title "${TAG}" \
|
--title "${TAG} — ${title_suffix}" \
|
||||||
--notes "${RELEASE_MESSAGE}" \
|
--notes-file "$NOTES_FILE"
|
||||||
--target main 2>/dev/null; then
|
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
|
||||||
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} GitHub release may already exist or failed"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} gh CLI not found — skipping GitHub release"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Step 5: Create Gitea Release via API
|
# Step 5: Create Gitea release via API
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo
|
echo
|
||||||
echo -e "${BLUE}Step 5: Creating Gitea Release via API...${NC}"
|
echo -e "${BLUE}Step 5: Creating Gitea release via API...${NC}"
|
||||||
|
|
||||||
# ── Gitea Token Handling ─────────────────────────────────────────────────────
|
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
||||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
|
||||||
echo
|
# Build JSON safely because release notes may contain quotes and newlines.
|
||||||
echo -e "${YELLOW}GITEA_TOKEN is not set.${NC}"
|
if command -v jq >/dev/null 2>&1; then
|
||||||
read -rsp "Enter your Gitea API token (input will be hidden): " GITEA_TOKEN
|
PAYLOAD=$(jq -n \
|
||||||
echo
|
--arg tag "$TAG" \
|
||||||
if [[ -z "$GITEA_TOKEN" ]]; then
|
--arg name "$TAG — Stable Release" \
|
||||||
echo -e " ${YELLOW}⚠${NC} No token provided — skipping Gitea release"
|
--arg body "$RELEASE_BODY" \
|
||||||
GITEA_TOKEN=""
|
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||||
fi
|
else
|
||||||
|
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': sys.argv[1] + ' — Stable Release', 'body': open(sys.argv[2]).read(), 'draft': False, 'prerelease': False}))" "$TAG" "$NOTES_FILE")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
RESPONSE_FILE=$(mktemp "/tmp/gitea-release-${TAG}.XXXXXX.json")
|
||||||
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' -X POST \
|
||||||
|
|
||||||
RESPONSE=$(curl -s -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{
|
-d "$PAYLOAD" \
|
||||||
\"tag_name\": \"${TAG}\",
|
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases")
|
||||||
\"name\": \"${TAG}\",
|
|
||||||
\"body\": \"${RELEASE_MESSAGE}\",
|
|
||||||
\"draft\": false,
|
|
||||||
\"prerelease\": false
|
|
||||||
}" \
|
|
||||||
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
if echo "$RESPONSE" | grep -q '"id"'; then
|
if [[ "$HTTP_STATUS" != "201" ]]; then
|
||||||
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
echo -e "${RED}Error: Gitea release creation failed (HTTP ${HTTP_STATUS}).${NC}" >&2
|
||||||
else
|
cat "$RESPONSE_FILE" >&2
|
||||||
echo -e " ${YELLOW}⚠${NC} Gitea release creation failed or already exists"
|
rm -f "$RESPONSE_FILE"
|
||||||
echo " Response: $RESPONSE"
|
exit 1
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
rm -f "$RESPONSE_FILE"
|
||||||
|
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Final Summary
|
# Final Summary
|
||||||
@@ -295,9 +549,15 @@ echo -e "${GREEN}║ ✅ Release ${TAG} completed successfully!
|
|||||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||||
echo
|
echo
|
||||||
echo "Next manual steps (recommended):"
|
echo "Next manual steps (recommended):"
|
||||||
echo " • Review and enhance the new section in CHANGELOG.md"
|
echo " • Build the installer ISO:"
|
||||||
echo " • Push the changelog commit: git push origin main"
|
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
|
||||||
|
echo " • Package, verify, and upload ISO to CDN:"
|
||||||
|
echo " ./scripts/upload-cdn.sh --upload"
|
||||||
|
echo " • Verify the ISO download and checksum from the public CDN"
|
||||||
echo " • Verify releases on both GitHub and Gitea"
|
echo " • Verify releases on both GitHub and Gitea"
|
||||||
echo
|
echo
|
||||||
echo -e "${CYAN}Tag created: ${TAG}${NC}"
|
echo -e "${CYAN}Tag created: ${TAG}${NC}"
|
||||||
git show "${TAG}" --quiet
|
git show "${TAG}" --quiet
|
||||||
|
|
||||||
|
# Clean up temp notes file
|
||||||
|
rm -f "${NOTES_FILE}"
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# upload-cdn.sh
|
||||||
|
# Copies built ISO out of the nix store, generates versioned SHA-256 checksum,
|
||||||
|
# verifies it, and optionally uploads to CDN.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/upload-cdn.sh [--upload]
|
||||||
|
#
|
||||||
|
# EVERYTHING this script creates lives OUTSIDE the repository, even when you
|
||||||
|
# run it from inside the repo:
|
||||||
|
# - the nix build output symlink -> $ISO_OUT_DIR/result
|
||||||
|
# - the ISO and .sha256 files -> $ISO_OUT_DIR
|
||||||
|
# The repo working tree stays completely clean.
|
||||||
|
#
|
||||||
|
# Environment variables (optional):
|
||||||
|
# ISO_OUT_DIR - dir for the build symlink + ISO + checksum
|
||||||
|
# (default: ~/Sovran-builds)
|
||||||
|
# CDN_RSYNC_TARGET - rsync destination (e.g. user@server:/var/www/downloads/)
|
||||||
|
# CDN_RCLONE_REMOTE - rclone remote target (e.g. s3:my-bucket/downloads/)
|
||||||
|
# CDN_UPLOAD_CMD - custom upload command
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Always operate from the repo root, no matter where the script is invoked from
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
# Read version from VERSION file
|
||||||
|
if [ ! -f VERSION ]; then
|
||||||
|
echo -e "${RED}Error: VERSION file not found in $REPO_ROOT.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VERSION=$(cat VERSION | tr -d '\n\r ')
|
||||||
|
ISO_NAME="Sovran_SystemsOS-${VERSION}.iso"
|
||||||
|
SHA_NAME="${ISO_NAME}.sha256"
|
||||||
|
|
||||||
|
# Output directory OUTSIDE the repo (override with ISO_OUT_DIR)
|
||||||
|
OUT_DIR="${ISO_OUT_DIR:-$HOME/Sovran-builds}"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
OUT_DIR="$(cd "$OUT_DIR" && pwd)"
|
||||||
|
ISO_PATH="$OUT_DIR/${ISO_NAME}"
|
||||||
|
SHA_PATH="$OUT_DIR/${SHA_NAME}"
|
||||||
|
RESULT_LINK="$OUT_DIR/result"
|
||||||
|
|
||||||
|
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${BLUE}║ Sovran_SystemsOS CDN ISO Packaging & Upload Tool ║${NC}"
|
||||||
|
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo -e " Version : ${CYAN}${VERSION}${NC}"
|
||||||
|
echo -e " ISO : ${CYAN}${ISO_NAME}${NC}"
|
||||||
|
echo -e " Repo : ${CYAN}${REPO_ROOT}${NC} (left untouched)"
|
||||||
|
echo -e " Output : ${CYAN}${OUT_DIR}${NC} (outside the repo)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Locate or build ISO
|
||||||
|
SRC_ISO=""
|
||||||
|
if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
|
||||||
|
SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
|
||||||
|
elif [ -f "$RESULT_LINK/iso/Sovran_SystemsOS.iso" ]; then
|
||||||
|
SRC_ISO="$RESULT_LINK/iso/Sovran_SystemsOS.iso"
|
||||||
|
elif [ -f "result/iso/${ISO_NAME}" ]; then
|
||||||
|
SRC_ISO="result/iso/${ISO_NAME}" # legacy: from an older in-repo build
|
||||||
|
elif [ -f "result/iso/Sovran_SystemsOS.iso" ]; then
|
||||||
|
SRC_ISO="result/iso/Sovran_SystemsOS.iso"
|
||||||
|
else
|
||||||
|
FOUND=$(find "$RESULT_LINK" result -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||||
|
if [ -n "$FOUND" ]; then
|
||||||
|
SRC_ISO="$FOUND"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
|
||||||
|
echo -e "${YELLOW}Built ISO not found. Building via Nix (result link goes outside the repo)...${NC}"
|
||||||
|
rm -f "$RESULT_LINK"
|
||||||
|
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage \
|
||||||
|
--out-link "$RESULT_LINK"
|
||||||
|
|
||||||
|
if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
|
||||||
|
SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
|
||||||
|
else
|
||||||
|
FOUND=$(find "$RESULT_LINK" -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||||
|
if [ -n "$FOUND" ]; then
|
||||||
|
SRC_ISO="$FOUND"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
|
||||||
|
echo -e "${RED}Error: Failed to locate built ISO.${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}Step 1: Copying built ISO to output dir (outside repo)...${NC}"
|
||||||
|
cp -v "$SRC_ISO" "$ISO_PATH"
|
||||||
|
echo -e " ${GREEN}✓${NC} Copied to $ISO_PATH"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 2: Generating versioned SHA-256 checksum...${NC}"
|
||||||
|
sha256sum "$ISO_PATH" > "$SHA_PATH"
|
||||||
|
echo -e " ${GREEN}✓${NC} Generated $SHA_PATH"
|
||||||
|
cat "$SHA_PATH"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 3: Verifying checksum...${NC}"
|
||||||
|
sha256sum --check "$SHA_PATH"
|
||||||
|
echo -e " ${GREEN}✓${NC} Checksum verified successfully"
|
||||||
|
|
||||||
|
# Parse arguments for upload
|
||||||
|
DO_UPLOAD=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
|
--upload)
|
||||||
|
DO_UPLOAD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$DO_UPLOAD" -eq 1 ]; then
|
||||||
|
echo
|
||||||
|
echo -e "${BLUE}Step 4: Uploading to CDN...${NC}"
|
||||||
|
|
||||||
|
UPLOADED=0
|
||||||
|
if [ -n "${CDN_UPLOAD_CMD:-}" ]; then
|
||||||
|
echo -e " Running custom CDN_UPLOAD_CMD..."
|
||||||
|
eval "$CDN_UPLOAD_CMD"
|
||||||
|
UPLOADED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${CDN_RSYNC_TARGET:-}" ]; then
|
||||||
|
echo -e " Uploading via rsync to ${CDN_RSYNC_TARGET}..."
|
||||||
|
rsync -avP "$ISO_PATH" "$SHA_PATH" "${CDN_RSYNC_TARGET}"
|
||||||
|
UPLOADED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${CDN_RCLONE_REMOTE:-}" ]; then
|
||||||
|
echo -e " Uploading via rclone to ${CDN_RCLONE_REMOTE}..."
|
||||||
|
rclone copy "$ISO_PATH" "$SHA_PATH" "${CDN_RCLONE_REMOTE}"
|
||||||
|
UPLOADED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$UPLOADED" -eq 0 ]; then
|
||||||
|
echo -e " ${YELLOW}⚠ Warning: --upload requested, but no upload method specified.${NC}"
|
||||||
|
echo -e " Set CDN_RSYNC_TARGET, CDN_RCLONE_REMOTE, or CDN_UPLOAD_CMD."
|
||||||
|
else
|
||||||
|
echo -e " ${GREEN}✓${NC} Upload complete."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${GREEN}║ ✅ ISO packaging & verification complete! ║${NC}"
|
||||||
|
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo -e "Ready files (in ${CYAN}${OUT_DIR}${NC}, outside the repo):"
|
||||||
|
ls -lh "$ISO_PATH" "$SHA_PATH"
|
||||||
|
echo
|
||||||
|
echo "To upload to CDN, run:"
|
||||||
|
echo " ./scripts/upload-cdn.sh --upload"
|
||||||
|
echo "(configure CDN_RSYNC_TARGET, CDN_RCLONE_REMOTE, or CDN_UPLOAD_CMD)"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
{ nixpkgs, overlay-stable, system ? "x86_64-linux" }:
|
||||||
|
|
||||||
|
let
|
||||||
|
lib = nixpkgs.lib;
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
overlays = [ overlay-stable ];
|
||||||
|
};
|
||||||
|
|
||||||
|
normalize = s:
|
||||||
|
lib.replaceStrings [ "\n" "\\" " " ] [ " " "" " " ] s;
|
||||||
|
|
||||||
|
extractAfter = prefix: str:
|
||||||
|
let
|
||||||
|
match = builtins.match ".*${prefix} ([^ ]+).*" (normalize str);
|
||||||
|
in
|
||||||
|
if match == null then
|
||||||
|
throw "Unable to extract ${prefix} from: ${normalize str}"
|
||||||
|
else
|
||||||
|
builtins.head match;
|
||||||
|
|
||||||
|
extractFlagValue = flag: str:
|
||||||
|
let
|
||||||
|
match = builtins.match ".*${flag}=([^ ]+).*" (normalize str);
|
||||||
|
in
|
||||||
|
if match == null then
|
||||||
|
throw "Unable to extract ${flag} from: ${normalize str}"
|
||||||
|
else
|
||||||
|
builtins.head match;
|
||||||
|
|
||||||
|
config = (lib.nixosSystem {
|
||||||
|
inherit system;
|
||||||
|
modules = [
|
||||||
|
{ nixpkgs.hostPlatform = system; nixpkgs.overlays = [ overlay-stable ]; }
|
||||||
|
../modules/bitcoin
|
||||||
|
{
|
||||||
|
nix-bitcoin.generateSecrets = true;
|
||||||
|
nix-bitcoin.secretsDir = "/build/secrets";
|
||||||
|
|
||||||
|
services.btcpayserver.enable = true;
|
||||||
|
services.btcpayserver.lightningBackend = "lnd";
|
||||||
|
services.nbxplorer.dataDir = "/build/nbxplorer";
|
||||||
|
services.btcpayserver.dataDir = "/build/btcpayserver";
|
||||||
|
services.lnd.dataDir = "/build/lnd";
|
||||||
|
services.bitcoind.dataDir = "/build/bitcoind";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}).config;
|
||||||
|
|
||||||
|
nbxplorerPreStart = config.systemd.services.nbxplorer.preStart;
|
||||||
|
bitcoindPreStart = config.systemd.services.bitcoind.preStart;
|
||||||
|
btcpayExecStart = config.systemd.services.btcpayserver.serviceConfig.ExecStart;
|
||||||
|
btcpayWorkingDir = config.systemd.services.btcpayserver.serviceConfig.WorkingDirectory;
|
||||||
|
|
||||||
|
nbxplorerConfigPath = extractAfter "install -m 600" nbxplorerPreStart;
|
||||||
|
btcpayConfigPath = extractFlagValue "--conf" btcpayExecStart;
|
||||||
|
|
||||||
|
nbxplorerConfig = builtins.readFile nbxplorerConfigPath;
|
||||||
|
btcpayConfig = builtins.readFile btcpayConfigPath;
|
||||||
|
in
|
||||||
|
assert lib.assertMsg
|
||||||
|
(config.users.users.${config.services.btcpayserver.user}.home == config.services.btcpayserver.dataDir)
|
||||||
|
"btcpayserver user home must match btcpayserver dataDir";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(config.users.users.${config.services.nbxplorer.user}.home == config.services.nbxplorer.dataDir)
|
||||||
|
"nbxplorer user home must match nbxplorer dataDir";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(config.nix-bitcoin.secrets.bitcoin-HMAC-btcpayserver.user == config.services.bitcoind.user)
|
||||||
|
"bitcoin-HMAC-btcpayserver must be owned by bitcoind";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.user == config.services.bitcoind.user)
|
||||||
|
"bitcoin-rpcpassword-btcpayserver must be owned by bitcoind";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.group == config.services.nbxplorer.group)
|
||||||
|
"bitcoin-rpcpassword-btcpayserver must be group-readable by nbxplorer";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(!(lib.elem config.services.nbxplorer.group config.users.users.${config.services.btcpayserver.user}.extraGroups))
|
||||||
|
"btcpayserver must not receive the nbxplorer group";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(lib.elem "nix-bitcoin-secrets.target" config.systemd.services.nbxplorer.after)
|
||||||
|
"nbxplorer must wait for nix-bitcoin-secrets.target";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(config.systemd.services.nbxplorer.serviceConfig.MemoryDenyWriteExecute == false)
|
||||||
|
"nbxplorer needs MemoryDenyWriteExecute = false";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(config.systemd.services.btcpayserver.serviceConfig.MemoryDenyWriteExecute == false)
|
||||||
|
"btcpayserver needs MemoryDenyWriteExecute = false";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(lib.hasInfix "network=mainnet" nbxplorerConfig
|
||||||
|
&& lib.hasInfix "btcrpcuser=btcpayserver" nbxplorerConfig
|
||||||
|
&& lib.hasInfix "btcnodeendpoint=127.0.0.1:8335" nbxplorerConfig
|
||||||
|
&& lib.hasInfix "bind=127.0.0.1" nbxplorerConfig
|
||||||
|
&& lib.hasInfix "port=24444" nbxplorerConfig
|
||||||
|
&& lib.hasInfix "postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" nbxplorerConfig)
|
||||||
|
"nbxplorer base config must contain the expected non-secret settings";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
|
||||||
|
&& lib.hasInfix "btcexplorercookiefile=/build/nbxplorer/Main/.cookie" btcpayConfig)
|
||||||
|
"btcpayserver config must contain btcexplorerurl and btcexplorercookiefile";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(lib.hasSuffix "/lib/btcpayserver" btcpayWorkingDir)
|
||||||
|
"btcpayserver WorkingDirectory must end with /lib/btcpayserver";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(!lib.hasInfix "/build/btcpayserver/settings.config" btcpayExecStart
|
||||||
|
&& lib.hasInfix "--datadir='/build/btcpayserver'" btcpayExecStart)
|
||||||
|
"btcpayserver must use a deterministic config file plus --datadir";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(lib.hasInfix "network=mainnet" btcpayConfig
|
||||||
|
&& lib.hasInfix "bind=127.0.0.1" btcpayConfig
|
||||||
|
&& lib.hasInfix "port=23000" btcpayConfig
|
||||||
|
&& lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
|
||||||
|
&& lib.hasInfix "explorer.postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" btcpayConfig
|
||||||
|
&& lib.hasInfix "postgres=User ID=btcpayserver;Host=/run/postgresql;Database=btcpayserver" btcpayConfig
|
||||||
|
&& lib.hasInfix "btclightning=type=lnd-rest;server=https://127.0.0.1:8080/;macaroonfilepath=/run/lnd/btcpayserver.macaroon;certfilepath=/build/secrets/lnd-cert" btcpayConfig)
|
||||||
|
"btcpayserver config must preserve BTCPay, NBXplorer, database, and LND settings";
|
||||||
|
assert lib.assertMsg
|
||||||
|
(lib.hasInfix "readValidatedRpcHmac()" bitcoindPreStart
|
||||||
|
&& lib.hasInfix ''if [[ ! -e "$hmacFile" ]]; then'' bitcoindPreStart
|
||||||
|
&& lib.hasInfix ''if [[ ! -r "$hmacFile" ]]; then'' bitcoindPreStart
|
||||||
|
&& lib.hasInfix ''if [[ -z "$hmacPayload" ]]; then'' bitcoindPreStart
|
||||||
|
&& lib.hasInfix ''^[[:xdigit:]]+\$[[:xdigit:]]+$'' bitcoindPreStart
|
||||||
|
&& lib.hasInfix ''Bitcoin RPC HMAC file has invalid format'' bitcoindPreStart
|
||||||
|
&& lib.hasInfix ''hmacPayload="$(readValidatedRpcHmac '/build/secrets/bitcoin-HMAC-btcpayserver')" || exit 1'' bitcoindPreStart)
|
||||||
|
"bitcoind preStart must validate missing, unreadable, empty, and malformed HMAC files";
|
||||||
|
pkgs.runCommand "bitcoin-btcpay-hardening" {} ''
|
||||||
|
mkdir -p /build/secrets /build/nbxplorer
|
||||||
|
|
||||||
|
printf '%s' 'first-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
|
||||||
|
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
|
||||||
|
|
||||||
|
test "$(stat -c '%a' /build/nbxplorer/settings.config)" = "600"
|
||||||
|
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
|
||||||
|
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
|
||||||
|
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
|
||||||
|
|
||||||
|
printf '%s' 'rotated-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
|
||||||
|
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
|
||||||
|
|
||||||
|
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
|
||||||
|
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
|
||||||
|
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
|
||||||
|
! grep -q 'first-password' /build/nbxplorer/settings.config
|
||||||
|
grep -q 'rotated-password' /build/nbxplorer/settings.config
|
||||||
|
|
||||||
|
touch "$out"
|
||||||
|
''
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Regression tests for the Bitcoin Core Tor IBD gossip Hub option.
|
||||||
|
|
||||||
|
These tests intentionally avoid importing the FastAPI application so they can run
|
||||||
|
in the repository's lightweight test environment without NixOS service access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(*parts: str) -> str:
|
||||||
|
with open(os.path.join(_REPO_ROOT, *parts), encoding="utf-8") as source:
|
||||||
|
return source.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _literal_assignment(source: str, name: str):
|
||||||
|
tree = ast.parse(source)
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.Assign):
|
||||||
|
if any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
|
||||||
|
return ast.literal_eval(node.value)
|
||||||
|
raise AssertionError(f"Assignment {name} was not found")
|
||||||
|
|
||||||
|
|
||||||
|
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
|
||||||
|
def test_bitcoind_loopback_listener_is_always_enabled(self):
|
||||||
|
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
||||||
|
self.assertIn("listen = true;", ecosystem)
|
||||||
|
self.assertIn("peerbloomfilters=1", ecosystem)
|
||||||
|
|
||||||
|
def test_gossip_is_opt_in(self):
|
||||||
|
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
||||||
|
self.assertIn(
|
||||||
|
"public = config.sovran_systemsOS.features.bitcoin-tor-gossip;",
|
||||||
|
ecosystem,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hub_option_and_evaluated_state_are_declared(self):
|
||||||
|
roles = _read("modules", "core", "roles.nix")
|
||||||
|
hub = _read("modules", "core", "sovran-hub.nix")
|
||||||
|
self.assertIn("bitcoin-tor-gossip = lib.mkEnableOption", roles)
|
||||||
|
self.assertIn("bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;", hub)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBitcoinTorGossipHubWiring(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.server_source = _read("app", "sovran_systemsos_web", "server.py")
|
||||||
|
cls.registry = _literal_assignment(cls.server_source, "FEATURE_REGISTRY")
|
||||||
|
|
||||||
|
def test_feature_is_modal_only_and_explains_risk(self):
|
||||||
|
feature = next(item for item in self.registry if item["id"] == "bitcoin-tor-gossip")
|
||||||
|
self.assertTrue(feature["modal_only"])
|
||||||
|
self.assertIn("bitcoin-service", feature["requires"])
|
||||||
|
self.assertTrue(any("clearnet" in detail for detail in feature["details"]))
|
||||||
|
self.assertTrue(any("bandwidth" in detail for detail in feature["details"]))
|
||||||
|
|
||||||
|
def test_backend_rejects_gossip_without_bitcoin_service(self):
|
||||||
|
self.assertIn(
|
||||||
|
"Enable the Bitcoin service before advertising its Tor IBD service.",
|
||||||
|
self.server_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_modal_renders_and_controls_the_option(self):
|
||||||
|
frontend = _read(
|
||||||
|
"app", "sovran_systemsos_web", "static", "js", "service-detail.js"
|
||||||
|
)
|
||||||
|
self.assertIn("Tor IBD Service Advertising", frontend)
|
||||||
|
self.assertIn("svc-detail-related-feature-btn", frontend)
|
||||||
|
self.assertIn("handleFeatureToggle(relatedFeat", frontend)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user