Compare commits

..
44 Commits
Author SHA1 Message Date
naturallaw777 1cf611d44c chore(release): prepare v1.1.1 2026-08-15 17:49:55 -05:00
Sovran Systems 1e3dd2dd76 Update release-stable.sh 2026-08-15 17:48:45 -05:00
Sovran Systems 45322db41a Enhance release script with preflight checks and metadata
Refactor release script to improve preflight checks and metadata preparation. Update release steps for GitHub and Gitea.
2026-08-15 17:43:03 -05:00
naturallaw777 2a1d73af25 Migrate dock/folder/mime entries from brave to brave-origin on upgrade 2026-08-15 17:31:25 -05:00
Sovran Systems e83c7fdb18 Merge pull request #434 from naturallaw777/fix/hub-logout-persistence
fix(hub): persistent browser profile so logout survives window reopen
2026-08-15 17:24:14 -05:00
naturallaw777 587c19c2a5 fix(hub): persistent browser profile so logout survives window reopen
The Hub launcher used an ephemeral /tmp profile deleted on exit, which
wiped the hub_manual_logout marker cookie. On reopen, /auto-login minted a
new session and logged the user straight back in without a password.

Use a persistent per-user profile under XDG_STATE_HOME and drop the
deletion trap so the logout marker survives close/reopen. Keep
--skip-origin-startup-dialog. Adds regression tests.
2026-08-15 17:22:58 -05:00
naturallaw777 c862ed5806 Skip Brave Origin startup dialog in Hub launcher 2026-08-15 17:15:12 -05:00
Sovran Systems 1d79acb083 Merge pull request #433 from naturallaw777/brave-origin-default
Switch default browser to Brave Origin (brave-origin)
2026-08-15 17:00:24 -05:00
naturallaw777 5edf594eac Switch default browser to Brave Origin (brave-origin) 2026-08-15 16:58:33 -05:00
Sovran Systems 94dba571c1 Merge pull request #432 from naturallaw777/fix/hub-session-auth-recovery
fix(hub): recover from expired sessions and preserve logout
2026-08-15 16:45:24 -05:00
naturallaw777 de32699539 fix(hub): recover from expired sessions and preserve logout 2026-08-15 16:43:37 -05:00
Sovran Systems 892397589f Merge pull request #431 from naturallaw777/feat/bitcoin-tor-ibd-gossip
feat: add Bitcoin Core Tor IBD gossip control
2026-08-15 14:47:21 -05:00
naturallaw777 30b753ec40 feat: add Bitcoin Core Tor IBD gossip control 2026-08-15 14:44:13 -05:00
Sovran Systems e98a0b7554 Merge pull request #430 from naturallaw777/chore/bitcoin-core-only
Replace Bitcoin Knots with Bitcoin Core
2026-08-13 13:48:28 -05:00
naturallaw777 3541f6baa1 Replace Bitcoin Knots with Bitcoin Core 2026-08-13 13:43:17 -05:00
Sovran Systems e0ee8a50a4 Merge pull request #429 from naturallaw777/fix/hub-ui-preformance-improvement
fix: prevent Bitcoin Core switch from hanging the Hub UI
2026-08-11 18:50:00 -05:00
naturallaw777 8f89a4350a fix: prevent Bitcoin Core switch from hanging the Hub UI 2026-08-11 18:47:40 -05:00
naturallaw777 cb2b49174e docs: update CHANGELOG.md for v1.1.0 2026-08-11 13:51:41 -05:00
naturallaw777 6563acef0a docs: update README ISO download links to v1.1.0 2026-08-11 13:51:41 -05:00
naturallaw777 a053ee77fb chore: bump VERSION to v1.1.0 for ISO naming 2026-08-11 13:51:41 -05:00
Sovran Systems 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 Systems 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 Systems 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 Agent 679c7a039f Fix DDNS URL validation: replace ${IP} temporarily for validator, keep placeholder for storage 2026-08-11 11:47:32 -05:00
Copilot 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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 Systems 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] ff6abf3b8e Initial plan 2026-08-11 14:00:34 +00:00
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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
copilot-swe-agent[bot]andnaturallaw777 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] 590ed134b7 Initial plan 2026-08-11 10:35:07 +00:00
Sovran Systems 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 Agent 0d0a1888f9 fix: report configured BTCPay Server version 2026-08-11 05:15:11 -05:00
Sovran Systems 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
copilot-swe-agent[bot]andnaturallaw777 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] a372b88488 Initial plan 2026-08-11 03:39:21 +00:00
42 changed files with 3758 additions and 1265 deletions

No files matched your search

+132 -1
View File
@@ -7,6 +7,136 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
--- ---
## [1.1.1] - 2026-08-15
### Added
- Update release-stable.sh
- Enhance release script with preflight checks and metadata
- Migrate dock/folder/mime entries from brave to brave-origin on upgrade
- Skip Brave Origin startup dialog in Hub launcher
- Switch default browser to Brave Origin (brave-origin)
- Add Bitcoin Core Tor IBD gossip control
- Replace Bitcoin Knots with Bitcoin Core
### Fixed
- Persistent browser profile so logout survives window reopen
- Recover from expired sessions and preserve logout
- Prevent Bitcoin Core switch from hanging the Hub UI
### Documentation
- Update README ISO download links to v1.1.0
[1.1.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.1
## [Unreleased]
### Changed
- Replaced Brave Browser with Brave Origin (the new `brave-origin` nixpkgs
package) as the desktop's default browser: installed package, dock
favorite, Browsers app-folder entry, `xdg.mime.defaultApplications`, and
the `$BROWSER` session variable now all point at Brave Origin, and the
Sovran Hub launcher runs it.
- On installs upgrading from the old Brave package, the desktop theme init
now migrates `brave-browser.desktop` → `brave-origin.desktop` in the dock
favorites, the Browsers app folder, and the user's `mimeapps.list`, so
the dock icon and default-browser handler survive the switch without a
manual re-pin.
### Fixed
- The Hub launcher now uses a persistent per-user browser profile
(`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp`
profile that was deleted on exit. The throwaway profile wiped the
`hub_manual_logout` marker cookie on every close, so `/auto-login`
silently logged the user straight back in after they signed out and
reopened the Hub window. The persistent profile makes explicit logout
stick (until the next password login) while still skipping Brave Origin's
one-time startup dialog.
- The Hub now redirects to the login page when an API request finds an
expired browser session, instead of leaving the dashboard tile grid in a
loading state while `/api/services` continues returning 401 every five
seconds.
- Explicitly logging out now suppresses the desktop launcher's local
auto-login after the Hub window is closed and reopened. A successful
password login clears that preference.
- Sessions are now persisted to `/var/lib/secrets/hub-sessions.json` so the
browser login survives the Hub service restart that `nixos-rebuild switch`
performs during activation. Previously the in-memory session store was
wiped by that restart, the `/api/rebuild/status` poll started returning
401, and the rebuild modal spun forever showing "Applying changes…" while
the switch result was never displayed.
- Rebuild and update modals now bail out and reload the page after sustained
polling failures instead of hanging indefinitely.
- Rebuild/update scripts stream `nixos-rebuild` output into the live log
(it was buffered until completion, making long rebuilds look frozen).
---
## [1.1.0] - 2026-08-11
### Added
- Speed up Hub service status loading
- Update Documentation
- Njalla.nix: fail on ImportError; fix redundant except clause
- Correctly escape DDNS placeholder in Nix string
- Security hardening: fix all 8 blocking findings for PR #419
- Security hardening: fix DDNS injection, Nix injection, reboot auth, support key, sudo rules
- Harden btcpay and nbxplorer config handling
- Add provenance headers and fix flake checks for PR409
- Restore upstream nix-bitcoin fetchNodeModules for mempool and RTL packages
- Refactor Nix build configuration for mempool
- Update npm dependencies and patch handling in default.nix
- Update npmDepsHash for backend and frontend packages
- Mempool: use postPatch to copy lock file for npmDeps fetcher
- Mempool: use preBuild cd instead of postUnpack so npmDeps fetcher finds lock files
- Mempool: fix npmDeps fetcher — postUnpack instead of sourceRoot, postPatch instead of patches
- Mempool: switch backend and frontend to buildNpmPackage with placeholder npmDepsHash
- Update npmDepsHash with correct hash value
- RTL: switch to buildNpmPackage with placeholder npmDepsHash
- Add bitcoind.rpc.users.btcpayserver for NBXplorer
- Restore original flake.nix with nixosModules.Sovran_SystemsOS export
- Add vendored RTL package and fix rtl.nix to use it
- Vendor mempool packages and wire mempool module to vendored pkgs
- Refactor onion service configuration for LND
- Add missing bitcoind-rpc-public-whitelist.nix
- Remove joinmarket-ob-watcher from onion-services defaults
- Tailor: make bitcoin modules truly Sovran-only (lnd-only) and delete stubs.nix
- Vendor: replace nix-bitcoin flake input with minimal vendored modules (nixpkgs-only)
- Remove duplicate 1.0.6 release notes from CHANGELOG
### Changed
- Clean mempool module comment typo
- Move vendor/nix-bitcoin to modules/bitcoin, remove overlays
- Nix flake update - drop nix-bitcoin
### Fixed
- Correct RTL and Mempool Hub versions
- Fix DDNS URL validation: replace ${IP} temporarily for validator, keep placeholder for storage
- Fix all 8 security hardening blockers for PR #423
- Fix Nix interpolation in DDNS runner
- Fix IP validation in DDNS and document journalctl sudo rule
- Report configured BTCPay Server version
- Fix Matrix SIGPIPE and RTL v0.15.8 config schema regressions
- Add NBXplorer cookie auth and WorkingDirectory for BTCPay service
- Fix BTCPay startup regression: add home dirs to service users
- Address btcpay hardening review feedback
- Register bitcoin-HMAC-btcpayserver as managed secret owned by bitcoind user
- Fix RTL: use fetchNodeModules instead of npm ci in buildPhase
- Fix PostgreSQL ensureUsers: use ensureDBOwnership instead of ensureClauses
- Fix lnd macaroons: replace invalid 'enable' with 'user'
- Fix postgresql ensurePermissions -> ensureClauses for nixpkgs unstable
- Fix btcpayserver.nix to use pkgs.stable overlay
- Fix btcpayserver syntax error and pin to nixpkgs-stable (2.4.2)
- Fix mempool package: remove fetchNodeModules dependency
- Fix typo in onion-addresses service check
- Fix formatting of extraGroups in btcpayserver.nix
- Add missing semicolon after extraGroups in btcpayserver.nix
- Remove services.clightning.enable assignment that fails on f13ff45
- Remove clightning and clightning-rest from stubs to avoid duplicate on f13ff45
- Add stubs for nixpkgs-unstable 2026-08 where services.clightning removed
- Keep ISO artifacts out of the repo and auto-update README on release
[1.1.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.0
## [1.0.6] - 2026-08-07 ## [1.0.6] - 2026-08-07
### Added ### Added
@@ -79,6 +209,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
@@ -159,7 +290,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Integrated Sparrow Wallet, Bisq, and Bisq 2 - Integrated Sparrow Wallet, Bisq, and Bisq 2
- Comprehensive Sovran Hub for service management - Comprehensive Sovran Hub for service management
- NixOS-based operating system with privacy and sovereignty focus - NixOS-based operating system with privacy and sovereignty focus
- Support for Bitcoin Knots + BIP110, Electrs, LND, Ride The Lightning, BTCPay Server, and more - Support for Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and more
- Server + Desktop hybrid mode with Matrix, Nextcloud, VaultWarden, and other self-hosted services - Server + Desktop hybrid mode with Matrix, Nextcloud, VaultWarden, and other self-hosted services
- Automated installer with graphical GNOME desktop - Automated installer with graphical GNOME desktop
- Tor integration and onion services for all major components - Tor integration and onion services for all major components
+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
+61 -88
View File
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
are ready. are ready.
[Visit the Website](https://sovransystems.com) · [Visit the Website](https://sovransystems.com) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso) · [Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso) ·
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) · [Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso.sha256) · [Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso.sha256) ·
[Build from Source](#build-from-source) [Build from Source](#build-from-source)
<img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" /> <img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
@@ -73,8 +73,7 @@ Sovran_SystemsOS solves both with one operating system:
- **Buy and sell Bitcoin peer-to-peer** with Bisq and Bisq 2. No central - **Buy and sell Bitcoin peer-to-peer** with Bisq and Bisq 2. No central
company holds user funds, and no exchange account stands between buyers and company holds user funds, and no exchange account stands between buyers and
sellers. sellers.
- **Verify your own Bitcoin** with a full node: [Bitcoin - **Verify your own Bitcoin** with a full node: [Bitcoin Core](https://bitcoin.org) and
Knots](https://bitcoinknots.org) and
[Electrs](https://github.com/romanz/electrs), so your wallets connect to [Electrs](https://github.com/romanz/electrs), so your wallets connect to
*your* node instead of a stranger's. *your* node instead of a stranger's.
- **Use Lightning** with [LND](https://github.com/lightningnetwork/lnd) and - **Use Lightning** with [LND](https://github.com/lightningnetwork/lnd) and
@@ -150,13 +149,13 @@ presents and manages the features available on your system.
### Your money — Bitcoin sovereignty ### Your money — Bitcoin sovereignty
- Bitcoin Knots, Electrs, and Tor integration - Bitcoin Core, Electrs, and Tor integration
- LND and Ride The Lightning, with [Alby Hub](https://albyhub.com) for Nostr - LND and Ride The Lightning, with [Alby Hub](https://albyhub.com) for Nostr
Wallet Connect (NWC) connections Wallet Connect (NWC) connections
- BTCPay Server - BTCPay Server
- Sparrow Wallet, Bisq, and Bisq 2, with automatic wallet-to-node connections - Sparrow Wallet, Bisq, and Bisq 2, with automatic wallet-to-node connections
- Optional: a self-hosted [Mempool](https://github.com/mempool/mempool) - Optional: a self-hosted [Mempool](https://github.com/mempool/mempool)
explorer, or Bitcoin Core in place of Knots explorer
Run your own Bitcoin infrastructure. Verify your own money. Trust no one. Run your own Bitcoin infrastructure. Verify your own money. Trust no one.
@@ -186,7 +185,7 @@ hardware you control.
### Your desktop ### Your desktop
- [GNOME](https://www.gnome.org) desktop - [GNOME](https://www.gnome.org) desktop
- [Brave](https://brave.com) and - [Brave Origin](https://brave.com/origin/) and
[Firefox](https://www.mozilla.org/firefox) browsers [Firefox](https://www.mozilla.org/firefox) browsers
- File management, email, calendar, and office applications - File management, email, calendar, and office applications
- System monitoring and administration tools - System monitoring and administration tools
@@ -202,7 +201,7 @@ Bitcoin and self-hosting infrastructure runs on the machine.
| Mode | Best for | What you get | | Mode | Best for | What you get |
|---|---|---| |---|---|---|
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use | | **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Knots, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections | | **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services | | **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services |
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free **Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
@@ -342,8 +341,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
### 1. Download the ISO and checksum ### 1. Download the ISO and checksum
- [Download Sovran_SystemsOS-1.0.6.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso) - [Download Sovran_SystemsOS-1.1.1.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso)
- [Download Sovran_SystemsOS-1.0.6.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso.sha256) - [Download Sovran_SystemsOS-1.1.1.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso.sha256)
The download may take some time. Do not rename or modify the ISO before The download may take some time. Do not rename or modify the ISO before
verifying it, and keep both files in the same folder. verifying it, and keep both files in the same folder.
@@ -361,16 +360,16 @@ checksum exactly.
Open a terminal in the download folder and run: Open a terminal in the download folder and run:
```bash ```bash
sha256sum --check Sovran_SystemsOS-1.0.6.iso.sha256 sha256sum --check Sovran_SystemsOS-1.1.1.iso.sha256
``` ```
A successful comparison reports: A successful comparison reports:
```text ```text
Sovran_SystemsOS-1.0.6.iso: OK Sovran_SystemsOS-1.1.1.iso: OK
``` ```
You can also run `sha256sum Sovran_SystemsOS-1.0.6.iso` and compare the output You can also run `sha256sum Sovran_SystemsOS-1.1.1.iso` and compare the output
against the checksum file manually. against the checksum file manually.
</details> </details>
@@ -381,11 +380,11 @@ against the checksum file manually.
Open Terminal in the download folder and run: Open Terminal in the download folder and run:
```bash ```bash
shasum -a 256 Sovran_SystemsOS-1.0.6.iso shasum -a 256 Sovran_SystemsOS-1.1.1.iso
``` ```
Compare the value shown in Terminal with the value inside Compare the value shown in Terminal with the value inside
`Sovran_SystemsOS-1.0.6.iso.sha256`. `Sovran_SystemsOS-1.1.1.iso.sha256`.
</details> </details>
@@ -395,7 +394,7 @@ Compare the value shown in Terminal with the value inside
Open PowerShell in the download folder and run: Open PowerShell in the download folder and run:
```powershell ```powershell
Get-FileHash .\Sovran_SystemsOS-1.0.6.iso -Algorithm SHA256 Get-FileHash .\Sovran_SystemsOS-1.1.1.iso -Algorithm SHA256
``` ```
Compare the value under `Hash` with the published checksum. Compare the value under `Hash` with the published checksum.
@@ -410,7 +409,7 @@ match exactly.
1. Download and install [Balena Etcher](https://etcher.balena.io), then 1. Download and install [Balena Etcher](https://etcher.balena.io), then
connect the USB drive. connect the USB drive.
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.0.6.iso`. 2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.1.iso`.
3. Choose **Select target**, select the USB drive, and review your selection 3. Choose **Select target**, select the USB drive, and review your selection
carefully. carefully.
4. Choose **Flash** and wait for the writing and verification process to 4. Choose **Flash** and wait for the writing and verification process to
@@ -507,14 +506,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 +531,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 +623,14 @@ sudo nixos-rebuild switch --rollback
| `flake.nix` | Declares flake inputs, the running system, and installer outputs | | `flake.nix` | Declares flake inputs, the running system, and installer outputs |
| `flake.lock` | Pins dependencies for reproducible builds | | `flake.lock` | Pins dependencies for reproducible builds |
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration | | `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
| `modules/` | Core modules, Bitcoin services, self-hosted services, and optional features | | `modules/` | Core modules, self-hosted services, and optional features |
| `modules/bitcoin/` | In-repository Bitcoin and Lightning service modules |
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior | | `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface | | `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
| `scripts/` | Automated release, build, and CDN upload utility scripts | | `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 |
@@ -687,7 +687,6 @@ rebuilds the machine into the selected declarative state.
| Shared credentials | `modules/credentials.nix` | | Shared credentials | `modules/credentials.nix` |
| Bitcoin and Lightning stack | `modules/bitcoinecosystem.nix` | | Bitcoin and Lightning stack | `modules/bitcoinecosystem.nix` |
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` | | Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
| Optional Bitcoin Core in place of Knots | `modules/bitcoin-core.nix` |
| Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` | | Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` |
| Matrix Synapse | `modules/synapse.nix` | | Matrix Synapse | `modules/synapse.nix` |
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` | | Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
@@ -704,65 +703,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 +749,15 @@ Sovran_SystemsOS would not have the same reliability, transparency, or reproduci
### nix-bitcoin ### nix-bitcoin
Special thanks go to the [nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) project and its [contributors](https://github.com/fort-nix/nix-bitcoin/graphs/contributors). The in-repository Bitcoin stack began with code adapted from
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), primarily from commit
[`360e30f`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
It has since been narrowed to Sovran's supported services and is maintained in
this repository. nix-bitcoin is no longer a flake input or build dependency.
nix-bitcoin provides the declarative foundation for building and operating Bitcoin and Lightning services on NixOS. Its work makes it possible to configure complex Bitcoin infrastructure—including nodes, Electrs, Lightning, Tor integration, and related services—in a reproducible and auditable way. We remain grateful to the nix-bitcoin contributors for the declarative and
security-focused foundation. Its MIT notice is retained in
Sovran_SystemsOS builds upon that foundation to make this infrastructure approachable through an integrated desktop, installer, and Sovran Hub. [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
### Emmanuel Rosa and btc-clients-nix ### Emmanuel Rosa and btc-clients-nix
@@ -802,7 +775,6 @@ This work helps make it possible for Sovran_SystemsOS to deliver self-custody an
Sovran_SystemsOS also depends on the work of the developers and communities behind: Sovran_SystemsOS also depends on the work of the developers and communities behind:
- [Bitcoin Knots](https://github.com/bitcoinknots/bitcoin)
- [Bitcoin Core](https://github.com/bitcoin/bitcoin) - [Bitcoin Core](https://github.com/bitcoin/bitcoin)
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow) - [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
- [Bisq](https://github.com/bisq-network/bisq) - [Bisq](https://github.com/bisq-network/bisq)
@@ -859,9 +831,10 @@ license.
> Individual upstream applications, packages, artwork, fonts, and other > Individual upstream applications, packages, artwork, fonts, and other
> components included with or built by Sovran_SystemsOS may have their own > components included with or built by Sovran_SystemsOS may have their own
> licenses and copyright holders. The AGPL-3.0 license for this repository does > licenses and copyright holders. The AGPL-3.0 license for this repository does
> not replace the licenses of independent upstream projects. > not replace those licenses.
Read the complete license terms in [`LICENSE`](LICENSE). Read [`LICENSE`](LICENSE) and
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
--- ---
@@ -879,7 +852,7 @@ primary location for collaboration. Please read our
## Privacy. Sovereignty. Bitcoin. ## Privacy. Sovereignty. Bitcoin.
[Visit Sovran Systems](https://sovransystems.com) · [Visit Sovran Systems](https://sovransystems.com) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso) · [Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso) ·
[View the License](LICENSE) [View the License](LICENSE)
</div> </div>
+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 **24–48 hours** and provide a tracking reference. traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
2. **Triage:** We will investigate and verify the vulnerability privately. We may reach out to you for clarifying details.
3. **Remediation:** If verified, we will develop a patch on a private security branch.
4. **Release:** We will coordinate a release date with you and publish the patch to Gitea `stable` and GitHub `main`.
5. **Advisory:** A public security advisory will be published, giving full credit to you for the discovery (unless you request anonymity).
We ask that you practice **coordinated vulnerability disclosure**, giving us reasonable time to patch the vulnerability before disclosing it publicly to protect other operators' funds and data. ### 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.
+1 -1
View File
@@ -1 +1 @@
1.0.6 1.1.1
-244
View File
@@ -1,244 +0,0 @@
<svg width="256" height="256" version="1.1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<image width="256" height="256" image-rendering="optimizeQuality" preserveAspectRatio="none" xlink:href="data:image/png;base64,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
"/>
</svg>

Before

Width:  |  Height:  |  Size: 18 KiB

@@ -491,6 +491,8 @@ if [[ -d /home ]]; then
--exclude='.config/chromium/*/Code Cache/' \ --exclude='.config/chromium/*/Code Cache/' \
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \ --exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \ --exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
--exclude='.config/BraveSoftware/Brave-Origin/*/Cache/' \
--exclude='.config/BraveSoftware/Brave-Origin/*/Code Cache/' \
--exclude='.local/share/baloo/' \ --exclude='.local/share/baloo/' \
--exclude='.thumbnails/' \ --exclude='.thumbnails/' \
--exclude='.xsession-errors' \ --exclude='.xsession-errors' \
@@ -0,0 +1,313 @@
"""Sovran Hub — pure security validation helpers.
This module contains the dependency-light security helper functions used by
the Hub server. Keeping them here allows tests to import and exercise the
exact production implementations rather than maintaining separate copies.
All functions in this module depend only on the Python standard library.
"""
from __future__ import annotations
import base64
import ipaddress
import json
import os
import re
import tempfile
import time
import urllib.parse
# ── Nix string escaping ────────────────────────────────────────────────────────
def _nix_escape(value: str) -> str:
"""Escape *value* for use inside a Nix double-quoted string literal.
Handles backslashes, double-quotes, newlines, carriage returns, tabs, and
Nix-specific anti-quotation sequences (``${...}``). The returned value is
safe to embed as ``"<returned_value>"`` in generated Nix source.
"""
value = value.replace("\\", "\\\\")
value = value.replace('"', '\\"')
value = value.replace("\n", "\\n")
value = value.replace("\r", "\\r")
value = value.replace("\t", "\\t")
value = value.replace("${", "\\${")
return value
# ── Nostr npub validation (NIP-19 / Bech32) ───────────────────────────────────
# Fast pre-filter: "npub1" followed by exactly 58 lower-case bech32 characters.
NPUB_RE = re.compile(r"^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
_BECH32_GENERATOR = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
def _bech32_polymod(values: list[int]) -> int:
chk = 1
for value in values:
top = chk >> 25
chk = ((chk & 0x1FFFFFF) << 5) ^ value
for i in range(5):
if (top >> i) & 1:
chk ^= _BECH32_GENERATOR[i]
return chk
def _bech32_hrp_expand(hrp: str) -> list[int]:
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]:
values = _bech32_hrp_expand(hrp) + data
polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
return [(polymod >> (5 * (5 - i))) & 31 for i in range(6)]
def _bech32_convertbits_decode(data: list[int]) -> list[int] | None:
"""Convert a 5-bit integer sequence to 8-bit bytes, stripping padding."""
acc = 0
bits = 0
ret: list[int] = []
for value in data:
acc = (acc << 5) | value
bits += 5
while bits >= 8:
bits -= 8
ret.append((acc >> bits) & 0xFF)
if bits >= 5 or ((acc << (8 - bits)) & 0xFF):
return None # invalid padding
return ret
def _bech32_decode(bech: str) -> tuple[str, bytes] | None:
"""Decode a bech32 string. Returns ``(hrp, payload_bytes)`` or ``None``.
Verifies:
- Lowercase-only (mixed case rejected per BIP-173).
- Only valid bech32 charset characters.
- Valid checksum.
- Exactly one separator (``1``).
- Minimum data part length (≥ 8 chars = 6 checksum + ≥ 2 data).
"""
if bech != bech.lower():
return None # mixed case
sep = bech.rfind("1")
if sep < 1 or sep + 7 > len(bech):
return None
hrp = bech[:sep]
data_part = bech[sep + 1:]
if any(c not in _BECH32_CHARSET for c in data_part):
return None
decoded = [_BECH32_CHARSET.index(c) for c in data_part]
if _bech32_polymod(_bech32_hrp_expand(hrp) + decoded) != 1:
return None # bad checksum
converted = _bech32_convertbits_decode(decoded[:-6])
if converted is None:
return None
return hrp, bytes(converted)
def _validate_npub(value: str) -> bool:
"""Return ``True`` iff *value* is a valid NIP-19 Nostr npub.
Checks:
- Lowercase ``npub`` HRP.
- Valid bech32 charset (no uppercase, no invalid chars).
- Valid bech32 checksum.
- Exactly 32 decoded payload bytes (256-bit public key).
- Retains the original regex as a fast pre-filter.
"""
if not NPUB_RE.fullmatch(value):
return False
result = _bech32_decode(value)
if result is None:
return False
hrp, payload = result
return hrp == "npub" and len(payload) == 32
# ── DDNS URL validation ────────────────────────────────────────────────────────
_DDNS_URL_MAX_LEN = 2048
_DDNS_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
# Allowlist: only the official Njal.la provider hostnames are accepted for
# DDNS update URLs. Any other host would allow SSRF against the Hub's
# internal network.
_DDNS_ALLOWED_HOSTNAMES: frozenset[str] = frozenset(["njal.la", "www.njal.la"])
def _validate_ddns_url(url: str) -> str:
"""Validate *url* as a safe DDNS update URL and return it normalised.
Rules:
- Must be a valid URL parseable by urllib.parse.
- Scheme must be ``https`` (case-insensitive).
- No userinfo (credentials must not be embedded in the URL).
- No fragment.
- No control characters.
- Must not exceed ``_DDNS_URL_MAX_LEN`` bytes.
- Hostname must be the exact Njal.la provider hostname (njal.la or www.njal.la).
- Port must be absent or the default HTTPS port 443.
- No percent-encoded null bytes.
Raises ``ValueError`` with a safe (non-secret) message on failure.
"""
if not url:
raise ValueError("DDNS URL must not be empty")
if len(url) > _DDNS_URL_MAX_LEN:
raise ValueError("DDNS URL exceeds maximum length")
if _DDNS_CONTROL_RE.search(url):
raise ValueError("DDNS URL contains control characters")
try:
parsed = urllib.parse.urlparse(url)
except Exception:
raise ValueError("DDNS URL could not be parsed")
if parsed.scheme.lower() != "https":
raise ValueError("DDNS URL must use the https scheme")
if parsed.username or parsed.password:
raise ValueError("DDNS URL must not contain credentials")
if parsed.fragment:
raise ValueError("DDNS URL must not contain a fragment")
if parsed.port is not None and parsed.port != 443:
raise ValueError("DDNS URL must use the default HTTPS port")
hostname = parsed.hostname or ""
if not hostname:
raise ValueError("DDNS URL must contain a hostname")
# Reject raw IP addresses
try:
ipaddress.ip_address(hostname)
raise ValueError("DDNS URL hostname must not be a raw IP address")
except ValueError as exc:
if "raw IP" in str(exc):
raise
# Allowlist: only Njal.la
if hostname.lower() not in _DDNS_ALLOWED_HOSTNAMES:
raise ValueError(
f"DDNS URL hostname is not an allowed Njal.la host "
f"(got {hostname!r})"
)
if "%00" in url.lower():
raise ValueError("DDNS URL must not contain encoded null bytes")
# Reject any remaining $ expressions — after ${IP} substitution there
# must be none. Callers that store ${IP} placeholder URLs must substitute
# before calling this function.
if "$" in url:
raise ValueError("DDNS URL must not contain $ expressions")
# Require the exact /update/ path used by Njal.la
if parsed.path != "/update/":
raise ValueError("DDNS URL path must be exactly /update/")
return url
# ── SSH public-key validation ─────────────────────────────────────────────────
_SSH_PUBKEY_ALGORITHMS = frozenset([
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com",
])
def _validate_ssh_pubkey(key: str) -> str:
"""Validate *key* as a single OpenSSH public key and return it normalised.
Accepts only single-line keys with a supported algorithm, valid base64
payload, and an optional comment. Rejects options, multiple lines,
control characters, and unsupported algorithms.
Raises ``ValueError`` with a safe message on failure.
"""
key = key.strip()
if not key:
raise ValueError("SSH public key must not be empty")
if _DDNS_CONTROL_RE.search(key):
raise ValueError("SSH public key contains control characters")
if "\n" in key or "\r" in key:
raise ValueError("SSH public key must be a single line")
parts = key.split()
if len(parts) < 2:
raise ValueError("SSH public key is malformed")
algo, b64 = parts[0], parts[1]
if algo not in _SSH_PUBKEY_ALGORITHMS:
raise ValueError(f"Unsupported SSH key algorithm: {algo!r}")
try:
decoded = base64.b64decode(b64, validate=True)
except Exception:
raise ValueError("SSH public key payload is not valid base64")
if len(decoded) < 20:
raise ValueError("SSH public key payload is too short")
return key
# ── Persistent Hub session store ─────────────────────────────────────────────
def load_session_store(path: str) -> dict[str, float]:
"""Load persisted Hub sessions from *path*.
Returns a mapping of session token → expiry timestamp (epoch seconds).
Expired entries are discarded. A missing, unreadable or malformed file
yields an empty mapping — losing sessions is a UX inconvenience (the user
must log in again), never a fatal error.
Persistence exists so that authenticated sessions survive a restart of
the Hub service itself. ``nixos-rebuild switch`` restarts
``sovran-hub-web.service`` during activation (its unit definition changes
with every feature toggle), and without persistence the in-progress
rebuild/update status polling loses authentication and the UI hangs.
"""
try:
with open(path, "r") as f:
data = json.load(f)
except (OSError, ValueError):
return {}
if not isinstance(data, dict):
return {}
now = time.time()
sessions: dict[str, float] = {}
for token, expiry in data.items():
if not isinstance(token, str) or not token:
continue
if isinstance(expiry, bool) or not isinstance(expiry, (int, float)):
continue
if expiry > now:
sessions[token] = float(expiry)
return sessions
def save_session_store(path: str, sessions: dict[str, float]) -> bool:
"""Atomically persist *sessions* (token → expiry) to *path* with mode 0600.
Writes to a temp file in the same directory and renames it into place so
the store is never left partially written. Returns True on success,
False otherwise (persistence is best-effort).
"""
directory = os.path.dirname(path) or "."
fd = None
tmp_path = None
try:
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".hub_sessions_tmp")
with os.fdopen(fd, "w") as f:
fd = None # os.fdopen takes ownership of the descriptor
json.dump(sessions, f)
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, path)
return True
except OSError:
if fd is not None:
try:
os.close(fd)
except OSError:
pass
if tmp_path is not None:
try:
os.unlink(tmp_path)
except OSError:
pass
return False
File diff suppressed because it is too large. Load diff
+62 -62
View File
@@ -1,5 +1,28 @@
/* ── Service tile card (status-only) ─────────────────────────────── */ /* ── Service tile card (status-only) ─────────────────────────────── */
.dashboard-loading {
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.dashboard-loading-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: dashboard-loading-spin 0.8s linear infinite;
}
@keyframes dashboard-loading-spin {
to { transform: rotate(360deg); }
}
.service-tile { .service-tile {
width: 160px; width: 160px;
min-height: 130px; min-height: 130px;
@@ -155,68 +178,6 @@
white-space: nowrap; white-space: nowrap;
} }
/* ── BIP-110 status badge (tile + detail modal) ───────────────────── */
.tile-bip110-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 0.64rem;
font-weight: 600;
border-radius: 4px;
padding: 2px 6px;
margin-top: 4px;
white-space: nowrap;
letter-spacing: 0.02em;
}
.tile-bip110-badge--active {
background: rgba(109, 191, 139, 0.18);
color: var(--green);
border: 1px solid rgba(109, 191, 139, 0.3);
}
.tile-bip110-badge--locked_in {
background: rgba(94, 173, 138, 0.15);
color: var(--accent-color);
border: 1px solid rgba(94, 173, 138, 0.3);
}
.tile-bip110-badge--signaling {
background: rgba(94, 173, 138, 0.12);
color: var(--accent-color);
border: 1px solid rgba(94, 173, 138, 0.2);
}
.tile-bip110-badge--not_signaling {
background: rgba(229, 165, 10, 0.12);
color: var(--yellow);
border: 1px solid rgba(229, 165, 10, 0.25);
}
.tile-bip110-badge--unsupported {
background: rgba(94, 122, 106, 0.12);
color: var(--grey);
border: 1px solid rgba(94, 122, 106, 0.2);
}
.tile-bip110-badge--unknown {
background: transparent;
color: var(--text-dim);
border: 1px solid var(--border-color);
}
.bip110-status-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.bip110-source-label {
color: var(--text-dim);
font-size: 0.75rem;
}
/* ── Service detail modal sections ───────────────────────────────── */ /* ── Service detail modal sections ───────────────────────────────── */
@@ -404,6 +365,45 @@
color: var(--text-dim); color: var(--text-dim);
} }
.svc-detail-option-card {
padding: 16px;
background: rgba(94, 173, 138, 0.06);
border: 1px solid rgba(94, 173, 138, 0.24);
border-radius: 10px;
}
.svc-detail-option-list {
margin: 10px 0 0;
padding-left: 20px;
color: var(--text-secondary);
font-size: 0.84rem;
line-height: 1.55;
}
.svc-detail-option-list li {
margin-bottom: 5px;
}
.svc-detail-option-privacy {
margin-top: 12px;
padding: 10px 12px;
border-left: 3px solid var(--accent-color);
background: rgba(94, 173, 138, 0.08);
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.82rem;
line-height: 1.5;
}
.svc-detail-option-privacy strong {
color: var(--accent-color);
}
.svc-detail-related-feature-btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.feature-conflict-warning { .feature-conflict-warning {
margin-top: 8px; margin-top: 8px;
margin-bottom: 8px; margin-bottom: 8px;
@@ -5,6 +5,10 @@
const POLL_INTERVAL_SERVICES = 5000; const POLL_INTERVAL_SERVICES = 5000;
const POLL_INTERVAL_UPDATES = 1800000; const POLL_INTERVAL_UPDATES = 1800000;
const UPDATE_POLL_INTERVAL = 2000; const UPDATE_POLL_INTERVAL = 2000;
// Max consecutive failed rebuild/update status polls before the page gives up
// waiting and reloads to re-sync (2s interval → ~2 minutes of failures).
// A brief Hub restart during activation only causes a handful of failures.
const STATUS_POLL_MAX_FAILURES = 60;
const REBOOT_CHECK_INTERVAL = 5000; const REBOOT_CHECK_INTERVAL = 5000;
const REBOOT_FETCH_TIMEOUT = 12000; const REBOOT_FETCH_TIMEOUT = 12000;
const REBOOT_REQUEST_TIMEOUT = 4000; const REBOOT_REQUEST_TIMEOUT = 4000;
+21 -12
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; }),
apiFetch("/api/security/banner-status").catch(function() { return null; }),
apiFetch("/api/config"),
]);
onboardingStatus = startupResults[0];
bannerData = startupResults[1];
cfg = startupResults[2];
} catch (_) {
// If config cannot be loaded, continue with the normal service fallback.
cfg = null;
}
if (onboardingStatus && !onboardingStatus.complete) {
window.location.href = "/onboarding"; window.location.href = "/onboarding";
return; return;
} }
} catch (_) {
// If we can't reach the endpoint, continue to normal dashboard
}
// Show first-login security banner only for machines that went through onboarding // Show first-login security banner only for machines that went through onboarding
// (legacy machines without the onboarding flag will never see this) // (legacy machines without the onboarding flag will never see this)
try {
var bannerData = await apiFetch("/api/security/banner-status");
if (bannerData && bannerData.show) { if (bannerData && bannerData.show) {
showSecurityBanner(); showSecurityBanner();
} }
} catch (_) {
// Non-fatal — silently ignore
}
try { try {
var cfg = await apiFetch("/api/config"); if (!cfg) throw new Error("Hub config unavailable");
_currentRole = cfg.role || "server_plus_desktop"; _currentRole = cfg.role || "server_plus_desktop";
if (cfg.category_order) { if (cfg.category_order) {
for (var i = 0; i < cfg.category_order.length; i++) { for (var i = 0; i < cfg.category_order.length; i++) {
@@ -342,8 +342,11 @@ async function performFeatureToggle(featId, enabled, extra) {
function handleFeatureToggle(feat, newEnabled) { function handleFeatureToggle(feat, newEnabled) {
if (!newEnabled) { if (!newEnabled) {
// Disable: ask confirmation // Disable: ask confirmation
var disableMessage = (feat.id === "bitcoin-tor-gossip")
? "This will stop advertising your Bitcoin Core onion address to other peers. Nodes that already know the address can still connect through Tor, and no clearnet port will be opened. The system will rebuild. Continue?"
: "This will disable " + feat.name + ". The system will rebuild. Continue?";
openFeatureConfirm( openFeatureConfirm(
"This will disable " + feat.name + ". The system will rebuild. Continue?", disableMessage,
function() { performFeatureToggle(feat.id, false, {}); } function() { performFeatureToggle(feat.id, false, {}); }
); );
return; return;
@@ -403,9 +406,9 @@ function handleFeatureToggle(feat, newEnabled) {
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck); openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
} }
if (feat.id === "bitcoin-core") { if (feat.id === "bitcoin-tor-gossip") {
var confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will replace Bitcoin Knots + BIP110 as the active node. Your timechain data will be preserved — you will not need to re-download the timechain. Continue?"; var torGossipConfirmMsg = "This will advertise your Bitcoin Core .onion P2P address through Bitcoin peer gossip. More Tor-capable nodes may discover your node and request historical blocks during IBD, which can use significant upload bandwidth. Your home IP remains hidden and no clearnet port or router forwarding is opened. Continue?";
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck); openFeatureConfirm(torGossipConfirmMsg, proceedAfterConflictCheck);
} else if (conflictNames.length > 0) { } else if (conflictNames.length > 0) {
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck); openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
} else { } else {
+11 -14
View File
@@ -111,8 +111,19 @@ function formatDuration(seconds) {
// ── Fetch wrappers ──────────────────────────────────────────────── // ── Fetch wrappers ────────────────────────────────────────────────
// Avoid issuing multiple redirects when several startup requests discover an
// expired session at the same time.
let _authRedirectInProgress = false;
async function apiFetch(path, options) { async function apiFetch(path, options) {
const res = await fetch(path, options || {}); const res = await fetch(path, options || {});
if (res.status === 401) {
if (!_authRedirectInProgress) {
_authRedirectInProgress = true;
window.location.replace("/login");
}
throw new Error("Unauthenticated");
}
if (!res.ok) { if (!res.ok) {
let detail = res.status + " " + res.statusText; let detail = res.status + " " + res.statusText;
try { try {
@@ -133,17 +144,3 @@ async function apiFetch(path, options) {
} }
return res.json(); return res.json();
} }
// ── BIP-110 badge state config ────────────────────────────────────
// Shared lookup used by tiles.js and service-detail.js.
// Keys match the "state" values returned by /api/bitcoin/bip110.
var BIP110_BADGE_CONFIG = {
active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' },
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' },
signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' },
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' },
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' },
unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' }
};
@@ -8,6 +8,7 @@ function openRebuildModal() {
_rebuildLogOffset = 0; _rebuildLogOffset = 0;
_rebuildServerDown = false; _rebuildServerDown = false;
_rebuildFinished = false; _rebuildFinished = false;
_rebuildPollFailures = 0;
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; } if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
var action = _rebuildIsEnabling ? "Enabling" : "Disabling"; var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
var label = _rebuildFeatureName || "feature"; var label = _rebuildFeatureName || "feature";
@@ -45,6 +46,7 @@ async function pollRebuildStatus() {
if (_rebuildFinished) return; if (_rebuildFinished) return;
try { try {
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
_rebuildPollFailures = 0;
if (_rebuildServerDown) { _rebuildServerDown = false; } if (_rebuildServerDown) { _rebuildServerDown = false; }
if (data.log) appendRebuildLog(data.log); if (data.log) appendRebuildLog(data.log);
_rebuildLogOffset = data.offset; _rebuildLogOffset = data.offset;
@@ -57,6 +59,18 @@ async function pollRebuildStatus() {
onRebuildDone(data.result === "success"); onRebuildDone(data.result === "success");
} }
} catch (err) { } catch (err) {
_rebuildPollFailures += 1;
// The Hub restarts itself during activation, which briefly drops this poll.
// If polling stays broken long past a normal restart, the page's session
// almost certainly no longer matches the server (e.g. the Hub restarted
// and sessions were not recovered). Reload to re-authenticate and show
// the real feature state instead of spinning forever.
if (_rebuildPollFailures >= STATUS_POLL_MAX_FAILURES) {
_rebuildFinished = true;
stopRebuildPoll();
window.location.reload();
return;
}
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; } if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
} }
} }
@@ -704,20 +704,6 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>' + '</div>' +
'</div>'); '</div>');
// Section B2: BIP-110 live status (bip110 tile only)
if (icon === 'bip110' && data.bip110) {
var bip110 = data.bip110;
var bip110State = bip110.state || 'unknown';
var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown;
var bip110Source = bip110.source ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
'<div class="bip110-status-row">' +
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
bip110Source +
'</div>' +
'</div>';
}
// Section C: Domain diagnostics (domain services) // Section C: Domain diagnostics (domain services)
if (data.needs_domain) { if (data.needs_domain) {
@@ -901,9 +887,7 @@ async function openServiceDetailModal(unit, name, icon) {
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
// Section title: use a more specific label for mutually-exclusive Bitcoin node features // Section title: use a more specific label for mutually-exclusive Bitcoin node features
var addonSectionTitle = (feat.id === "bitcoin-core") var addonSectionTitle = "\uD83D\uDD27 Addon Feature";
? "\u20BF Bitcoin Node Selection"
: "\uD83D\uDD27 Addon Feature";
// Description: prefer the feature's own description over a generic fallback // Description: prefer the feature's own description over a generic fallback
var addonDesc = feat.description var addonDesc = feat.description
@@ -934,6 +918,54 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>'); '</div>');
} }
// Section G: Contextual feature options. The Tor IBD advertising control is
// deliberately shown only inside the Bitcoin Core modal so its bandwidth
// and privacy implications are explained before the user enables it.
var relatedFeatures = Array.isArray(data.related_features) ? data.related_features : [];
relatedFeatures.forEach(function(optionFeat) {
// Keep the shared feature state current for the standard rebuild flow.
if (!_featuresData) {
_featuresData = { features: [optionFeat], ssl_email_configured: false };
} else {
var optionIndex = _featuresData.features.findIndex(function(f) { return f.id === optionFeat.id; });
if (optionIndex >= 0) _featuresData.features[optionIndex] = optionFeat;
else _featuresData.features.push(optionFeat);
}
var detailsHtml = "";
if (Array.isArray(optionFeat.details) && optionFeat.details.length > 0) {
detailsHtml = '<ul class="svc-detail-option-list">' +
optionFeat.details.map(function(detail) {
return '<li>' + escHtml(detail) + '</li>';
}).join("") +
'</ul>';
}
var optionAvailable = optionFeat.available !== false;
var optionStatusLabel = optionFeat.enabled ? "Advertising enabled \u2713" : "Not advertised";
var optionStatusCls = optionFeat.enabled ? "addon-status--on" : "addon-status--off";
var optionButtonLabel = optionFeat.enabled ? "Stop Advertising" : "Advertise Onion Address";
var optionButtonCls = optionFeat.enabled ? "btn btn-close-modal" : "btn btn-primary";
if (!optionAvailable) {
optionStatusLabel = "Bitcoin Core is not enabled";
optionButtonLabel = "Enable Bitcoin Core First";
optionButtonCls = "btn btn-close-modal";
}
addSetup('<div class="svc-detail-section svc-detail-option-card">' +
'<div class="svc-detail-section-title">Tor IBD Service Advertising</div>' +
'<p class="svc-detail-desc">' + escHtml(optionFeat.description || "") + '</p>' +
detailsHtml +
'<div class="svc-detail-option-privacy">' +
'<strong>Tor-only:</strong> This setting advertises the onion service. It does not open a clearnet port, expose your home IP address, or require router port forwarding.' +
'</div>' +
'<div class="svc-detail-addon-row">' +
'<span class="svc-detail-addon-status ' + optionStatusCls + '">' + escHtml(optionStatusLabel) + '</span>' +
'<button class="' + optionButtonCls + ' svc-detail-related-feature-btn" data-feature-id="' + escHtml(optionFeat.id) + '"' + (!optionAvailable ? ' disabled' : '') + '>' + escHtml(optionButtonLabel) + '</button>' +
'</div>' +
'</div>');
});
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") { if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
addSetup('<div class="svc-detail-section svc-detail-restart-section">' + addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
'<div class="svc-detail-section-title">Troubleshooting</div>' + '<div class="svc-detail-section-title">Troubleshooting</div>' +
@@ -994,6 +1026,18 @@ async function openServiceDetailModal(unit, name, icon) {
} }
} }
var relatedFeatureButtons = $credsBody.querySelectorAll(".svc-detail-related-feature-btn");
relatedFeatureButtons.forEach(function(button) {
button.addEventListener("click", function() {
if (button.disabled) return;
var featureId = button.getAttribute("data-feature-id");
var relatedFeat = relatedFeatures.find(function(f) { return f.id === featureId; });
if (!relatedFeat) return;
closeCredsModal();
handleFeatureToggle(relatedFeat, !relatedFeat.enabled);
});
});
var restartBtn = document.getElementById("svc-detail-restart-btn"); var restartBtn = document.getElementById("svc-detail-restart-btn");
var restartResult = document.getElementById("svc-detail-restart-result"); var restartResult = document.getElementById("svc-detail-restart-result");
if (restartBtn && restartResult) { if (restartBtn && restartResult) {
@@ -9,6 +9,7 @@ let _updatePollTimer = null;
let _updateLogOffset = 0; let _updateLogOffset = 0;
let _serverWasDown = false; let _serverWasDown = false;
let _updateFinished = false; let _updateFinished = false;
let _updatePollFailures = 0; // consecutive failed update-status polls
let _supportTimerInt = null; let _supportTimerInt = null;
let _supportEnabledAt = null; let _supportEnabledAt = null;
let _supportStatus = null; // last fetched /api/support/status payload let _supportStatus = null; // last fetched /api/support/status payload
@@ -25,6 +26,7 @@ let _rebuildLogOffset = 0;
let _rebuildPollTimer = null; let _rebuildPollTimer = null;
let _rebuildFinished = false; let _rebuildFinished = false;
let _rebuildServerDown = false; let _rebuildServerDown = false;
let _rebuildPollFailures = 0; // consecutive failed rebuild-status polls
let _pendingToggle = null; // {feature, extra} waiting for domain/confirm let _pendingToggle = null; // {feature, extra} waiting for domain/confirm
let _rebuildFeatureName = ""; let _rebuildFeatureName = "";
let _rebuildIsEnabling = true; let _rebuildIsEnabling = true;
+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); }
} }
} }
+1 -27
View File
@@ -4,14 +4,6 @@
// Keyed by tileId: { progress: float, timestamp: ms } // Keyed by tileId: { progress: float, timestamp: ms }
var _btcSyncPrev = {}; var _btcSyncPrev = {};
// ── BIP-110 badge helper ──────────────────────────────────────────
function _renderBip110Badge(bip110) {
if (!bip110) return '';
var state = bip110.state || 'unknown';
var cfg = BIP110_BADGE_CONFIG[state] || BIP110_BADGE_CONFIG.unknown;
return '<div class="tile-bip110-badge ' + cfg.cls + '" title="' + escHtml(cfg.title) + '">' + escHtml(cfg.label) + '</div>';
}
function _firstElementFromHtml(html) { function _firstElementFromHtml(html) {
var tmp = document.createElement("div"); var tmp = document.createElement("div");
@@ -175,8 +167,7 @@ function buildTile(svc) {
return tile; return tile;
} }
var bip110Badge = (svc.icon === 'bip110') ? _renderBip110Badge(svc.bip110) : ''; tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
tile.style.cursor = "pointer"; tile.style.cursor = "pointer";
tile.addEventListener("click", function() { tile.addEventListener("click", function() {
@@ -244,23 +235,6 @@ function updateTiles(services) {
var text = tile.querySelector(".status-text"); var text = tile.querySelector(".status-text");
if (dot) dot.className = "status-dot " + sc; if (dot) dot.className = "status-dot " + sc;
if (text) text.textContent = st; if (text) text.textContent = st;
// Update BIP-110 badge for bip110 tiles
if (svc.icon === 'bip110') {
var badgeHtml = _renderBip110Badge(svc.bip110);
var badgeEl = tile.querySelector(".tile-bip110-badge");
if (badgeEl) {
// Replace existing badge in-place
var newBadge = _firstElementFromHtml(badgeHtml);
if (newBadge) { badgeEl.replaceWith(newBadge); } else { badgeEl.remove(); }
} else if (badgeHtml) {
// Insert badge after the service name
var anchorEl = tile.querySelector(".tile-name");
if (anchorEl) {
var newBadgeEl = _firstElementFromHtml(badgeHtml);
if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl);
}
}
}
} }
} }
} }
@@ -33,6 +33,7 @@ function _doOpenUpdateModal() {
_updateLogOffset = 0; _updateLogOffset = 0;
_serverWasDown = false; _serverWasDown = false;
_updateFinished = false; _updateFinished = false;
_updatePollFailures = 0;
if ($modalLog) $modalLog.textContent = ""; if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "Starting update…"; if ($modalStatus) $modalStatus.textContent = "Starting update…";
if ($modalSpinner) $modalSpinner.classList.add("spinning"); if ($modalSpinner) $modalSpinner.classList.add("spinning");
@@ -94,6 +95,7 @@ async function pollUpdateStatus() {
if (_updateFinished) return; if (_updateFinished) return;
try { try {
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset); var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
_updatePollFailures = 0;
if (_serverWasDown) { if (_serverWasDown) {
_serverWasDown = false; _serverWasDown = false;
if (!data.running) { if (!data.running) {
@@ -143,6 +145,16 @@ async function pollUpdateStatus() {
onUpdateDone(false); onUpdateDone(false);
} }
} catch (err) { } catch (err) {
_updatePollFailures += 1;
// Same guard as the rebuild modal: if polling stays broken long past a
// normal Hub restart, reload to re-authenticate and show the real state
// instead of spinning forever.
if (_updatePollFailures >= STATUS_POLL_MAX_FAILURES) {
_updateFinished = true;
stopUpdatePoll();
window.location.reload();
return;
}
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; } if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
} }
} }
+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",
+4 -7
View File
@@ -145,7 +145,7 @@
ranger fastfetch gedit openssl pwgen ranger fastfetch gedit openssl pwgen
aspell aspellDicts.en lm_sensors aspell aspellDicts.en lm_sensors
hunspell hunspellDicts.en_US hunspell hunspellDicts.en_US
synadm brave dua synadm brave-origin dua
gparted pv unzip parted screen zenity gparted pv unzip parted screen zenity
libargon2 gnome-terminal libreoffice-fresh libargon2 gnome-terminal libreoffice-fresh
dig firefox wp-cli axel dig firefox wp-cli axel
@@ -192,12 +192,9 @@ backup /etc/nix-bitcoin-secrets/ localhost/
}; };
# ── Cron ─────────────────────────────────────────────────── # ── Cron ───────────────────────────────────────────────────
services.cron = { # The legacy njalla.sh root cron job has been replaced by the systemd timer
enable = true; # defined in modules/core/njalla.nix (sovran-ddns-update.timer). Cron is
systemCronJobs = [ # retained so that rsnapshot and other module-defined cron jobs continue to run.
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh"
];
};
# ── Tor ──────────────────────────────────────────────────── # ── Tor ────────────────────────────────────────────────────
services.tor = { enable = true; client.enable = true; torsocks.enable = true; }; services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
-7
View File
@@ -1,7 +0,0 @@
{ config, pkgs, lib, ... }:
lib.mkIf config.sovran_systemsOS.features.bitcoin-core {
# Vendored nix-bitcoin now uses nixpkgs directly; no nix-bitcoin.pkgs indirection.
# Use standard bitcoind from nixpkgs (override to knots if desired via pkgs.bitcoind-knots)
services.bitcoind.package = lib.mkForce pkgs.bitcoind;
}
-5
View File
@@ -62,11 +62,6 @@ let
description = '' description = ''
The package providing bitcoind binaries. 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 { extraConfig = mkOption {
+7 -7
View File
@@ -101,33 +101,33 @@ let
index = 1; index = 1;
lnNode = "lnd"; lnNode = "lnd";
lnImplementation = "LND"; lnImplementation = "LND";
Authentication = { authentication = {
macaroonPath = "${cfg.dataDir}/macaroons"; macaroonPath = "${cfg.dataDir}/macaroons";
swapMacaroonPath = if cfg.nodes.lnd.loop then "${cfg.dataDir}/loop-macaroons" else ""; swapMacaroonPath = if cfg.nodes.lnd.loop then "${cfg.dataDir}/loop-macaroons" else "";
boltzMacaroonPath = ""; boltzMacaroonPath = "";
}; };
Settings = { settings = {
userPersona = "OPERATOR"; userPersona = "OPERATOR";
themeMode = if cfg.nightTheme then "NIGHT" else "DAY"; themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
themeColor = "PURPLE"; themeColor = "PURPLE";
channelBackupPath = "${cfg.dataDir}/backup"; channelBackupPath = "${cfg.dataDir}/backup";
logLevel = "INFO"; 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; fiatConversion = cfg.extraCurrency != null;
unannouncedChannels = true; unannouncedChannels = true;
} // optionalAttrs (cfg.extraCurrency != null) { } // optionalAttrs (cfg.extraCurrency != null) {
currencyUnit = cfg.extraCurrency; currencyUnit = cfg.extraCurrency;
}; };
} // optionalAttrs cfg.nodes.lnd.loop {
swapServerUrl = "https://localhost:8081";
boltzServerUrl = "";
}) ++ optional cfg.nodes.clightning.enable { }) ++ optional cfg.nodes.clightning.enable {
index = 2; index = 2;
lnNode = "clightning"; lnNode = "clightning";
lnImplementation = "CLN"; lnImplementation = "CLN";
Authentication = { authentication = {
runePath = runePath; runePath = runePath;
}; };
Settings = { settings = {
userPersona = "OPERATOR"; userPersona = "OPERATOR";
themeMode = if cfg.nightTheme then "NIGHT" else "DAY"; themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
themeColor = "PURPLE"; themeColor = "PURPLE";
+13 -2
View File
@@ -4,7 +4,12 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
services.bitcoind = { services.bitcoind = {
enable = true; enable = true;
package = pkgs.bitcoind-knots; # Keep the normal loopback P2P socket available for local clients such as
# Bisq. Because `address` defaults to 127.0.0.1 this does not expose a
# clearnet or LAN listener. When the existing bitcoind onion service is
# enabled, this also creates its Tor-tagged loopback target on port 8334.
listen = true;
package = pkgs.bitcoind;
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"; dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
txindex = true; txindex = true;
tor.proxy = true; tor.proxy = true;
@@ -16,7 +21,13 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
''; '';
}; };
nix-bitcoin.onionServices.bitcoind.enable = true; nix-bitcoin.onionServices.bitcoind = {
enable = true;
# This is a locally vendored option namespace, not an upstream dependency.
# The onion listener remains available to peers that already know it;
# advertising its address through Bitcoin peer gossip is opt-in in the Hub.
public = config.sovran_systemsOS.features.bitcoin-tor-gossip;
};
nix-bitcoin.onionServices.electrs.enable = true; nix-bitcoin.onionServices.electrs.enable = true;
nix-bitcoin.onionServices.rtl.enable = true; nix-bitcoin.onionServices.rtl.enable = true;
+109 -17
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.
environment.etc."sovran/security_helpers.py" = {
source = ../../app/sovran_systemsos_web/security_helpers.py;
mode = "0444";
user = "root";
group = "root";
};
# ── Safe DDNS update service ─────────────────────────────────────────────
# Reads DDNS update URLs from the JSON store written by the Hub API and
# invokes curl directly — no shell interpolation, no script execution.
# Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh.
systemd.services.sovran-ddns-update = {
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; User = "root";
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
# Harden the service — it only needs network access and read access to
# /var/lib/njalla/ddns_urls.json.
NoNewPrivileges = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/njalla" ];
ReadOnlyPaths = [ "/etc/sovran" ];
ProtectHome = true;
PrivateTmp = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
}; };
unitConfig = {
ConditionPathExists = "!/var/lib/njalla/njalla.sh";
}; };
script = ''
cat > /var/lib/njalla/njalla.sh <<'SCRIPT'
#!/usr/bin/env bash
IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)
## Add DDNS entries below — one curl per line # Run the update every 15 minutes
## Managed via Sovran Hub web interface systemd.timers.sovran-ddns-update = {
SCRIPT description = "Sovran Njal.la DDNS update timer";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "2min";
OnUnitActiveSec = "15min";
Persistent = true;
};
};
chmod 700 /var/lib/njalla/njalla.sh # Install the Python runner script at build time so the service can find it.
# The script is owned by root and not world-writable.
# Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same
# production validator used by the Hub API — before executing any curl call.
# No shell is used; no redirects; no script execution.
# ${IP} placeholder is preserved in stored URLs and substituted at runtime;
# the URL is validated after substitution so any remaining $ is rejected.
system.activationScripts.sovran-ddns-update-script = ''
install -d -m 0755 /var/lib/sovran
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
#!/usr/bin/env python3
"""Sovran safe DDNS update runner.
Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder,
validates each URL using the production _validate_ddns_url() from
/etc/sovran/security_helpers.py, then calls curl per URL.
No shell interpolation. No redirects. No script execution.
"""
import ipaddress, json, os, subprocess, sys
sys.path.insert(0, '/etc/sovran')
try:
from security_helpers import _validate_ddns_url
except ImportError:
sys.exit(1) # validator missing — fail so systemd logs the misconfiguration
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
try:
with open(URLS_FILE) as f:
urls = json.load(f)
if not isinstance(urls, list):
raise ValueError("not a list")
except Exception:
sys.exit(0) # no URLs configured — nothing to do
# Resolve current public IP once
public_ip = ""
try:
r = subprocess.run(
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
capture_output=True, text=True, timeout=10,
)
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
ipaddress.ip_address(raw) # validates — raises if not a real IP
public_ip = raw
except Exception:
pass
if not public_ip:
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
for raw_url in urls:
try:
# Substitute ''${IP} placeholder then validate through production validator.
# After substitution there must be no $ left; _validate_ddns_url rejects
# any remaining $ expression.
url = raw_url.replace("''${IP}", public_ip)
_validate_ddns_url(url)
subprocess.run(
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
timeout=20, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception:
pass
PYEOF
chmod 0500 /var/lib/sovran/ddns-update.py
''; '';
};
} }
+1 -1
View File
@@ -33,7 +33,7 @@
haven = lib.mkForce false; haven = lib.mkForce false;
mempool = lib.mkForce false; mempool = lib.mkForce false;
element-calling = lib.mkForce false; element-calling = lib.mkForce false;
bitcoin-core = lib.mkForce false; bitcoin-tor-gossip = lib.mkForce false;
"nwc-wallets" = lib.mkForce false; "nwc-wallets" = lib.mkForce false;
}; };
+9 -22
View File
@@ -45,23 +45,20 @@
haven = lib.mkEnableOption "Haven NOSTR relay"; haven = lib.mkEnableOption "Haven NOSTR relay";
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer"; mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
element-calling = lib.mkEnableOption "Element Video and Audio Calling"; element-calling = lib.mkEnableOption "Element Video and Audio Calling";
bitcoin-core = lib.mkEnableOption "Bitcoin Core"; bitcoin-tor-gossip = lib.mkEnableOption "Advertise the Bitcoin Core onion service through Bitcoin peer gossip";
"nwc-wallets" = lib.mkEnableOption "Lightning Wallet Connections"; # Compatibility shim for Hub-managed settings from releases where Core
rdp = lib.mkEnableOption "Gnome Remote Desktop"; # was an optional replacement for the default node. Core is now always
sshd = lib.mkEnableOption "SSH remote access"; # selected when the Bitcoin service is enabled.
bitcoin-core = lib.mkOption {
# Deprecated: BIP-110 is now built into mainline Bitcoin Knots and is the
# default node. This option is retained ONLY so that existing machines with
# `sovran_systemsOS.features.bip110 = lib.mkForce true;` left in their local
# custom.nix continue to evaluate. It has no effect and will be removed in a
# future release once the Hub has cleaned up old custom.nix files.
bip110 = lib.mkOption {
type = lib.types.nullOr lib.types.bool; type = lib.types.nullOr lib.types.bool;
default = null; default = null;
internal = true; internal = true;
visible = false; visible = false;
description = "(Deprecated, no-op) BIP-110 is now built into Bitcoin Knots."; description = "Deprecated no-op: Bitcoin Core is the default node implementation.";
}; };
"nwc-wallets" = lib.mkEnableOption "Lightning Wallet Connections";
rdp = lib.mkEnableOption "Gnome Remote Desktop";
sshd = lib.mkEnableOption "SSH remote access";
}; };
# ── Web exposure (controls Caddy vhosts) ────────────────── # ── Web exposure (controls Caddy vhosts) ──────────────────
@@ -103,14 +100,4 @@
}; };
}; };
config = lib.mkIf (config.sovran_systemsOS.features.bip110 != null) {
warnings = [
''
sovran_systemsOS.features.bip110 is deprecated and has no effect:
BIP-110 is now built into mainline Bitcoin Knots, which is the default node.
You can safely remove the `sovran_systemsOS.features.bip110` line from
/etc/nixos/custom.nix. The Sovran Hub will also remove it automatically.
''
];
};
} }
+50 -25
View File
@@ -37,13 +37,10 @@ let
{ label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above"; } { label = "How to Connect"; value = "1. Install an RDP client (e.g. Remmina, Microsoft Remote Desktop)\n2. Create a new RDP connection\n3. Enter the Address above as the host\n4. Enter the Username and Password above"; }
]; } ]; }
] ]
# ── Bitcoin Base (node implementations) ──────────────────── # ── Bitcoin Base ────────────────────────────────────────────
++ lib.optionals cfg.services.bitcoin [ ++ lib.optionals cfg.services.bitcoin [
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [ { name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; } { label = "Tor Bitcoin P2P Address — Reachable only through Tor"; file = "/var/lib/tor/onion/bitcoind/hostname"; suffix = ":8333"; }
]; }
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
]; } ]; }
] ]
# ── Bitcoin Apps (services on top of the node) ───────────── # ── Bitcoin Apps (services on top of the node) ─────────────
@@ -125,6 +122,9 @@ let
role = activeRole; role = activeRole;
services = monitoredServices; services = monitoredServices;
feature_manager = true; feature_manager = true;
feature_states = {
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
};
sovran_version = sovranVersion; sovran_version = sovranVersion;
}); });
@@ -132,13 +132,19 @@ let
"caddy.service" = if pkgs ? caddy then pkgs.caddy.version else "2.8.4"; "caddy.service" = if pkgs ? caddy then pkgs.caddy.version else "2.8.4";
"tor.service" = if pkgs ? tor then pkgs.tor.version else "0.4.8.12"; "tor.service" = if pkgs ? tor then pkgs.tor.version else "0.4.8.12";
"gnome-remote-desktop.service" = if pkgs ? gnome-remote-desktop then pkgs.gnome-remote-desktop.version else "46.0"; "gnome-remote-desktop.service" = if pkgs ? gnome-remote-desktop then pkgs.gnome-remote-desktop.version else "46.0";
"bitcoind.service" = if pkgs ? bitcoind-knots then pkgs.bitcoind-knots.version else (if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0"); "bitcoind.service" = if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0";
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6"; "electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0"; "lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
"rtl.service" = if pkgs ? clightning-rtl then pkgs.clightning-rtl.version else (if pkgs ? rtl then pkgs.rtl.version else "0.15.2"); # 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";
@@ -179,13 +185,14 @@ let
if [ "$RC" -eq 0 ]; then if [ "$RC" -eq 0 ]; then
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──" echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
BOOT_OUT=$(nixos-rebuild boot --flake /etc/nixos --print-build-logs \ # Stream output straight into $LOG (see rebuild-script) so the Hub UI
# shows live progress instead of an empty log during long builds.
nixos-rebuild boot --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \ --option connect-timeout 10 \
--option stalled-download-timeout 90 \ --option stalled-download-timeout 90 \
--option download-attempts 7 \ --option download-attempts 7 \
--option fallback true 2>&1) --option fallback true
BOOT_RC=$? BOOT_RC=$?
echo "$BOOT_OUT"
if [ "$BOOT_RC" -ne 0 ]; then if [ "$BOOT_RC" -ne 0 ]; then
echo "[ERROR] nixos-rebuild boot failed" echo "[ERROR] nixos-rebuild boot failed"
RC=1 RC=1
@@ -234,20 +241,23 @@ let
echo "══════════════════════════════════════════════════" echo "══════════════════════════════════════════════════"
echo "" echo ""
echo "── Rebuilding system configuration ──────────────" echo "── Rebuilding system configuration ──────────────"
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \ # Stream output straight into $LOG (tee'd by the exec redirect above) so
# the Hub UI shows live progress. Capturing the output in a variable
# kept the log empty for the entire build+activation, which made long
# rebuilds can otherwise look like a hang.
nixos-rebuild switch --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \ --option connect-timeout 10 \
--option stalled-download-timeout 90 \ --option stalled-download-timeout 90 \
--option download-attempts 7 \ --option download-attempts 7 \
--option fallback true 2>&1) --option fallback true
SWITCH_RC=$? SWITCH_RC=$?
echo "$SWITCH_OUT"
if [ "$SWITCH_RC" -eq 0 ]; then if [ "$SWITCH_RC" -eq 0 ]; then
echo "" echo ""
echo "══════════════════════════════════════════════════" echo "══════════════════════════════════════════════════"
echo " ✓ Rebuild completed successfully" echo " ✓ Rebuild completed successfully"
echo "══════════════════════════════════════════════════" echo "══════════════════════════════════════════════════"
echo "SUCCESS" > "$STATUS" echo "SUCCESS" > "$STATUS"
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then elif grep -q "switchInhibitors\|Pre-switch checks failed" "$LOG"; then
echo "" echo ""
echo " ✓ Build succeeded — a reboot is required to apply this rebuild" echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
echo " (Critical system components changed; running nixos-rebuild boot instead)" echo " (Critical system components changed; running nixos-rebuild boot instead)"
@@ -272,17 +282,31 @@ let
fi fi
''; '';
# ── Brave launcher wrapper: stable profile dir so Wayland app_id is # ── Brave Origin launcher wrapper: a *persistent* per-user profile dir.
# deterministic and GNOME Shell can match the window to the .desktop # It must NOT be wiped on exit: the Hub's logout marker cookie
# entry (fixes generic gear icon appearing in the dock). # (hub_manual_logout) and the session cookie live in this profile.
# Launching from a fresh/ephemeral profile every time throws away the
# marker, so /auto-login would mint a new session and silently log the
# user straight back in after they signed out and reopened the window.
# A stable directory also keeps the Wayland app_id deterministic so
# GNOME Shell can match the window to the .desktop entry (dock icon).
hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" '' hub-brave-wrapper = pkgs.writeShellScript "sovran-hub-brave.sh" ''
export PATH="${lib.makeBinPath [ pkgs.brave pkgs.coreutils ]}:$PATH" export PATH="${lib.makeBinPath [ pkgs.brave-origin pkgs.coreutils ]}:$PATH"
HUB_DATA="/tmp/sovran-hub-brave-$(id -u)" # Per-user, persistent browser state. $XDG_STATE_HOME keeps it out of the
# way of backups and survives reboots and window close/reopen.
if [ -n "$XDG_STATE_HOME" ]; then
HUB_DATA="$XDG_STATE_HOME/sovran-hub-browser"
else
HUB_DATA="$HOME/.local/state/sovran-hub-browser"
fi
mkdir -p "$HUB_DATA" mkdir -p "$HUB_DATA"
trap '[ -n "$HUB_DATA" ] && rm -rf "$HUB_DATA"' EXIT INT TERM
export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop" export BAMF_DESKTOP_FILE_HINT="/run/current-system/sw/share/applications/sovran-hub.desktop"
export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop" export GIO_LAUNCHED_DESKTOP_FILE="/run/current-system/sw/share/applications/sovran-hub.desktop"
brave --app=http://localhost:8937/auto-login \ # With a persistent profile Brave Origin's one-time "Proceed with Origin
# for free on Linux" onboarding dialog only appears once; keep skipping it
# anyway so it can never block auto-login (Linux-only switch).
brave-origin --app=http://localhost:8937/auto-login \
--skip-origin-startup-dialog \
--class=sovran-hub \ --class=sovran-hub \
--user-data-dir="$HUB_DATA" \ --user-data-dir="$HUB_DATA" \
--password-store=basic \ --password-store=basic \
@@ -317,7 +341,8 @@ let
sovran-hub-web = pkgs.python3Packages.buildPythonApplication { sovran-hub-web = pkgs.python3Packages.buildPythonApplication {
pname = "sovran-systemsos-hub-web"; pname = "sovran-systemsos-hub-web";
version = "1.0.0"; # Keep the package metadata in lockstep with the version shown in the Hub.
version = sovranVersion;
format = "other"; format = "other";
src = ../../app; src = ../../app;
+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()
+44 -15
View File
@@ -44,6 +44,35 @@ let
fi fi
fi fi
# ── Brave Origin migration (existing installs upgrading from `brave`) ──
# The old package's brave-browser.desktop no longer exists, so GNOME drops
# it from the dock and the Browsers folder. Swap the id in place, but only
# when the stale value is still present (never touch user customizations).
if [ -f "$USER_DB" ]; then
FAVS="$(${pkgs.dconf}/bin/dconf read /org/gnome/shell/favorite-apps 2>/dev/null || true)"
if [ -n "$FAVS" ]; then
NEW_FAVS="''${FAVS//brave-browser.desktop/brave-origin.desktop}"
if [ "$NEW_FAVS" != "$FAVS" ]; then
${pkgs.dconf}/bin/dconf write /org/gnome/shell/favorite-apps "$NEW_FAVS"
fi
fi
BROWSER_APPS="$(${pkgs.dconf}/bin/dconf read /org/gnome/desktop/app-folders/folders/Browsers/apps 2>/dev/null || true)"
if [ -n "$BROWSER_APPS" ]; then
NEW_BROWSER_APPS="''${BROWSER_APPS//brave-browser.desktop/brave-origin.desktop}"
if [ "$NEW_BROWSER_APPS" != "$BROWSER_APPS" ]; then
${pkgs.dconf}/bin/dconf write /org/gnome/desktop/app-folders/folders/Browsers/apps "$NEW_BROWSER_APPS"
fi
fi
fi
# A previous fresh install wrote the user's mimeapps.list with the old id;
# XDG gives it precedence over the new system-wide default, so rewrite it.
MIME_LIST="$HOME/.config/mimeapps.list"
if [ -f "$MIME_LIST" ] && ${pkgs.gnugrep}/bin/grep -q 'brave-browser\.desktop' "$MIME_LIST"; then
${pkgs.gnused}/bin/sed -i 's/brave-browser\.desktop/brave-origin.desktop/g' "$MIME_LIST"
fi
# Already applied — skip # Already applied — skip
if [ -f "$STAMP" ]; then if [ -f "$STAMP" ]; then
exit 0 exit 0
@@ -61,11 +90,11 @@ let
mkdir -p "$HOME/.config" mkdir -p "$HOME/.config"
cat > "$HOME/.config/mimeapps.list" << EOF cat > "$HOME/.config/mimeapps.list" << EOF
[Default Applications] [Default Applications]
text/html=brave-browser.desktop text/html=brave-origin.desktop
x-scheme-handler/http=brave-browser.desktop x-scheme-handler/http=brave-origin.desktop
x-scheme-handler/https=brave-browser.desktop x-scheme-handler/https=brave-origin.desktop
x-scheme-handler/about=brave-browser.desktop x-scheme-handler/about=brave-origin.desktop
x-scheme-handler/unknown=brave-browser.desktop x-scheme-handler/unknown=brave-origin.desktop
EOF EOF
${pkgs.dconf}/bin/dconf load / << EOF ${pkgs.dconf}/bin/dconf load / << EOF
@@ -104,7 +133,7 @@ search-filter-time-type='last_modified'
[org/gnome/shell] [org/gnome/shell]
disabled-extensions=['just-perfection-desktop@just-perfection'] disabled-extensions=['just-perfection-desktop@just-perfection']
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'light-style@gnome-shell-extensions.gcampax.github.com'] enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'dash-to-dock-cosmic-@halfmexicanhalfamazing@gmail.com', 'Vitals@CoreCoding.com', 'dash-to-dock@micxgx.gmail.com', 'pop-shell@system76.com', 'date-menu-formatter@marcinjakubowski.github.com', 'light-style@gnome-shell-extensions.gcampax.github.com']
favorite-apps=['brave-browser.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop'] favorite-apps=['brave-origin.desktop', 'org.gnome.Settings.desktop', 'org.gnome.Nautilus.desktop', 'sovran-hub.desktop', 'org.gnome.Software.desktop', 'org.gnome.Geary.desktop', 'org.gnome.Contacts.desktop', 'org.gnome.Calendar.desktop', 'sparrow.desktop', 'Bisq.desktop', 'bisq2.desktop']
welcome-dialog-last-shown-version='48.4' welcome-dialog-last-shown-version='48.4'
[org/gnome/desktop/app-folders] [org/gnome/desktop/app-folders]
@@ -112,7 +141,7 @@ folder-children=['Browsers', 'Office', 'Terminal', 'Chat', 'Bitcoin', 'Media', '
[org/gnome/desktop/app-folders/folders/Browsers] [org/gnome/desktop/app-folders/folders/Browsers]
name='Browsers' name='Browsers'
apps=['brave-browser.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop'] apps=['brave-origin.desktop', 'firefox.desktop', 'org.gnome.Epiphany.desktop']
[org/gnome/desktop/app-folders/folders/Office] [org/gnome/desktop/app-folders/folders/Office]
name='Office' name='Office'
@@ -269,7 +298,7 @@ in
]; ];
favorite-apps = [ favorite-apps = [
"brave-browser.desktop" "brave-origin.desktop"
"org.gnome.Settings.desktop" "org.gnome.Settings.desktop"
"org.gnome.Nautilus.desktop" "org.gnome.Nautilus.desktop"
"sovran-hub.desktop" "sovran-hub.desktop"
@@ -292,7 +321,7 @@ in
"org/gnome/desktop/app-folders/folders/Browsers" = { "org/gnome/desktop/app-folders/folders/Browsers" = {
name = "Browsers"; name = "Browsers";
apps = [ apps = [
"brave-browser.desktop" "brave-origin.desktop"
"firefox.desktop" "firefox.desktop"
"org.gnome.Epiphany.desktop" "org.gnome.Epiphany.desktop"
]; ];
@@ -433,13 +462,13 @@ in
]; ];
xdg.mime.defaultApplications = { xdg.mime.defaultApplications = {
"text/html" = "brave-browser.desktop"; "text/html" = "brave-origin.desktop";
"x-scheme-handler/http" = "brave-browser.desktop"; "x-scheme-handler/http" = "brave-origin.desktop";
"x-scheme-handler/https" = "brave-browser.desktop"; "x-scheme-handler/https" = "brave-origin.desktop";
"x-scheme-handler/about" = "brave-browser.desktop"; "x-scheme-handler/about" = "brave-origin.desktop";
"x-scheme-handler/unknown" = "brave-browser.desktop"; "x-scheme-handler/unknown" = "brave-origin.desktop";
}; };
environment.sessionVariables.BROWSER = "brave-browser"; environment.sessionVariables.BROWSER = "brave-origin";
} }
+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
View File
@@ -35,7 +35,6 @@
./nwc-wallets.nix ./nwc-wallets.nix
./element-calling.nix ./element-calling.nix
./mempool.nix ./mempool.nix
./bitcoin-core.nix
./rdp.nix ./rdp.nix
./sshd.nix ./sshd.nix
]; ];
+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
+259 -175
View File
@@ -20,6 +20,11 @@
set -euo pipefail set -euo pipefail
# Always operate from the repository root, regardless of the caller's cwd.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
# Colors # Colors
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@@ -62,13 +67,28 @@ echo
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Helper: Get latest tag # Helper: Get latest tag
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# GitHub release tags are fetched into a private namespace instead of
# refs/tags/. GitHub and Gitea contain some same-named historical tags that
# point to different objects; sharing refs/tags/ would make either fetch fail
# with "would clobber existing tag".
GITHUB_TAG_NAMESPACE="refs/release-tags/github"
get_latest_tag() { get_latest_tag() {
local exclude="${1:-}" local exclude="${1:-}"
if [[ -n "$exclude" ]]; then local tag
git tag --list 'v*' --sort=-version:refname | grep -v -x -E "v?${exclude#v}|${exclude}" | head -1 || echo ""
else while IFS= read -r tag; do
git tag --list 'v*' --sort=-version:refname | head -1 || echo "" if [[ -n "$exclude" && "$tag" == "v${exclude#v}" ]]; then
continue
fi fi
printf '%s\n' "$tag"
return 0
done < <(git for-each-ref \
--sort=-version:refname \
--format='%(refname:strip=3)' \
"${GITHUB_TAG_NAMESPACE}/v*")
return 0
} }
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -90,10 +110,84 @@ suggest_next_version() {
} }
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 0: Fetch everything # Step 0: Preflight and fetch everything
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo -e "${BLUE}Step 0: Fetching latest from all remotes...${NC}" echo -e "${BLUE}Step 0: Running release preflight...${NC}"
git fetch --all --tags 2>/dev/null || true
# A release must start from an exact, committed, reproducible tree. This also
# prevents release metadata generated below from being mixed with local work.
if [[ -n "$(git status --porcelain)" ]]; then
echo -e "${RED}Error: the working tree is not clean. Commit, stash, or remove local changes first.${NC}" >&2
git status --short >&2
exit 1
fi
for remote in "$GITHUB_REMOTE" "$GITEA_REMOTE"; do
if ! git remote | grep -q -x "$remote"; then
echo -e "${RED}Error: required remote '$remote' is not configured.${NC}" >&2
exit 1
fi
done
for command_name in gh curl; do
if ! command -v "$command_name" >/dev/null 2>&1; then
echo -e "${RED}Error: required command '$command_name' is not installed.${NC}" >&2
exit 1
fi
done
if ! gh auth status >/dev/null 2>&1; then
echo -e "${RED}Error: GitHub CLI is not authenticated. Run 'gh auth login' first.${NC}" >&2
exit 1
fi
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo
read -rsp "Enter your Gitea API token (input hidden): " GITEA_TOKEN
echo
fi
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo -e "${RED}Error: a GITEA_TOKEN with write:repository scope is required.${NC}" >&2
exit 1
fi
echo -e " Fetching GitHub (${GITHUB_REMOTE}) and Gitea (${GITEA_REMOTE})..."
# Never fetch either host's tags into refs/tags/. Historical tags with the same
# name differ between the hosts, and Git correctly refuses to clobber them.
git fetch "$GITHUB_REMOTE" --prune --no-tags
git fetch "$GITEA_REMOTE" --prune --no-tags
git fetch "$GITHUB_REMOTE" --prune --no-tags \
"+refs/tags/*:${GITHUB_TAG_NAMESPACE}/*"
GITHUB_MAIN_REF="refs/remotes/${GITHUB_REMOTE}/main"
GITEA_STAGING_REF="refs/remotes/${GITEA_REMOTE}/staging-dev"
if ! git rev-parse --verify "$GITHUB_MAIN_REF" >/dev/null 2>&1; then
echo -e "${RED}Error: cannot resolve GitHub main at ${GITHUB_MAIN_REF}.${NC}" >&2
exit 1
fi
if ! git rev-parse --verify "$GITEA_STAGING_REF" >/dev/null 2>&1; then
echo -e "${RED}Error: cannot resolve Gitea staging-dev at ${GITEA_STAGING_REF}.${NC}" >&2
exit 1
fi
HEAD_COMMIT=$(git rev-parse HEAD)
GITHUB_MAIN_COMMIT=$(git rev-parse "$GITHUB_MAIN_REF")
GITEA_STAGING_COMMIT=$(git rev-parse "$GITEA_STAGING_REF")
if [[ "$GITHUB_MAIN_COMMIT" != "$GITEA_STAGING_COMMIT" ]]; then
echo -e "${RED}Error: GitHub main and Gitea staging-dev are not synchronized.${NC}" >&2
echo " GitHub main : $GITHUB_MAIN_COMMIT" >&2
echo " Gitea staging-dev : $GITEA_STAGING_COMMIT" >&2
exit 1
fi
if [[ "$HEAD_COMMIT" != "$GITHUB_MAIN_COMMIT" ]]; then
echo -e "${RED}Error: local HEAD is not the synchronized release candidate.${NC}" >&2
echo " Local HEAD : $HEAD_COMMIT" >&2
echo " Remote HEAD : $GITHUB_MAIN_COMMIT" >&2
echo "Update/check out the synchronized commit, then run the script again." >&2
exit 1
fi
echo -e " ${GREEN}✓${NC} Clean tree; GitHub main and Gitea staging-dev match local HEAD"
LATEST_TAG=$(get_latest_tag) LATEST_TAG=$(get_latest_tag)
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG") NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
@@ -117,6 +211,23 @@ fi
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
TAG="v${VERSION}" TAG="v${VERSION}"
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${RED}Error: version must use MAJOR.MINOR.PATCH format (for example, 1.1.1).${NC}" >&2
exit 1
fi
remote_has_tag() {
local remote="$1"
[[ -n "$(git ls-remote --tags "$remote" "refs/tags/${TAG}" "refs/tags/${TAG}^{}")" ]]
}
if git show-ref --verify --quiet "refs/tags/${TAG}" || \
git show-ref --verify --quiet "${GITHUB_TAG_NAMESPACE}/${TAG}" || \
remote_has_tag "$GITHUB_REMOTE" || remote_has_tag "$GITEA_REMOTE"; then
echo -e "${RED}Error: tag ${TAG} already exists locally or on a remote.${NC}" >&2
echo "Refusing to move or overwrite an existing release tag." >&2
exit 1
fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Get release message # Get release message
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -193,20 +304,26 @@ generate_release_notes() {
printf '%s' "$notes" printf '%s' "$notes"
} }
# Build the notes from commits since the previous tag (exclude the target tag if already present) # Build notes from the previous canonical GitHub tag. Use its private ref so a
# conflicting local or Gitea tag with the same name cannot select the wrong
# commit.
PREV_TAG=$(get_latest_tag "$TAG") PREV_TAG=$(get_latest_tag "$TAG")
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG" >/dev/null; then PREV_TAG_REF="${GITHUB_TAG_NAMESPACE}/${PREV_TAG}"
COMMIT_RANGE="${PREV_TAG}..HEAD" if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG_REF" >/dev/null; then
COMMIT_RANGE="${PREV_TAG_REF}..HEAD"
COMMIT_RANGE_DISPLAY="${PREV_TAG}..HEAD"
else else
COMMIT_RANGE="HEAD" COMMIT_RANGE="HEAD"
COMMIT_RANGE_DISPLAY="HEAD"
fi fi
echo echo
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE}...${NC}" echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE_DISPLAY}...${NC}"
RELEASE_NOTES="$(generate_release_notes "$COMMIT_RANGE")" RELEASE_NOTES="$(generate_release_notes "$COMMIT_RANGE")"
# Let the user review/edit the generated notes before publishing # Let the user review/edit the generated notes before publishing
NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")" NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")"
trap 'rm -f "$NOTES_FILE"' EXIT
{ {
echo "## Sovran_SystemsOS ${TAG}" echo "## Sovran_SystemsOS ${TAG}"
echo echo
@@ -246,95 +363,45 @@ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
fi fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 1: Push tested code to Gitea (stable & staging-dev) # Step 1: Prepare and commit all release metadata
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo echo
echo -e "${BLUE}Step 1: Pushing tested code to Gitea (stable & staging-dev)...${NC}" echo -e "${BLUE}Step 1: Preparing release metadata for ${TAG}...${NC}"
if git remote | grep -q -x "${GITEA_REMOTE}"; then
git push "${GITEA_REMOTE}" HEAD:stable --force-with-lease || echo -e " ${YELLOW}⚠ Push to ${GITEA_REMOTE} (stable) failed.${NC}"
git push "${GITEA_REMOTE}" HEAD:staging-dev --force-with-lease || echo -e " ${YELLOW}⚠ Push to ${GITEA_REMOTE} (staging-dev) failed.${NC}"
else
echo -e " ${YELLOW}⚠ Remote '${GITEA_REMOTE}' not found in git — skipping Gitea push.${NC}"
fi
# ───────────────────────────────────────────────────────────────────────────── # VERSION drives ISO naming.
# Step 2: Create annotated tag
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 2: Creating annotated tag ${TAG}...${NC}"
git tag -f -a "${TAG}" -m "${RELEASE_MESSAGE}
- Stable release of Sovran_SystemsOS
- See CHANGELOG.md for full details" || true
if git remote | grep -q -x "${GITEA_REMOTE}"; then
git push "${GITEA_REMOTE}" "${TAG}" || echo -e " ${YELLOW}⚠ Tag push to ${GITEA_REMOTE} failed.${NC}"
else
echo -e " ${YELLOW}⚠ Remote '${GITEA_REMOTE}' not found in git — skipping Gitea tag push.${NC}"
fi
if git remote | grep -q -x "${GITHUB_REMOTE}"; then
git push "${GITHUB_REMOTE}" "${TAG}" || echo -e " ${YELLOW}⚠ Tag push to ${GITHUB_REMOTE} failed.${NC}"
fi
# Update VERSION file for ISO builds
echo "${VERSION}" > VERSION echo "${VERSION}" > VERSION
git add VERSION
git commit -m "chore: bump VERSION to ${TAG} for ISO naming" || true
echo -e " ${GREEN}✓${NC} VERSION file updated to ${VERSION}"
# ─────────────────────────────────────────────────────────────────────────────
# Step 2b: Auto-update README.md ISO download references
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 2b: Updating README.md ISO references to ${VERSION}...${NC}"
# Update every versioned ISO filename in README.md.
README_FILE="README.md" README_FILE="README.md"
if [ -f "$README_FILE" ]; then if [[ ! -f "$README_FILE" ]]; then
# Extract the version currently used in the README's ISO filenames, echo -e "${RED}Error: ${README_FILE} not found.${NC}" >&2
# e.g. "Sovran_SystemsOS-1.0.6.iso" -> "1.0.6" exit 1
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" | head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//') fi
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" \
if [ -n "$OLD_ISO_VER" ] && [ "$OLD_ISO_VER" != "$VERSION" ]; then | head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
# Portable in-place edit (works with GNU sed and BSD/macOS sed) if [[ -z "$OLD_ISO_VER" ]]; then
echo -e "${RED}Error: no versioned ISO reference found in ${README_FILE}.${NC}" >&2
exit 1
fi
if [[ "$OLD_ISO_VER" != "$VERSION" ]]; then
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \ sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
"$README_FILE" > "$README_FILE.tmp" && mv "$README_FILE.tmp" "$README_FILE" "$README_FILE" > "$README_FILE.tmp"
mv "$README_FILE.tmp" "$README_FILE"
UPDATED=$(grep -c "Sovran_SystemsOS-${VERSION}" "$README_FILE")
echo -e " ${GREEN}✓${NC} README.md: ${OLD_ISO_VER} -> ${VERSION} (${UPDATED} references updated)"
git add "$README_FILE"
git commit -m "docs: update README ISO download links to ${TAG}" || true
echo -e " ${GREEN}✓${NC} Committed README update"
else
echo -e " ${YELLOW}⚠${NC} README.md already references ${VERSION} or no versioned ISO links found — skipping"
fi
else
echo -e " ${YELLOW}⚠${NC} README.md not found — skipping"
fi fi
# ───────────────────────────────────────────────────────────────────────────── # Add the changelog entry before tagging so the tag and stable branch contain it.
# Step 3: Auto-update CHANGELOG.md
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 3: Updating ${CHANGELOG_FILE}...${NC}"
TODAY=$(date +%Y-%m-%d) TODAY=$(date +%Y-%m-%d)
# Create new changelog entry from the generated notes (no placeholders)
NEW_ENTRY="## [${VERSION}] - ${TODAY} NEW_ENTRY="## [${VERSION}] - ${TODAY}
${RELEASE_NOTES} ${RELEASE_NOTES}
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG} [${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
" "
# Prepend to changelog (after the header) if [[ ! -f "$CHANGELOG_FILE" ]]; then
if [ -f "$CHANGELOG_FILE" ]; then echo -e "${RED}Error: ${CHANGELOG_FILE} not found.${NC}" >&2
# Backup exit 1
cp "$CHANGELOG_FILE" "${CHANGELOG_FILE}.bak" fi
awk -v new_entry="$NEW_ENTRY" '
# Insert new section after the first --- line
awk -v new_entry="$NEW_ENTRY" '
BEGIN { printed=0 } BEGIN { printed=0 }
/^---$/ && !printed { /^---$/ && !printed {
print print
@@ -344,116 +411,134 @@ if [ -f "$CHANGELOG_FILE" ]; then
next next
} }
{ print } { print }
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" && mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE" END { if (!printed) exit 2 }
rm -f "${CHANGELOG_FILE}.bak" ' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" || {
rm -f "${CHANGELOG_FILE}.tmp"
echo -e "${RED}Error: could not find the changelog insertion marker.${NC}" >&2
exit 1
}
mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
echo -e " ${GREEN}✓${NC} CHANGELOG.md updated with new section for ${TAG}" git add VERSION "$README_FILE" "$CHANGELOG_FILE"
else
echo -e " ${YELLOW}⚠${NC} CHANGELOG.md not found — skipping"
fi
# Commit the changelog update
git add "$CHANGELOG_FILE"
if git diff --cached --quiet; then if git diff --cached --quiet; then
echo " (No changes to commit in changelog)" echo -e "${RED}Error: release preparation produced no changes.${NC}" >&2
else exit 1
git commit -m "docs: update CHANGELOG.md for ${TAG}"
echo -e " ${GREEN}✓${NC} Committed changelog update"
# Ask if user wants to push
echo
read -rp "Push the changelog commit to GitHub (main) and Gitea (staging-dev) now? (y/N): " push_confirm
if [[ "$push_confirm" =~ ^[Yy]$ ]]; then
if git remote | grep -q -x "${GITHUB_REMOTE}"; then
echo -e "${BLUE}Pushing changelog commit to GitHub main...${NC}"
if git push "${GITHUB_REMOTE}" HEAD:main; then
echo -e " ${GREEN}✓${NC} Changelog pushed to GitHub main (${GITHUB_REMOTE})"
else
echo -e " ${YELLOW}⚠ Push to GitHub main failed — verify credentials or remote name.${NC}"
fi
fi
if git remote | grep -q -x "${GITEA_REMOTE}"; then
echo -e "${BLUE}Pushing changelog commit to Gitea staging-dev...${NC}"
if git push "${GITEA_REMOTE}" HEAD:staging-dev; then
echo -e " ${GREEN}✓${NC} Changelog pushed to Gitea staging-dev (${GITEA_REMOTE})"
else
echo -e " ${YELLOW}⚠ Push to Gitea staging-dev failed.${NC}"
fi
fi
else
echo " (Changelog commit left local — remember to push later)"
fi
fi fi
git commit -m "chore(release): prepare ${TAG}"
RELEASE_COMMIT=$(git rev-parse HEAD)
echo -e " ${GREEN}✓${NC} VERSION, README, and CHANGELOG committed"
echo -e " Release commit: ${CYAN}${RELEASE_COMMIT}${NC}"
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 4: Create GitHub Release via gh CLI # Step 2: Publish the final release commit to every release branch
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo echo
echo -e "${BLUE}Step 4: Creating GitHub Release...${NC}" echo -e "${BLUE}Step 2: Publishing the final release commit...${NC}"
if command -v gh &>/dev/null; then # GitHub main and Gitea staging-dev were verified equal during preflight, so
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }" # these are normal fast-forward pushes. Stable is an intentional promotion and
title_suffix="${title_suffix#Sovran_SystemsOS * — }" # uses a lease to prevent overwriting a branch changed since the fetch.
if gh release create "${TAG}" \ git push "$GITHUB_REMOTE" HEAD:main
git push "$GITEA_REMOTE" HEAD:staging-dev
git push "$GITEA_REMOTE" HEAD:stable --force-with-lease
# Verify all three branch tips before creating an immutable release tag.
remote_branch_commit() {
local remote="$1"
local branch="$2"
git ls-remote "$remote" "refs/heads/${branch}" | awk 'NR == 1 { print $1 }'
}
PUBLISHED_GITHUB=$(remote_branch_commit "$GITHUB_REMOTE" main)
PUBLISHED_STAGING=$(remote_branch_commit "$GITEA_REMOTE" staging-dev)
PUBLISHED_STABLE=$(remote_branch_commit "$GITEA_REMOTE" stable)
if [[ "$PUBLISHED_GITHUB" != "$RELEASE_COMMIT" || \
"$PUBLISHED_STAGING" != "$RELEASE_COMMIT" || \
"$PUBLISHED_STABLE" != "$RELEASE_COMMIT" ]]; then
echo -e "${RED}Error: post-push verification failed; no release tag was created.${NC}" >&2
echo " Expected : $RELEASE_COMMIT" >&2
echo " GitHub main : ${PUBLISHED_GITHUB:-missing}" >&2
echo " Gitea staging-dev : ${PUBLISHED_STAGING:-missing}" >&2
echo " Gitea stable : ${PUBLISHED_STABLE:-missing}" >&2
exit 1
fi
echo -e " ${GREEN}✓${NC} GitHub main, Gitea staging-dev, and Gitea stable all match"
# ─────────────────────────────────────────────────────────────────────────────
# Step 3: Tag the final release commit and publish the tag
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 3: Creating annotated tag ${TAG} on ${RELEASE_COMMIT}...${NC}"
git tag -a "$TAG" -m "${RELEASE_MESSAGE}
- Stable release of Sovran_SystemsOS
- See CHANGELOG.md for full details" "$RELEASE_COMMIT"
git push "$GITHUB_REMOTE" "$TAG"
git push "$GITEA_REMOTE" "$TAG"
# For an annotated tag, ^{} resolves the commit referenced by the tag object.
remote_tag_commit() {
local remote="$1"
git ls-remote "$remote" "refs/tags/${TAG}^{}" | awk 'NR == 1 { print $1 }'
}
GITHUB_TAG_COMMIT=$(remote_tag_commit "$GITHUB_REMOTE")
GITEA_TAG_COMMIT=$(remote_tag_commit "$GITEA_REMOTE")
if [[ "$GITHUB_TAG_COMMIT" != "$RELEASE_COMMIT" || "$GITEA_TAG_COMMIT" != "$RELEASE_COMMIT" ]]; then
echo -e "${RED}Error: published tag verification failed.${NC}" >&2
exit 1
fi
echo -e " ${GREEN}✓${NC} ${TAG} points to the final release commit on GitHub and Gitea"
# ─────────────────────────────────────────────────────────────────────────────
# Step 4: Create GitHub release via gh CLI
# ─────────────────────────────────────────────────────────────────────────────
echo
echo -e "${BLUE}Step 4: Creating GitHub release...${NC}"
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
gh release create "$TAG" \
--repo naturallaw777/Sovran_SystemsOS \ --repo naturallaw777/Sovran_SystemsOS \
--title "${TAG} — ${title_suffix}" \ --title "${TAG} — ${title_suffix}" \
--notes-file "${NOTES_FILE}"; then --notes-file "$NOTES_FILE"
echo -e " ${GREEN}✓${NC} GitHub release created successfully" echo -e " ${GREEN}✓${NC} GitHub release created successfully"
else
echo -e " ${YELLOW}⚠${NC} GitHub release creation failed (check 'gh auth status' or create via web GUI)"
fi
else
echo -e " ${YELLOW}⚠${NC} gh CLI not found — skipping GitHub release (create via GitHub web GUI)"
fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 5: Create Gitea Release via API # Step 5: Create Gitea release via API
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo echo
echo -e "${BLUE}Step 5: Creating Gitea Release via API...${NC}" echo -e "${BLUE}Step 5: Creating Gitea release via API...${NC}"
# ── Gitea Token Handling ───────────────────────────────────────────────────── GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo # Build JSON safely because release notes may contain quotes and newlines.
echo -e "${YELLOW}GITEA_TOKEN is not set.${NC}" if command -v jq >/dev/null 2>&1; then
read -rsp "Enter your Gitea API token (input will be hidden): " GITEA_TOKEN PAYLOAD=$(jq -n \
echo --arg tag "$TAG" \
if [[ -z "$GITEA_TOKEN" ]]; then --arg name "$TAG — Stable Release" \
echo -e " ${YELLOW}⚠${NC} No token provided — skipping Gitea release" --arg body "$RELEASE_BODY" \
GITEA_TOKEN="" '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
fi else
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': sys.argv[1] + ' — Stable Release', 'body': open(sys.argv[2]).read(), 'draft': False, 'prerelease': False}))" "$TAG" "$NOTES_FILE")
fi fi
if [[ -n "${GITEA_TOKEN:-}" ]]; then RESPONSE_FILE=$(mktemp "/tmp/gitea-release-${TAG}.XXXXXX.json")
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS" HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' -X POST \
# Build JSON payload safely (release body may contain quotes/newlines)
if command -v jq &>/dev/null; then
PAYLOAD=$(jq -n \
--arg tag "${TAG}" \
--arg name "${TAG} — Stable Release" \
--arg body "${RELEASE_BODY}" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
else
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': sys.argv[1] + ' — Stable Release', 'body': open(sys.argv[2]).read(), 'draft': False, 'prerelease': False}))" "${TAG}" "${NOTES_FILE}")
fi
RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "${PAYLOAD}" \ -d "$PAYLOAD" \
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases" 2>/dev/null || echo "") "${GITEA_API_URL}/repos/${GITEA_REPO}/releases")
if echo "$RESPONSE" | grep -q '"id"'; then if [[ "$HTTP_STATUS" != "201" ]]; then
echo -e " ${GREEN}✓${NC} Gitea release created successfully" echo -e "${RED}Error: Gitea release creation failed (HTTP ${HTTP_STATUS}).${NC}" >&2
elif echo "$RESPONSE" | grep -q "write:repository"; then cat "$RESPONSE_FILE" >&2
echo -e " ${YELLOW}⚠${NC} Gitea token scope issue: your token requires the 'write:repository' scope (currently has write:package)." rm -f "$RESPONSE_FILE"
echo " To fix: In Gitea, navigate to Settings → Applications → Manage Access Tokens and generate a token with 'write:repository'." exit 1
else
echo -e " ${YELLOW}⚠${NC} Gitea release creation failed or already exists"
echo " Response: $RESPONSE"
fi
fi fi
rm -f "$RESPONSE_FILE"
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Final Summary # Final Summary
@@ -468,8 +553,7 @@ echo " • Build the installer ISO:"
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage" echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
echo " • Package, verify, and upload ISO to CDN:" echo " • Package, verify, and upload ISO to CDN:"
echo " ./scripts/upload-cdn.sh --upload" echo " ./scripts/upload-cdn.sh --upload"
echo " • Review and enhance the new section in CHANGELOG.md" echo " • Verify the ISO download and checksum from the public CDN"
echo " • Push changes: git push ${GITHUB_REMOTE} HEAD:main && git push ${GITEA_REMOTE} HEAD:staging-dev"
echo " • Verify releases on both GitHub and Gitea" echo " • Verify releases on both GitHub and Gitea"
echo echo
echo -e "${CYAN}Tag created: ${TAG}${NC}" echo -e "${CYAN}Tag created: ${TAG}${NC}"
+78
View File
@@ -0,0 +1,78 @@
"""Regression tests for the Bitcoin Core Tor IBD gossip Hub option.
These tests intentionally avoid importing the FastAPI application so they can run
in the repository's lightweight test environment without NixOS service access.
"""
import ast
import os
import unittest
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
def _read(*parts: str) -> str:
with open(os.path.join(_REPO_ROOT, *parts), encoding="utf-8") as source:
return source.read()
def _literal_assignment(source: str, name: str):
tree = ast.parse(source)
for node in tree.body:
if isinstance(node, ast.Assign):
if any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
return ast.literal_eval(node.value)
raise AssertionError(f"Assignment {name} was not found")
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
def test_bitcoind_loopback_listener_is_always_enabled(self):
ecosystem = _read("modules", "bitcoinecosystem.nix")
self.assertIn("listen = true;", ecosystem)
self.assertIn("peerbloomfilters=1", ecosystem)
def test_gossip_is_opt_in(self):
ecosystem = _read("modules", "bitcoinecosystem.nix")
self.assertIn(
"public = config.sovran_systemsOS.features.bitcoin-tor-gossip;",
ecosystem,
)
def test_hub_option_and_evaluated_state_are_declared(self):
roles = _read("modules", "core", "roles.nix")
hub = _read("modules", "core", "sovran-hub.nix")
self.assertIn("bitcoin-tor-gossip = lib.mkEnableOption", roles)
self.assertIn("bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;", hub)
class TestBitcoinTorGossipHubWiring(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server_source = _read("app", "sovran_systemsos_web", "server.py")
cls.registry = _literal_assignment(cls.server_source, "FEATURE_REGISTRY")
def test_feature_is_modal_only_and_explains_risk(self):
feature = next(item for item in self.registry if item["id"] == "bitcoin-tor-gossip")
self.assertTrue(feature["modal_only"])
self.assertIn("bitcoin-service", feature["requires"])
self.assertTrue(any("clearnet" in detail for detail in feature["details"]))
self.assertTrue(any("bandwidth" in detail for detail in feature["details"]))
def test_backend_rejects_gossip_without_bitcoin_service(self):
self.assertIn(
"Enable the Bitcoin service before advertising its Tor IBD service.",
self.server_source,
)
def test_core_modal_renders_and_controls_the_option(self):
frontend = _read(
"app", "sovran_systemsos_web", "static", "js", "service-detail.js"
)
self.assertIn("Tor IBD Service Advertising", frontend)
self.assertIn("svc-detail-related-feature-btn", frontend)
self.assertIn("handleFeatureToggle(relatedFeat", frontend)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large. Load diff