Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cf611d44c | ||
|
|
1e3dd2dd76 | ||
|
|
45322db41a | ||
|
|
2a1d73af25 | ||
|
|
e83c7fdb18 | ||
|
|
587c19c2a5 | ||
|
|
c862ed5806 | ||
|
|
1d79acb083 | ||
|
|
5edf594eac | ||
|
|
94dba571c1 | ||
|
|
de32699539 | ||
|
|
892397589f | ||
|
|
30b753ec40 | ||
|
|
e98a0b7554 | ||
|
|
3541f6baa1 | ||
|
|
e0ee8a50a4 | ||
|
|
8f89a4350a | ||
|
|
cb2b49174e | ||
|
|
6563acef0a | ||
|
|
a053ee77fb |
+131
-1
@@ -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
|
||||
|
||||
### Added
|
||||
@@ -160,7 +290,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Integrated Sparrow Wallet, Bisq, and Bisq 2
|
||||
- Comprehensive Sovran Hub for service management
|
||||
- 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
|
||||
- Automated installer with graphical GNOME desktop
|
||||
- Tor integration and onion services for all major components
|
||||
|
||||
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
|
||||
are ready.
|
||||
|
||||
[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) ·
|
||||
[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)
|
||||
|
||||
<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
|
||||
company holds user funds, and no exchange account stands between buyers and
|
||||
sellers.
|
||||
- **Verify your own Bitcoin** with a full node: [Bitcoin
|
||||
Knots](https://bitcoinknots.org) and
|
||||
- **Verify your own Bitcoin** with a full node: [Bitcoin Core](https://bitcoin.org) and
|
||||
[Electrs](https://github.com/romanz/electrs), so your wallets connect to
|
||||
*your* node instead of a stranger's.
|
||||
- **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
|
||||
|
||||
- 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
|
||||
Wallet Connect (NWC) connections
|
||||
- BTCPay Server
|
||||
- Sparrow Wallet, Bisq, and Bisq 2, with automatic wallet-to-node connections
|
||||
- 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.
|
||||
|
||||
@@ -186,7 +185,7 @@ hardware you control.
|
||||
### Your 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
|
||||
- File management, email, calendar, and office applications
|
||||
- System monitoring and administration tools
|
||||
@@ -202,7 +201,7 @@ Bitcoin and self-hosting infrastructure runs on the machine.
|
||||
| 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 |
|
||||
| **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 |
|
||||
|
||||
**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
|
||||
|
||||
- [Download Sovran_SystemsOS-1.0.6.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.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](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso)
|
||||
- [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
|
||||
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:
|
||||
|
||||
```bash
|
||||
sha256sum --check Sovran_SystemsOS-1.0.6.iso.sha256
|
||||
sha256sum --check Sovran_SystemsOS-1.1.1.iso.sha256
|
||||
```
|
||||
|
||||
A successful comparison reports:
|
||||
|
||||
```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.
|
||||
|
||||
</details>
|
||||
@@ -381,11 +380,11 @@ against the checksum file manually.
|
||||
Open Terminal in the download folder and run:
|
||||
|
||||
```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
|
||||
`Sovran_SystemsOS-1.0.6.iso.sha256`.
|
||||
`Sovran_SystemsOS-1.1.1.iso.sha256`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -395,7 +394,7 @@ Compare the value shown in Terminal with the value inside
|
||||
Open PowerShell in the download folder and run:
|
||||
|
||||
```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.
|
||||
@@ -410,7 +409,7 @@ match exactly.
|
||||
|
||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||
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
|
||||
carefully.
|
||||
4. Choose **Flash** and wait for the writing and verification process to
|
||||
@@ -688,7 +687,6 @@ rebuilds the machine into the selected declarative state.
|
||||
| Shared credentials | `modules/credentials.nix` |
|
||||
| Bitcoin and Lightning stack | `modules/bitcoinecosystem.nix` |
|
||||
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
||||
| Optional Bitcoin Core in place of Knots | `modules/bitcoin-core.nix` |
|
||||
| Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` |
|
||||
| Matrix Synapse | `modules/synapse.nix` |
|
||||
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
||||
@@ -777,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:
|
||||
|
||||
- [Bitcoin Knots](https://github.com/bitcoinknots/bitcoin)
|
||||
- [Bitcoin Core](https://github.com/bitcoin/bitcoin)
|
||||
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
|
||||
- [Bisq](https://github.com/bisq-network/bisq)
|
||||
@@ -855,7 +852,7 @@ primary location for collaboration. Please read our
|
||||
## Privacy. Sovereignty. Bitcoin.
|
||||
|
||||
[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)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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/BraveSoftware/Brave-Browser/*/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='.thumbnails/' \
|
||||
--exclude='.xsession-errors' \
|
||||
|
||||
@@ -11,7 +11,11 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
# ── Nix string escaping ────────────────────────────────────────────────────────
|
||||
@@ -239,3 +243,71 @@ def _validate_ssh_pubkey(key: str) -> str:
|
||||
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
|
||||
|
||||
+175
-280
@@ -52,6 +52,8 @@ from .security_helpers import (
|
||||
_SSH_PUBKEY_ALGORITHMS,
|
||||
_bech32_decode,
|
||||
_bech32_convertbits_decode,
|
||||
load_session_store,
|
||||
save_session_store,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -140,12 +142,34 @@ AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
|
||||
FREE_PASSWORD_FILE = "/var/lib/secrets/free-password"
|
||||
FREE_PASSWORD_FILE_WEB = "/var/lib/secrets/free-password-web"
|
||||
MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass"
|
||||
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
|
||||
SESSION_COOKIE_NAME = "hub_session"
|
||||
SESSION_MAX_AGE = 86400 # 24 hours
|
||||
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
|
||||
SESSION_COOKIE_NAME = "hub_session"
|
||||
MANUAL_LOGOUT_COOKIE_NAME = "hub_manual_logout"
|
||||
SESSION_MAX_AGE = 86400 # 24 hours
|
||||
# Chromium limits persistent cookies to roughly 400 days. This marker only
|
||||
# suppresses desktop auto-login until the next successful password login.
|
||||
MANUAL_LOGOUT_MAX_AGE = 400 * 86400
|
||||
|
||||
# In-memory session store: token → expiry timestamp (float)
|
||||
# Sessions are persisted here so logins survive a restart of the Hub service.
|
||||
# nixos-rebuild switch restarts sovran-hub-web.service during activation (its
|
||||
# unit definition changes with every feature toggle — e.g. the Bitcoin
|
||||
# PATH). Without persistence the browser session dies mid-rebuild, the
|
||||
# /api/rebuild/status polling starts receiving 401s and the rebuild modal
|
||||
# hangs forever showing "Applying changes…". The file lives in
|
||||
# /var/lib/secrets so a security reset wipes it and forces a re-login, like
|
||||
# the session secret itself.
|
||||
SESSIONS_FILE = "/var/lib/secrets/hub-sessions.json"
|
||||
|
||||
# Session store: token → expiry timestamp (float). Loaded lazily from
|
||||
# SESSIONS_FILE on first use and written back on every meaningful change.
|
||||
_sessions: dict[str, float] = {}
|
||||
_sessions_loaded = False
|
||||
_sessions_lock = Lock()
|
||||
|
||||
# Sliding the expiry on every authenticated request would rewrite the store on
|
||||
# every poll, so persist slide-only updates at most this often.
|
||||
_SESSION_PERSIST_MIN_INTERVAL = 30.0 # seconds
|
||||
_sessions_last_persist = 0.0
|
||||
|
||||
# Failed login tracking: ip → list of failure timestamps
|
||||
_login_failures: dict[str, list[float]] = {}
|
||||
@@ -298,15 +322,24 @@ FEATURE_REGISTRY = [
|
||||
"port_requirements": [],
|
||||
},
|
||||
{
|
||||
"id": "bitcoin-core",
|
||||
"name": "Bitcoin Core",
|
||||
"description": "Only one Bitcoin node implementation can be active: Bitcoin Knots + BIP110 (default) or Bitcoin Core. Enabling this replaces Knots + BIP110 with Bitcoin Core. Your timechain data is preserved.",
|
||||
"id": "bitcoin-tor-gossip",
|
||||
"name": "Advertise Tor IBD Node",
|
||||
"description": "Advertise this Bitcoin Core node's onion address through Bitcoin peer gossip so more Tor-capable nodes can discover it and request blocks.",
|
||||
"details": [
|
||||
"Your Tor IBD listener remains available whether or not advertising is enabled.",
|
||||
"Enabling this announces only the node's .onion P2P address; it does not publish your home IP address.",
|
||||
"Other Tor nodes can discover your node and request historical blocks while performing Initial Block Download (IBD).",
|
||||
"No clearnet port or router port forwarding is opened.",
|
||||
"Serving additional IBD peers can use significant upload bandwidth.",
|
||||
],
|
||||
"category": "bitcoin",
|
||||
"modal_only": True,
|
||||
"needs_domain": False,
|
||||
"domain_name": None,
|
||||
"needs_ddns": False,
|
||||
"extra_fields": [],
|
||||
"conflicts_with": [],
|
||||
"requires": ["bitcoin-service"],
|
||||
"port_requirements": [],
|
||||
},
|
||||
{
|
||||
@@ -342,7 +375,7 @@ FEATURE_REGISTRY = [
|
||||
|
||||
# Feature ids that have been removed/deprecated. The Hub must never write these
|
||||
# back into custom.nix, and should strip any it finds (see startup migration).
|
||||
DEPRECATED_FEATURE_IDS: set[str] = {"bip110"}
|
||||
DEPRECATED_FEATURE_IDS: set[str] = {"bitcoin-core"}
|
||||
|
||||
# Map feature IDs to their systemd units in config.json
|
||||
FEATURE_SERVICE_MAP = {
|
||||
@@ -350,7 +383,7 @@ FEATURE_SERVICE_MAP = {
|
||||
"haven": "haven-relay.service",
|
||||
"element-calling": "livekit.service",
|
||||
"mempool": "mempool.service",
|
||||
"bitcoin-core": None,
|
||||
"bitcoin-tor-gossip": None,
|
||||
"btcpay-web": "btcpayserver.service",
|
||||
"nwc-wallets": "albyhub.service",
|
||||
"sshd": "sshd.service",
|
||||
@@ -405,9 +438,7 @@ SERVICE_DOMAIN_MAP: dict[str, str] = {
|
||||
}
|
||||
|
||||
# For features that share a unit, disambiguate by icon field
|
||||
FEATURE_ICON_MAP = {
|
||||
"bitcoin-core": "bitcoin-core",
|
||||
}
|
||||
FEATURE_ICON_MAP: dict[str, str] = {}
|
||||
|
||||
ROLE_LABELS = {
|
||||
"server_plus_desktop": "Server + Desktop",
|
||||
@@ -426,7 +457,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = {
|
||||
ROLE_FEATURES: dict[str, set[str] | None] = {
|
||||
"server_plus_desktop": None,
|
||||
"desktop": {"rdp", "sshd"},
|
||||
"node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "nwc-wallets", "sshd"},
|
||||
"node": {"rdp", "bitcoin-tor-gossip", "mempool", "btcpay-web", "nwc-wallets", "sshd"},
|
||||
}
|
||||
|
||||
SERVICE_DESCRIPTIONS: dict[str, str] = {
|
||||
@@ -606,38 +637,74 @@ def _get_or_create_session_secret() -> bytes:
|
||||
return token_hex
|
||||
|
||||
|
||||
def _load_sessions_once() -> None:
|
||||
"""Lazily load the persisted session store on first use (idempotent)."""
|
||||
global _sessions_loaded
|
||||
with _sessions_lock:
|
||||
if _sessions_loaded:
|
||||
return
|
||||
_sessions.update(load_session_store(SESSIONS_FILE))
|
||||
_sessions_loaded = True
|
||||
|
||||
|
||||
def _persist_sessions(force: bool = False) -> None:
|
||||
"""Write the session store to SESSIONS_FILE (best-effort).
|
||||
|
||||
Expiry slides happen on every authenticated request, so non-forced
|
||||
persists are throttled; create/destroy/purge pass ``force=True``.
|
||||
"""
|
||||
global _sessions_last_persist
|
||||
now = time.time()
|
||||
with _sessions_lock:
|
||||
if not force and (now - _sessions_last_persist) < _SESSION_PERSIST_MIN_INTERVAL:
|
||||
return
|
||||
snapshot = dict(_sessions)
|
||||
_sessions_last_persist = now
|
||||
save_session_store(SESSIONS_FILE, snapshot)
|
||||
|
||||
|
||||
def _create_session() -> str:
|
||||
"""Create a new opaque session token and register it in the store."""
|
||||
_load_sessions_once()
|
||||
_purge_expired_sessions()
|
||||
token = secrets.token_hex(32)
|
||||
_sessions[token] = time.time() + SESSION_MAX_AGE
|
||||
_persist_sessions(force=True)
|
||||
return token
|
||||
|
||||
|
||||
def _destroy_session(token: str) -> None:
|
||||
"""Remove a session token from the store."""
|
||||
_sessions.pop(token, None)
|
||||
_load_sessions_once()
|
||||
if _sessions.pop(token, None) is not None:
|
||||
_persist_sessions(force=True)
|
||||
|
||||
|
||||
def _purge_expired_sessions() -> None:
|
||||
"""Remove all expired sessions from the in-memory store."""
|
||||
"""Remove all expired sessions from the store."""
|
||||
_load_sessions_once()
|
||||
now = time.time()
|
||||
expired = [tok for tok, exp in _sessions.items() if exp <= now]
|
||||
for tok in expired:
|
||||
del _sessions[tok]
|
||||
if expired:
|
||||
_persist_sessions(force=True)
|
||||
|
||||
|
||||
def _is_authenticated(request: Request) -> bool:
|
||||
"""Return True if the request carries a valid, unexpired session cookie."""
|
||||
_load_sessions_once()
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if not token:
|
||||
return False
|
||||
expiry = _sessions.get(token)
|
||||
if expiry is None or time.time() >= expiry:
|
||||
_sessions.pop(token, None)
|
||||
if _sessions.pop(token, None) is not None:
|
||||
_persist_sessions(force=True)
|
||||
return False
|
||||
# Slide the expiry window on activity
|
||||
_sessions[token] = time.time() + SESSION_MAX_AGE
|
||||
_persist_sessions() # throttled — don't rewrite the store on every poll
|
||||
return True
|
||||
|
||||
|
||||
@@ -2017,14 +2084,24 @@ def _migrate_strip_deprecated_features() -> None:
|
||||
# ── Feature status helpers ─────────────────────────────────────────
|
||||
|
||||
def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
||||
"""Check if a feature's service appears as enabled in the running config.json.
|
||||
Returns True/False if found, None if the feature has no mapped service."""
|
||||
"""Check whether a feature is enabled in the evaluated Hub configuration.
|
||||
|
||||
Most features map directly to a systemd service. Modal-only settings are
|
||||
represented separately in ``config.json``. Returns ``None`` only when no
|
||||
evaluated state is available.
|
||||
"""
|
||||
if feature_id == "btcpay-web":
|
||||
return False # Default off in Node role; only on via explicit hub toggle
|
||||
|
||||
cfg = load_config()
|
||||
|
||||
if feature_id == "bitcoin-tor-gossip":
|
||||
state = cfg.get("feature_states", {}).get(feature_id)
|
||||
return bool(state) if state is not None else None
|
||||
|
||||
unit = FEATURE_SERVICE_MAP.get(feature_id)
|
||||
if unit is None:
|
||||
return None # bitcoin-core — can't determine from config
|
||||
cfg = load_config()
|
||||
return None
|
||||
for svc in cfg.get("services", []):
|
||||
if svc.get("unit") == unit:
|
||||
return svc.get("enabled", False)
|
||||
@@ -2444,6 +2521,10 @@ async def auto_login_redirect(request: Request):
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
if client_ip not in ("127.0.0.1", "::1"):
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
# An explicit logout must take precedence over the desktop launcher's
|
||||
# localhost auto-login, including after the Hub window is closed/reopened.
|
||||
if request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME) == "1":
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
token = _create_session()
|
||||
response = RedirectResponse(url="/", status_code=303)
|
||||
response.set_cookie(
|
||||
@@ -2480,17 +2561,27 @@ async def api_login(req: LoginRequest, request: Request):
|
||||
samesite="lax",
|
||||
secure=False, # LAN-only appliance; no TLS on the Hub port
|
||||
)
|
||||
# A successful password login explicitly reverses a prior manual logout.
|
||||
response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/api/logout")
|
||||
async def api_logout(request: Request):
|
||||
"""Clear the session cookie and destroy the server-side session."""
|
||||
"""Destroy the session and prevent desktop auto-login until password login."""
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if token:
|
||||
_destroy_session(token)
|
||||
response = JSONResponse({"ok": True})
|
||||
response.delete_cookie(key=SESSION_COOKIE_NAME)
|
||||
response.set_cookie(
|
||||
key=MANUAL_LOGOUT_COOKIE_NAME,
|
||||
value="1",
|
||||
max_age=MANUAL_LOGOUT_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False, # LAN-only appliance; no TLS on the Hub port
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@@ -2766,21 +2857,11 @@ BITCOIN_DATADIR = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"
|
||||
_btc_sync_cache: tuple[float, dict | None] = (0.0, None)
|
||||
_BTC_SYNC_CACHE_TTL = 5 # seconds
|
||||
|
||||
_btc_version_cache: tuple[float, dict | None] = (0.0, None)
|
||||
_BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime
|
||||
|
||||
# Cache for ``bitcoind --version`` output (available even before RPC is ready)
|
||||
_btcd_version_cache: tuple[float, str | None] = (0.0, None)
|
||||
|
||||
# Cache for ``bitcoin-cli getdeploymentinfo`` output (BIP-110 live status)
|
||||
_btc_deployment_cache: tuple[float, dict | None] = (0.0, None)
|
||||
|
||||
# Bitcoin Knots exposes BIP-110 as the `reduced_data` versionbits deployment
|
||||
# (RDTS, bit 4) in getdeploymentinfo. See Knots src/deploymentinfo.cpp,
|
||||
# src/kernel/chainparams.cpp, and doc/bips.md.
|
||||
BIP110_DEPLOYMENT_NAMES = {"reduced_data", "rdts", "bip110", "uasf-bip110"}
|
||||
BIP110_VERSIONBITS_BIT = 4
|
||||
BIP110_SUBVERSION_MARKERS = {"bip110", "uasf-bip110", "reduced_data", "rdts"}
|
||||
|
||||
|
||||
# ── Generic service version detection (NixOS store path) ─────────
|
||||
@@ -2906,213 +2987,12 @@ def _get_service_version(unit: str) -> str | None:
|
||||
return version
|
||||
|
||||
|
||||
def _parse_bitcoin_subversion(subversion: str) -> str:
|
||||
"""Parse a subversion string like '/Bitcoin Knots:27.1.0/' into 'v27.1.0'.
|
||||
|
||||
Examples:
|
||||
'/Bitcoin Knots:27.1.0/' → 'v27.1.0'
|
||||
'/Satoshi:27.0.0/' → 'v27.0.0'
|
||||
'/Bitcoin Knots:27.1.0(bip110)/' → 'v27.1.0 (bip110)'
|
||||
Falls back to the raw subversion string if parsing fails.
|
||||
"""
|
||||
m = re.search(r":(\d+\.\d+(?:\.\d+)*)", subversion)
|
||||
if m:
|
||||
ver = "v" + m.group(1)
|
||||
if "(bip110)" in subversion.lower():
|
||||
ver += " (bip110)"
|
||||
return ver
|
||||
return subversion
|
||||
|
||||
|
||||
def _get_bitcoin_version_info() -> dict | None:
|
||||
"""Call bitcoin-cli getnetworkinfo and return parsed JSON, or None on error.
|
||||
|
||||
Results are cached for _BTC_VERSION_CACHE_TTL seconds since the version
|
||||
does not change while the service is running.
|
||||
"""
|
||||
global _btc_version_cache
|
||||
now = time.monotonic()
|
||||
cached_at, cached_val = _btc_version_cache
|
||||
if now - cached_at < _BTC_VERSION_CACHE_TTL:
|
||||
return cached_val
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bitcoin-cli", f"-datadir={BITCOIN_DATADIR}", "getnetworkinfo"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
# This is a dashboard hint; do not make an unavailable RPC delay
|
||||
# the entire service tile response.
|
||||
timeout=3,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
_btc_version_cache = (now, None)
|
||||
return None
|
||||
info = json.loads(result.stdout)
|
||||
_btc_version_cache = (now, info)
|
||||
return info
|
||||
except Exception:
|
||||
_btc_version_cache = (now, None)
|
||||
return None
|
||||
|
||||
|
||||
def _get_bitcoin_deployment_info() -> dict | None:
|
||||
"""Call bitcoin-cli getdeploymentinfo and return parsed JSON, or None on error.
|
||||
|
||||
Results are cached for _BTC_VERSION_CACHE_TTL seconds. Never raises.
|
||||
"""
|
||||
global _btc_deployment_cache
|
||||
now = time.monotonic()
|
||||
cached_at, cached_val = _btc_deployment_cache
|
||||
if now - cached_at < _BTC_VERSION_CACHE_TTL:
|
||||
return cached_val
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bitcoin-cli", f"-datadir={BITCOIN_DATADIR}", "getdeploymentinfo"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
# BIP-110 is supplementary tile metadata; an RPC timeout must not
|
||||
# block the dashboard from rendering.
|
||||
timeout=3,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
_btc_deployment_cache = (now, None)
|
||||
return None
|
||||
info = json.loads(result.stdout)
|
||||
_btc_deployment_cache = (now, info)
|
||||
return info
|
||||
except Exception:
|
||||
_btc_deployment_cache = (now, None)
|
||||
return None
|
||||
|
||||
|
||||
def _get_bip110_status() -> dict:
|
||||
"""Return a dict describing the live BIP-110 deployment/signaling state.
|
||||
|
||||
The returned struct has four stable keys::
|
||||
|
||||
{
|
||||
"supported": bool, # node build is BIP-110-capable
|
||||
"signaling": bool, # node is actively signaling / locked-in / active
|
||||
"state": str, # "active" | "locked_in" | "signaling" |
|
||||
# "not_signaling" | "unsupported" | "unknown"
|
||||
"source": str, # "getdeploymentinfo" | "subversion" | "none"
|
||||
}
|
||||
|
||||
Resolution order (authoritative → fallback → honest unknown):
|
||||
|
||||
1. ``getdeploymentinfo`` (authoritative) — scan ``deployments`` for BIP-110.
|
||||
Bitcoin Knots currently exposes BIP-110 as ``reduced_data`` (RDTS, bit 4;
|
||||
see Knots deploymentinfo.cpp / chainparams.cpp / doc/bips.md), so matching
|
||||
first uses known deployment names, then falls back to versionbits bit 4.
|
||||
|
||||
2. Subversion fallback — if getdeploymentinfo is unavailable or yields no
|
||||
recognisable BIP-110 entry, inspect the ``subversion`` field from
|
||||
``getnetworkinfo``. A case-insensitive match for known BIP-110 markers
|
||||
(including "bip110", "uasf-bip110", "reduced_data", "rdts") is treated as
|
||||
"signaling".
|
||||
|
||||
3. Unknown — if the node is entirely unreachable or neither source is
|
||||
conclusive, return state="unknown", signaling=False, source="none".
|
||||
"""
|
||||
_unknown: dict = {"supported": False, "signaling": False, "state": "unknown", "source": "none"}
|
||||
|
||||
def _deployment_bit(entry: dict) -> int | None:
|
||||
bip9 = entry.get("bip9", {}) or {}
|
||||
bip8 = entry.get("bip8", {}) or {}
|
||||
bit = bip9.get("bit")
|
||||
if bit is None:
|
||||
bit = bip8.get("bit")
|
||||
if bit is None:
|
||||
bit = entry.get("bit")
|
||||
return bit
|
||||
|
||||
# ── 1. getdeploymentinfo (authoritative) ──────────────────────────
|
||||
deploy_info = _get_bitcoin_deployment_info()
|
||||
if deploy_info is not None:
|
||||
deployments = deploy_info.get("deployments", {})
|
||||
if isinstance(deployments, dict):
|
||||
matched_entry: dict | None = None
|
||||
|
||||
# Primary match: known deployment names (case-insensitive exact match)
|
||||
for key, entry in deployments.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
key_lower = key.lower()
|
||||
if key_lower not in BIP110_DEPLOYMENT_NAMES:
|
||||
continue
|
||||
matched_entry = entry
|
||||
break
|
||||
|
||||
# Secondary match: versionbits bit (fallback only)
|
||||
if matched_entry is None:
|
||||
for _, entry in deployments.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if _deployment_bit(entry) != BIP110_VERSIONBITS_BIT:
|
||||
continue
|
||||
matched_entry = entry
|
||||
break
|
||||
|
||||
if matched_entry is not None:
|
||||
entry = matched_entry
|
||||
|
||||
# bip9 / bip8 status field
|
||||
bip9 = entry.get("bip9", {}) or {}
|
||||
bip8 = entry.get("bip8", {}) or {}
|
||||
status = (
|
||||
bip9.get("status")
|
||||
or bip8.get("status")
|
||||
or entry.get("status")
|
||||
or ""
|
||||
).lower()
|
||||
active = entry.get("active", False)
|
||||
|
||||
if active or status == "active":
|
||||
return {"supported": True, "signaling": True, "state": "active", "source": "getdeploymentinfo"}
|
||||
if status == "locked_in":
|
||||
return {"supported": True, "signaling": True, "state": "locked_in", "source": "getdeploymentinfo"}
|
||||
if status in ("started", "defined"):
|
||||
# Check whether deployment is currently signaling in this period.
|
||||
stats = bip9.get("statistics") or bip8.get("statistics") or {}
|
||||
# Some Knots outputs expose only ``count`` (not explicit signaling bool),
|
||||
# so treat count>0 as a conservative signaling indicator for this period.
|
||||
count = stats.get("count")
|
||||
signaling = bool(
|
||||
stats.get("signaling")
|
||||
or stats.get("signalling")
|
||||
or (isinstance(count, int) and count > 0)
|
||||
)
|
||||
if signaling:
|
||||
return {"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"}
|
||||
return {"supported": True, "signaling": False, "state": "not_signaling", "source": "getdeploymentinfo"}
|
||||
if status == "failed":
|
||||
return {"supported": True, "signaling": False, "state": "not_signaling", "source": "getdeploymentinfo"}
|
||||
# Entry found but status unrecognised — node supports BIP-110 but state unclear
|
||||
return {"supported": True, "signaling": False, "state": "unknown", "source": "getdeploymentinfo"}
|
||||
|
||||
# ── 2. Subversion fallback ─────────────────────────────────────────
|
||||
net_info = _get_bitcoin_version_info()
|
||||
if net_info is not None:
|
||||
subversion = net_info.get("subversion", "") or ""
|
||||
sv_lower = subversion.lower()
|
||||
if any(marker in sv_lower for marker in BIP110_SUBVERSION_MARKERS):
|
||||
return {"supported": True, "signaling": True, "state": "signaling", "source": "subversion"}
|
||||
# Node is reachable via RPC but no BIP-110 marker found anywhere
|
||||
return {"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"}
|
||||
|
||||
# ── 3. Node unreachable / RPC not ready ───────────────────────────
|
||||
return _unknown
|
||||
|
||||
|
||||
def _get_bitcoind_version() -> str | None:
|
||||
"""Run ``bitcoind --version`` and return the raw version string, or None on error.
|
||||
|
||||
Parses the first output line to extract the token after "version ".
|
||||
For example: "Bitcoin Knots daemon version v29.3.knots20260508"
|
||||
returns "v29.3.knots20260508".
|
||||
|
||||
Works regardless of whether the RPC server is ready (IBD, warmup, etc.).
|
||||
Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL).
|
||||
"""
|
||||
@@ -3143,17 +3023,9 @@ def _get_bitcoind_version() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _format_bitcoin_version(raw_version: str, icon: str = "") -> str:
|
||||
"""Format a raw version string from ``bitcoind --version`` for tile display.
|
||||
|
||||
For the BIP110 tile (icon == "bip110") a " (bip110)" tag is appended,
|
||||
since mainline Bitcoin Knots (29.3.knots20260508+) now includes BIP-110
|
||||
and no longer carries a separate ``+bip110-vX.Y.Z`` suffix.
|
||||
"""
|
||||
display = raw_version
|
||||
if icon == "bip110" and "(bip110)" not in display.lower():
|
||||
display += " (bip110)"
|
||||
return display
|
||||
def _format_bitcoin_version(raw_version: str) -> str:
|
||||
"""Format a raw version string from ``bitcoind --version`` for display."""
|
||||
return raw_version
|
||||
|
||||
|
||||
def _get_bitcoin_sync_info() -> dict | None:
|
||||
@@ -3222,19 +3094,6 @@ async def api_bitcoin_version():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/bitcoin/bip110")
|
||||
async def api_bitcoin_bip110():
|
||||
"""Return live BIP-110 deployment/signaling status from bitcoin-cli.
|
||||
|
||||
Always returns HTTP 200. When bitcoind is unreachable or the node is mid-IBD
|
||||
the response will contain ``state = "unknown"`` so the UI can render a neutral
|
||||
badge rather than an error toast.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
status = await loop.run_in_executor(None, _get_bip110_status)
|
||||
return status
|
||||
|
||||
|
||||
@app.get("/api/services")
|
||||
async def api_services():
|
||||
started_at = time.monotonic()
|
||||
@@ -3314,39 +3173,27 @@ async def api_services():
|
||||
dns_states_future = asyncio.gather(
|
||||
*(resolve_domain(domain) for domain in sorted(domain_names))
|
||||
)
|
||||
# Bitcoin sync and BIP-110 are supplementary tile metadata. Fetch each
|
||||
# once, concurrently with the other diagnostics, instead of doing duplicate
|
||||
# RPC calls inside both bitcoind tile coroutines.
|
||||
# Bitcoin sync is supplementary tile metadata. Fetch it once alongside
|
||||
# the other diagnostics instead of duplicating RPC calls per tile.
|
||||
has_enabled_bitcoin = any(
|
||||
entry.get("unit") == "bitcoind.service" and enabled
|
||||
for entry, enabled in effective_entries
|
||||
)
|
||||
has_enabled_bip110 = any(
|
||||
entry.get("icon") == "bip110" and enabled
|
||||
for entry, enabled in effective_entries
|
||||
)
|
||||
bitcoin_sync_future = (
|
||||
loop.run_in_executor(None, _get_bitcoin_sync_info)
|
||||
if has_enabled_bitcoin
|
||||
else asyncio.sleep(0, result=None)
|
||||
)
|
||||
bip110_future = (
|
||||
loop.run_in_executor(None, _get_bip110_status)
|
||||
if has_enabled_bip110
|
||||
else asyncio.sleep(0, result=None)
|
||||
)
|
||||
(
|
||||
active_states,
|
||||
port_states,
|
||||
dns_states,
|
||||
bitcoin_sync_info,
|
||||
bip110_status,
|
||||
) = await asyncio.gather(
|
||||
active_states_future,
|
||||
port_states_future,
|
||||
dns_states_future,
|
||||
bitcoin_sync_future,
|
||||
bip110_future,
|
||||
)
|
||||
listening_ports, firewall_ports = port_states
|
||||
resolved_domains = dict(dns_states)
|
||||
@@ -3502,11 +3349,9 @@ async def api_services():
|
||||
if unit == "bitcoind.service" and enabled:
|
||||
raw_ver = await loop.run_in_executor(None, _get_bitcoind_version)
|
||||
if raw_ver is not None:
|
||||
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
||||
btc_ver = _format_bitcoin_version(raw_ver)
|
||||
service_data["bitcoin_version"] = btc_ver # backwards compat
|
||||
service_data["version"] = btc_ver
|
||||
if icon == "bip110" and bip110_status is not None:
|
||||
service_data["bip110"] = bip110_status
|
||||
# ── Generic version for all services (Nix store path) ──────────
|
||||
if enabled and unit and "version" not in service_data:
|
||||
ver = await loop.run_in_executor(None, _get_service_version, unit)
|
||||
@@ -3751,6 +3596,37 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
||||
"port_requirements": feat_meta.get("port_requirements", []),
|
||||
}
|
||||
|
||||
# Modal-only settings related to this service. These are intentionally not
|
||||
# rendered as standalone feature cards: the user encounters them in the
|
||||
# context where their consequences are easiest to understand.
|
||||
related_features: list[dict] = []
|
||||
if icon == "bitcoin-core":
|
||||
related_id = "bitcoin-tor-gossip"
|
||||
related_meta = next((f for f in FEATURE_REGISTRY if f["id"] == related_id), None)
|
||||
if related_meta is not None:
|
||||
if related_id in overrides:
|
||||
related_enabled = bool(overrides[related_id])
|
||||
else:
|
||||
config_state = _is_feature_enabled_in_config(related_id)
|
||||
related_enabled = bool(config_state) if config_state is not None else False
|
||||
related_features.append({
|
||||
"id": related_id,
|
||||
"name": related_meta["name"],
|
||||
"description": related_meta["description"],
|
||||
"details": related_meta.get("details", []),
|
||||
"category": related_meta["category"],
|
||||
"enabled": related_enabled,
|
||||
"available": bool(enabled),
|
||||
"needs_domain": False,
|
||||
"domain_configured": True,
|
||||
"domain_name": None,
|
||||
"needs_ddns": False,
|
||||
"extra_fields": [],
|
||||
"conflicts_with": related_meta.get("conflicts_with", []),
|
||||
"requires": related_meta.get("requires", []),
|
||||
"port_requirements": [],
|
||||
})
|
||||
|
||||
service_detail: dict = {
|
||||
"name": entry.get("name", ""),
|
||||
"unit": unit,
|
||||
@@ -3773,6 +3649,7 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
||||
"external_ip": external_ip,
|
||||
"internal_ip": internal_ip,
|
||||
"feature": feature_entry,
|
||||
"related_features": related_features,
|
||||
}
|
||||
if sync_ibd is not None:
|
||||
service_detail["sync_ibd"] = sync_ibd
|
||||
@@ -3783,11 +3660,9 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
||||
loop = asyncio.get_event_loop()
|
||||
raw_ver = await loop.run_in_executor(None, _get_bitcoind_version)
|
||||
if raw_ver is not None:
|
||||
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
||||
btc_ver = _format_bitcoin_version(raw_ver)
|
||||
service_detail["bitcoin_version"] = btc_ver # backwards compat
|
||||
service_detail["version"] = btc_ver
|
||||
if icon == "bip110":
|
||||
service_detail["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
|
||||
# ── Generic version for all services (Nix store path) ──────────
|
||||
if enabled and unit and "version" not in service_detail:
|
||||
ver = await loop.run_in_executor(None, _get_service_version, unit)
|
||||
@@ -4397,8 +4272,10 @@ async def api_features():
|
||||
|
||||
role = load_config().get("role", "server_plus_desktop")
|
||||
allowed_features = ROLE_FEATURES.get(role)
|
||||
registry = FEATURE_REGISTRY if allowed_features is None else [
|
||||
f for f in FEATURE_REGISTRY if f["id"] in allowed_features
|
||||
registry = [
|
||||
f for f in FEATURE_REGISTRY
|
||||
if not f.get("modal_only")
|
||||
and (allowed_features is None or f["id"] in allowed_features)
|
||||
]
|
||||
|
||||
features = []
|
||||
@@ -4472,6 +4349,22 @@ async def api_features_toggle(req: FeatureToggleRequest):
|
||||
features, nostr_npub, cur_tz, cur_locale = await loop.run_in_executor(None, _read_hub_overrides)
|
||||
|
||||
if req.enabled:
|
||||
# Onion-address advertising is only meaningful while the Bitcoin Core
|
||||
# service is enabled. The control is shown in that service's modal, but
|
||||
# enforce the dependency server-side as well.
|
||||
if req.feature == "bitcoin-tor-gossip":
|
||||
bitcoin_core_enabled = any(
|
||||
svc.get("unit") == "bitcoind.service"
|
||||
and svc.get("icon") == "bitcoin-core"
|
||||
and bool(svc.get("enabled", False))
|
||||
for svc in load_config().get("services", [])
|
||||
)
|
||||
if not bitcoin_core_enabled:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Enable the Bitcoin service before advertising its Tor IBD service.",
|
||||
)
|
||||
|
||||
# Element-calling requires matrix domain
|
||||
if req.feature == "element-calling":
|
||||
if not os.path.exists(os.path.join(DOMAINS_DIR, "matrix")):
|
||||
@@ -6152,6 +6045,9 @@ async def _startup_session_secret():
|
||||
"""Ensure the session secret exists on disk at startup."""
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _get_or_create_session_secret)
|
||||
# Preload persisted sessions so browser logins survive this restart (the
|
||||
# Hub service is restarted by nixos-rebuild switch during every rebuild).
|
||||
await loop.run_in_executor(None, _load_sessions_once)
|
||||
|
||||
|
||||
# ── Startup: recover stale RUNNING status files ──────────────────
|
||||
@@ -6265,8 +6161,7 @@ async def _startup_recover_stale_status():
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup_migrate_deprecated_features():
|
||||
"""Strip deprecated feature lines (e.g. bip110) from the Hub Managed section
|
||||
of custom.nix so they are never re-written and do not cause stale warnings."""
|
||||
"""Strip deprecated feature lines from the Hub Managed section of custom.nix."""
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _migrate_strip_deprecated_features)
|
||||
|
||||
|
||||
@@ -178,68 +178,6 @@
|
||||
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 ───────────────────────────────── */
|
||||
|
||||
@@ -427,6 +365,45 @@
|
||||
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 {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
const POLL_INTERVAL_SERVICES = 5000;
|
||||
const POLL_INTERVAL_UPDATES = 1800000;
|
||||
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_FETCH_TIMEOUT = 12000;
|
||||
const REBOOT_REQUEST_TIMEOUT = 4000;
|
||||
|
||||
@@ -342,8 +342,11 @@ async function performFeatureToggle(featId, enabled, extra) {
|
||||
function handleFeatureToggle(feat, newEnabled) {
|
||||
if (!newEnabled) {
|
||||
// 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(
|
||||
"This will disable " + feat.name + ". The system will rebuild. Continue?",
|
||||
disableMessage,
|
||||
function() { performFeatureToggle(feat.id, false, {}); }
|
||||
);
|
||||
return;
|
||||
@@ -403,9 +406,9 @@ function handleFeatureToggle(feat, newEnabled) {
|
||||
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
||||
}
|
||||
|
||||
if (feat.id === "bitcoin-core") {
|
||||
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?";
|
||||
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
||||
if (feat.id === "bitcoin-tor-gossip") {
|
||||
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(torGossipConfirmMsg, proceedAfterConflictCheck);
|
||||
} else if (conflictNames.length > 0) {
|
||||
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
||||
} else {
|
||||
|
||||
@@ -111,8 +111,19 @@ function formatDuration(seconds) {
|
||||
|
||||
// ── 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) {
|
||||
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) {
|
||||
let detail = res.status + " " + res.statusText;
|
||||
try {
|
||||
@@ -133,17 +144,3 @@ async function apiFetch(path, options) {
|
||||
}
|
||||
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;
|
||||
_rebuildServerDown = false;
|
||||
_rebuildFinished = false;
|
||||
_rebuildPollFailures = 0;
|
||||
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
|
||||
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
|
||||
var label = _rebuildFeatureName || "feature";
|
||||
@@ -45,6 +46,7 @@ async function pollRebuildStatus() {
|
||||
if (_rebuildFinished) return;
|
||||
try {
|
||||
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
|
||||
_rebuildPollFailures = 0;
|
||||
if (_rebuildServerDown) { _rebuildServerDown = false; }
|
||||
if (data.log) appendRebuildLog(data.log);
|
||||
_rebuildLogOffset = data.offset;
|
||||
@@ -57,6 +59,18 @@ async function pollRebuildStatus() {
|
||||
onRebuildDone(data.result === "success");
|
||||
}
|
||||
} 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…"; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -704,20 +704,6 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'</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)
|
||||
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";
|
||||
|
||||
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
||||
var addonSectionTitle = (feat.id === "bitcoin-core")
|
||||
? "\u20BF Bitcoin Node Selection"
|
||||
: "\uD83D\uDD27 Addon Feature";
|
||||
var addonSectionTitle = "\uD83D\uDD27 Addon Feature";
|
||||
|
||||
// Description: prefer the feature's own description over a generic fallback
|
||||
var addonDesc = feat.description
|
||||
@@ -934,6 +918,54 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'</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") {
|
||||
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
|
||||
'<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 restartResult = document.getElementById("svc-detail-restart-result");
|
||||
if (restartBtn && restartResult) {
|
||||
|
||||
@@ -9,6 +9,7 @@ let _updatePollTimer = null;
|
||||
let _updateLogOffset = 0;
|
||||
let _serverWasDown = false;
|
||||
let _updateFinished = false;
|
||||
let _updatePollFailures = 0; // consecutive failed update-status polls
|
||||
let _supportTimerInt = null;
|
||||
let _supportEnabledAt = null;
|
||||
let _supportStatus = null; // last fetched /api/support/status payload
|
||||
@@ -25,6 +26,7 @@ let _rebuildLogOffset = 0;
|
||||
let _rebuildPollTimer = null;
|
||||
let _rebuildFinished = false;
|
||||
let _rebuildServerDown = false;
|
||||
let _rebuildPollFailures = 0; // consecutive failed rebuild-status polls
|
||||
let _pendingToggle = null; // {feature, extra} waiting for domain/confirm
|
||||
let _rebuildFeatureName = "";
|
||||
let _rebuildIsEnabling = true;
|
||||
|
||||
@@ -4,14 +4,6 @@
|
||||
// Keyed by tileId: { progress: float, timestamp: ms }
|
||||
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) {
|
||||
var tmp = document.createElement("div");
|
||||
@@ -175,8 +167,7 @@ function buildTile(svc) {
|
||||
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>' + bip110Badge + '<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><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() {
|
||||
@@ -244,23 +235,6 @@ function updateTiles(services) {
|
||||
var text = tile.querySelector(".status-text");
|
||||
if (dot) dot.className = "status-dot " + sc;
|
||||
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;
|
||||
_serverWasDown = false;
|
||||
_updateFinished = false;
|
||||
_updatePollFailures = 0;
|
||||
if ($modalLog) $modalLog.textContent = "";
|
||||
if ($modalStatus) $modalStatus.textContent = "Starting update…";
|
||||
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||
@@ -94,6 +95,7 @@ async function pollUpdateStatus() {
|
||||
if (_updateFinished) return;
|
||||
try {
|
||||
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
|
||||
_updatePollFailures = 0;
|
||||
if (_serverWasDown) {
|
||||
_serverWasDown = false;
|
||||
if (!data.running) {
|
||||
@@ -143,6 +145,16 @@ async function pollUpdateStatus() {
|
||||
onUpdateDone(false);
|
||||
}
|
||||
} 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…"; }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@
|
||||
ranger fastfetch gedit openssl pwgen
|
||||
aspell aspellDicts.en lm_sensors
|
||||
hunspell hunspellDicts.en_US
|
||||
synadm brave dua
|
||||
synadm brave-origin dua
|
||||
gparted pv unzip parted screen zenity
|
||||
libargon2 gnome-terminal libreoffice-fresh
|
||||
dig firefox wp-cli axel
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -62,11 +62,6 @@ let
|
||||
description = ''
|
||||
The package providing bitcoind binaries.
|
||||
|
||||
You can use this option to select other bitcoind-compatible implementations.
|
||||
Example:
|
||||
```nix
|
||||
services.bitcoind.package = pkgs.bitcoind-knots;
|
||||
```
|
||||
'';
|
||||
};
|
||||
extraConfig = mkOption {
|
||||
|
||||
@@ -4,7 +4,12 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
|
||||
services.bitcoind = {
|
||||
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";
|
||||
txindex = 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.rtl.enable = true;
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
haven = lib.mkForce false;
|
||||
mempool = lib.mkForce false;
|
||||
element-calling = lib.mkForce false;
|
||||
bitcoin-core = lib.mkForce false;
|
||||
bitcoin-tor-gossip = lib.mkForce false;
|
||||
"nwc-wallets" = lib.mkForce false;
|
||||
};
|
||||
|
||||
|
||||
+9
-22
@@ -45,23 +45,20 @@
|
||||
haven = lib.mkEnableOption "Haven NOSTR relay";
|
||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
||||
"nwc-wallets" = lib.mkEnableOption "Lightning Wallet Connections";
|
||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||
sshd = lib.mkEnableOption "SSH remote access";
|
||||
|
||||
# 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 {
|
||||
bitcoin-tor-gossip = lib.mkEnableOption "Advertise the Bitcoin Core onion service through Bitcoin peer gossip";
|
||||
# Compatibility shim for Hub-managed settings from releases where Core
|
||||
# was an optional replacement for the default node. Core is now always
|
||||
# selected when the Bitcoin service is enabled.
|
||||
bitcoin-core = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.bool;
|
||||
default = null;
|
||||
internal = true;
|
||||
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) ──────────────────
|
||||
@@ -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.
|
||||
''
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
+39
-21
@@ -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"; }
|
||||
]; }
|
||||
]
|
||||
# ── Bitcoin Base (node implementations) ────────────────────
|
||||
# ── Bitcoin Base ────────────────────────────────────────────
|
||||
++ 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 = [
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||
]; }
|
||||
{ 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://"; }
|
||||
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.services.bitcoin; category = "bitcoin-base"; credentials = [
|
||||
{ label = "Tor Bitcoin P2P Address — Reachable only through Tor"; file = "/var/lib/tor/onion/bitcoind/hostname"; suffix = ":8333"; }
|
||||
]; }
|
||||
]
|
||||
# ── Bitcoin Apps (services on top of the node) ─────────────
|
||||
@@ -125,6 +122,9 @@ let
|
||||
role = activeRole;
|
||||
services = monitoredServices;
|
||||
feature_manager = true;
|
||||
feature_states = {
|
||||
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
|
||||
};
|
||||
sovran_version = sovranVersion;
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@ let
|
||||
"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";
|
||||
"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";
|
||||
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
||||
# Keep the fallbacks aligned with the vendored packages used by the
|
||||
@@ -185,13 +185,14 @@ let
|
||||
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
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 stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1)
|
||||
--option fallback true
|
||||
BOOT_RC=$?
|
||||
echo "$BOOT_OUT"
|
||||
if [ "$BOOT_RC" -ne 0 ]; then
|
||||
echo "[ERROR] nixos-rebuild boot failed"
|
||||
RC=1
|
||||
@@ -240,20 +241,23 @@ let
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
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 stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1)
|
||||
--option fallback true
|
||||
SWITCH_RC=$?
|
||||
echo "$SWITCH_OUT"
|
||||
if [ "$SWITCH_RC" -eq 0 ]; then
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✓ Rebuild completed successfully"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
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 " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||
@@ -278,17 +282,31 @@ let
|
||||
fi
|
||||
'';
|
||||
|
||||
# ── Brave launcher wrapper: stable profile dir so Wayland app_id is
|
||||
# deterministic and GNOME Shell can match the window to the .desktop
|
||||
# entry (fixes generic gear icon appearing in the dock).
|
||||
# ── Brave Origin launcher wrapper: a *persistent* per-user profile dir.
|
||||
# It must NOT be wiped on exit: the Hub's logout marker cookie
|
||||
# (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" ''
|
||||
export PATH="${lib.makeBinPath [ pkgs.brave pkgs.coreutils ]}:$PATH"
|
||||
HUB_DATA="/tmp/sovran-hub-brave-$(id -u)"
|
||||
export PATH="${lib.makeBinPath [ pkgs.brave-origin pkgs.coreutils ]}:$PATH"
|
||||
# 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"
|
||||
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 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 \
|
||||
--user-data-dir="$HUB_DATA" \
|
||||
--password-store=basic \
|
||||
|
||||
@@ -44,6 +44,35 @@ let
|
||||
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
|
||||
if [ -f "$STAMP" ]; then
|
||||
exit 0
|
||||
@@ -61,11 +90,11 @@ let
|
||||
mkdir -p "$HOME/.config"
|
||||
cat > "$HOME/.config/mimeapps.list" << EOF
|
||||
[Default Applications]
|
||||
text/html=brave-browser.desktop
|
||||
x-scheme-handler/http=brave-browser.desktop
|
||||
x-scheme-handler/https=brave-browser.desktop
|
||||
x-scheme-handler/about=brave-browser.desktop
|
||||
x-scheme-handler/unknown=brave-browser.desktop
|
||||
text/html=brave-origin.desktop
|
||||
x-scheme-handler/http=brave-origin.desktop
|
||||
x-scheme-handler/https=brave-origin.desktop
|
||||
x-scheme-handler/about=brave-origin.desktop
|
||||
x-scheme-handler/unknown=brave-origin.desktop
|
||||
EOF
|
||||
|
||||
${pkgs.dconf}/bin/dconf load / << EOF
|
||||
@@ -104,7 +133,7 @@ search-filter-time-type='last_modified'
|
||||
[org/gnome/shell]
|
||||
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']
|
||||
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'
|
||||
|
||||
[org/gnome/desktop/app-folders]
|
||||
@@ -112,7 +141,7 @@ folder-children=['Browsers', 'Office', 'Terminal', 'Chat', 'Bitcoin', 'Media', '
|
||||
|
||||
[org/gnome/desktop/app-folders/folders/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]
|
||||
name='Office'
|
||||
@@ -269,7 +298,7 @@ in
|
||||
];
|
||||
|
||||
favorite-apps = [
|
||||
"brave-browser.desktop"
|
||||
"brave-origin.desktop"
|
||||
"org.gnome.Settings.desktop"
|
||||
"org.gnome.Nautilus.desktop"
|
||||
"sovran-hub.desktop"
|
||||
@@ -292,7 +321,7 @@ in
|
||||
"org/gnome/desktop/app-folders/folders/Browsers" = {
|
||||
name = "Browsers";
|
||||
apps = [
|
||||
"brave-browser.desktop"
|
||||
"brave-origin.desktop"
|
||||
"firefox.desktop"
|
||||
"org.gnome.Epiphany.desktop"
|
||||
];
|
||||
@@ -433,13 +462,13 @@ in
|
||||
];
|
||||
|
||||
xdg.mime.defaultApplications = {
|
||||
"text/html" = "brave-browser.desktop";
|
||||
"x-scheme-handler/http" = "brave-browser.desktop";
|
||||
"x-scheme-handler/https" = "brave-browser.desktop";
|
||||
"x-scheme-handler/about" = "brave-browser.desktop";
|
||||
"x-scheme-handler/unknown" = "brave-browser.desktop";
|
||||
"text/html" = "brave-origin.desktop";
|
||||
"x-scheme-handler/http" = "brave-origin.desktop";
|
||||
"x-scheme-handler/https" = "brave-origin.desktop";
|
||||
"x-scheme-handler/about" = "brave-origin.desktop";
|
||||
"x-scheme-handler/unknown" = "brave-origin.desktop";
|
||||
};
|
||||
|
||||
environment.sessionVariables.BROWSER = "brave-browser";
|
||||
environment.sessionVariables.BROWSER = "brave-origin";
|
||||
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
./nwc-wallets.nix
|
||||
./element-calling.nix
|
||||
./mempool.nix
|
||||
./bitcoin-core.nix
|
||||
./rdp.nix
|
||||
./sshd.nix
|
||||
];
|
||||
|
||||
+272
-188
@@ -20,6 +20,11 @@
|
||||
|
||||
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
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@@ -62,13 +67,28 @@ echo
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 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() {
|
||||
local exclude="${1:-}"
|
||||
if [[ -n "$exclude" ]]; then
|
||||
git tag --list 'v*' --sort=-version:refname | grep -v -x -E "v?${exclude#v}|${exclude}" | head -1 || echo ""
|
||||
else
|
||||
git tag --list 'v*' --sort=-version:refname | head -1 || echo ""
|
||||
fi
|
||||
local tag
|
||||
|
||||
while IFS= read -r tag; do
|
||||
if [[ -n "$exclude" && "$tag" == "v${exclude#v}" ]]; then
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' "$tag"
|
||||
return 0
|
||||
done < <(git for-each-ref \
|
||||
--sort=-version:refname \
|
||||
--format='%(refname:strip=3)' \
|
||||
"${GITHUB_TAG_NAMESPACE}/v*")
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -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}"
|
||||
git fetch --all --tags 2>/dev/null || true
|
||||
echo -e "${BLUE}Step 0: Running release preflight...${NC}"
|
||||
|
||||
# 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)
|
||||
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
|
||||
@@ -117,6 +211,23 @@ fi
|
||||
VERSION="${VERSION#v}"
|
||||
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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -193,20 +304,26 @@ generate_release_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")
|
||||
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG" >/dev/null; then
|
||||
COMMIT_RANGE="${PREV_TAG}..HEAD"
|
||||
PREV_TAG_REF="${GITHUB_TAG_NAMESPACE}/${PREV_TAG}"
|
||||
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG_REF" >/dev/null; then
|
||||
COMMIT_RANGE="${PREV_TAG_REF}..HEAD"
|
||||
COMMIT_RANGE_DISPLAY="${PREV_TAG}..HEAD"
|
||||
else
|
||||
COMMIT_RANGE="HEAD"
|
||||
COMMIT_RANGE_DISPLAY="HEAD"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE}...${NC}"
|
||||
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE_DISPLAY}...${NC}"
|
||||
RELEASE_NOTES="$(generate_release_notes "$COMMIT_RANGE")"
|
||||
|
||||
# Let the user review/edit the generated notes before publishing
|
||||
NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")"
|
||||
trap 'rm -f "$NOTES_FILE"' EXIT
|
||||
{
|
||||
echo "## Sovran_SystemsOS ${TAG}"
|
||||
echo
|
||||
@@ -246,214 +363,182 @@ if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 1: Push tested code to Gitea (stable & staging-dev)
|
||||
# Step 1: Prepare and commit all release metadata
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 1: Pushing tested code to Gitea (stable & staging-dev)...${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
|
||||
echo -e "${BLUE}Step 1: Preparing release metadata for ${TAG}...${NC}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
# VERSION drives ISO naming.
|
||||
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"
|
||||
if [ -f "$README_FILE" ]; then
|
||||
# Extract the version currently used in the README's ISO filenames,
|
||||
# e.g. "Sovran_SystemsOS-1.0.6.iso" -> "1.0.6"
|
||||
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" | head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
||||
|
||||
if [ -n "$OLD_ISO_VER" ] && [ "$OLD_ISO_VER" != "$VERSION" ]; then
|
||||
# Portable in-place edit (works with GNU sed and BSD/macOS sed)
|
||||
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
|
||||
"$README_FILE" > "$README_FILE.tmp" && mv "$README_FILE.tmp" "$README_FILE"
|
||||
|
||||
UPDATED=$(grep -c "Sovran_SystemsOS-${VERSION}" "$README_FILE")
|
||||
echo -e " ${GREEN}✓${NC} README.md: ${OLD_ISO_VER} -> ${VERSION} (${UPDATED} references updated)"
|
||||
|
||||
git add "$README_FILE"
|
||||
git commit -m "docs: update README ISO download links to ${TAG}" || true
|
||||
echo -e " ${GREEN}✓${NC} Committed README update"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} README.md already references ${VERSION} or no versioned ISO links found — skipping"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} README.md not found — skipping"
|
||||
if [[ ! -f "$README_FILE" ]]; then
|
||||
echo -e "${RED}Error: ${README_FILE} not found.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" \
|
||||
| head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
||||
if [[ -z "$OLD_ISO_VER" ]]; then
|
||||
echo -e "${RED}Error: no versioned ISO reference found in ${README_FILE}.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$OLD_ISO_VER" != "$VERSION" ]]; then
|
||||
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
|
||||
"$README_FILE" > "$README_FILE.tmp"
|
||||
mv "$README_FILE.tmp" "$README_FILE"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 3: Auto-update CHANGELOG.md
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 3: Updating ${CHANGELOG_FILE}...${NC}"
|
||||
|
||||
# Add the changelog entry before tagging so the tag and stable branch contain it.
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
# Create new changelog entry from the generated notes (no placeholders)
|
||||
NEW_ENTRY="## [${VERSION}] - ${TODAY}
|
||||
|
||||
${RELEASE_NOTES}
|
||||
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
|
||||
"
|
||||
|
||||
# Prepend to changelog (after the header)
|
||||
if [ -f "$CHANGELOG_FILE" ]; then
|
||||
# Backup
|
||||
cp "$CHANGELOG_FILE" "${CHANGELOG_FILE}.bak"
|
||||
|
||||
# Insert new section after the first --- line
|
||||
awk -v new_entry="$NEW_ENTRY" '
|
||||
BEGIN { printed=0 }
|
||||
/^---$/ && !printed {
|
||||
print
|
||||
print ""
|
||||
print new_entry
|
||||
printed=1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" && mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
||||
rm -f "${CHANGELOG_FILE}.bak"
|
||||
|
||||
echo -e " ${GREEN}✓${NC} CHANGELOG.md updated with new section for ${TAG}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} CHANGELOG.md not found — skipping"
|
||||
if [[ ! -f "$CHANGELOG_FILE" ]]; then
|
||||
echo -e "${RED}Error: ${CHANGELOG_FILE} not found.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
awk -v new_entry="$NEW_ENTRY" '
|
||||
BEGIN { printed=0 }
|
||||
/^---$/ && !printed {
|
||||
print
|
||||
print ""
|
||||
print new_entry
|
||||
printed=1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END { if (!printed) exit 2 }
|
||||
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" || {
|
||||
rm -f "${CHANGELOG_FILE}.tmp"
|
||||
echo -e "${RED}Error: could not find the changelog insertion marker.${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
||||
|
||||
# Commit the changelog update
|
||||
git add "$CHANGELOG_FILE"
|
||||
git add VERSION "$README_FILE" "$CHANGELOG_FILE"
|
||||
if git diff --cached --quiet; then
|
||||
echo " (No changes to commit in changelog)"
|
||||
else
|
||||
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
|
||||
echo -e "${RED}Error: release preparation produced no changes.${NC}" >&2
|
||||
exit 1
|
||||
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 -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
|
||||
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
||||
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
||||
if gh release create "${TAG}" \
|
||||
--repo naturallaw777/Sovran_SystemsOS \
|
||||
--title "${TAG} — ${title_suffix}" \
|
||||
--notes-file "${NOTES_FILE}"; then
|
||||
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)"
|
||||
# GitHub main and Gitea staging-dev were verified equal during preflight, so
|
||||
# these are normal fast-forward pushes. Stable is an intentional promotion and
|
||||
# uses a lease to prevent overwriting a branch changed since the fetch.
|
||||
git push "$GITHUB_REMOTE" HEAD:main
|
||||
git push "$GITEA_REMOTE" HEAD:staging-dev
|
||||
git push "$GITEA_REMOTE" HEAD:stable --force-with-lease
|
||||
|
||||
# Verify all three branch tips before creating an immutable release tag.
|
||||
remote_branch_commit() {
|
||||
local remote="$1"
|
||||
local branch="$2"
|
||||
git ls-remote "$remote" "refs/heads/${branch}" | awk 'NR == 1 { print $1 }'
|
||||
}
|
||||
|
||||
PUBLISHED_GITHUB=$(remote_branch_commit "$GITHUB_REMOTE" main)
|
||||
PUBLISHED_STAGING=$(remote_branch_commit "$GITEA_REMOTE" staging-dev)
|
||||
PUBLISHED_STABLE=$(remote_branch_commit "$GITEA_REMOTE" stable)
|
||||
if [[ "$PUBLISHED_GITHUB" != "$RELEASE_COMMIT" || \
|
||||
"$PUBLISHED_STAGING" != "$RELEASE_COMMIT" || \
|
||||
"$PUBLISHED_STABLE" != "$RELEASE_COMMIT" ]]; then
|
||||
echo -e "${RED}Error: post-push verification failed; no release tag was created.${NC}" >&2
|
||||
echo " Expected : $RELEASE_COMMIT" >&2
|
||||
echo " GitHub main : ${PUBLISHED_GITHUB:-missing}" >&2
|
||||
echo " Gitea staging-dev : ${PUBLISHED_STAGING:-missing}" >&2
|
||||
echo " Gitea stable : ${PUBLISHED_STABLE:-missing}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓${NC} GitHub main, Gitea staging-dev, and Gitea stable all match"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 5: Create Gitea Release via API
|
||||
# Step 3: Tag the final release commit and publish the tag
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 5: Creating Gitea Release via API...${NC}"
|
||||
echo -e "${BLUE}Step 3: Creating annotated tag ${TAG} on ${RELEASE_COMMIT}...${NC}"
|
||||
git tag -a "$TAG" -m "${RELEASE_MESSAGE}
|
||||
|
||||
# ── Gitea Token Handling ─────────────────────────────────────────────────────
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo
|
||||
echo -e "${YELLOW}GITEA_TOKEN is not set.${NC}"
|
||||
read -rsp "Enter your Gitea API token (input will be hidden): " GITEA_TOKEN
|
||||
echo
|
||||
if [[ -z "$GITEA_TOKEN" ]]; then
|
||||
echo -e " ${YELLOW}⚠${NC} No token provided — skipping Gitea release"
|
||||
GITEA_TOKEN=""
|
||||
fi
|
||||
- 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 \
|
||||
--title "${TAG} — ${title_suffix}" \
|
||||
--notes-file "$NOTES_FILE"
|
||||
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 5: Create Gitea release via API
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 5: Creating Gitea release via API...${NC}"
|
||||
|
||||
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
||||
|
||||
# Build JSON safely because release notes may contain quotes and newlines.
|
||||
if command -v jq >/dev/null 2>&1; 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
|
||||
|
||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
||||
RESPONSE_FILE=$(mktemp "/tmp/gitea-release-${TAG}.XXXXXX.json")
|
||||
HTTP_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases")
|
||||
|
||||
# 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 "Content-Type: application/json" \
|
||||
-d "${PAYLOAD}" \
|
||||
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases" 2>/dev/null || echo "")
|
||||
|
||||
if echo "$RESPONSE" | grep -q '"id"'; then
|
||||
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
||||
elif echo "$RESPONSE" | grep -q "write:repository"; then
|
||||
echo -e " ${YELLOW}⚠${NC} Gitea token scope issue: your token requires the 'write:repository' scope (currently has write:package)."
|
||||
echo " To fix: In Gitea, navigate to Settings → Applications → Manage Access Tokens and generate a token with 'write:repository'."
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Gitea release creation failed or already exists"
|
||||
echo " Response: $RESPONSE"
|
||||
fi
|
||||
if [[ "$HTTP_STATUS" != "201" ]]; then
|
||||
echo -e "${RED}Error: Gitea release creation failed (HTTP ${HTTP_STATUS}).${NC}" >&2
|
||||
cat "$RESPONSE_FILE" >&2
|
||||
rm -f "$RESPONSE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$RESPONSE_FILE"
|
||||
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Final Summary
|
||||
@@ -468,8 +553,7 @@ echo " • Build the installer ISO:"
|
||||
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
|
||||
echo " • Package, verify, and upload ISO to CDN:"
|
||||
echo " ./scripts/upload-cdn.sh --upload"
|
||||
echo " • Review and enhance the new section in CHANGELOG.md"
|
||||
echo " • Push changes: git push ${GITHUB_REMOTE} HEAD:main && git push ${GITEA_REMOTE} HEAD:staging-dev"
|
||||
echo " • Verify the ISO download and checksum from the public CDN"
|
||||
echo " • Verify releases on both GitHub and Gitea"
|
||||
echo
|
||||
echo -e "${CYAN}Tag created: ${TAG}${NC}"
|
||||
|
||||
@@ -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()
|
||||
@@ -14,6 +14,7 @@ import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
# Add the app package to the path so we can import without the full FastAPI tree.
|
||||
@@ -30,6 +31,8 @@ from sovran_systemsos_web.security_helpers import ( # noqa: E402
|
||||
_validate_ssh_pubkey,
|
||||
_DDNS_ALLOWED_HOSTNAMES,
|
||||
_bech32_decode,
|
||||
load_session_store,
|
||||
save_session_store,
|
||||
)
|
||||
from sovran_systemsos_web import support_ops # noqa: E402
|
||||
|
||||
@@ -362,6 +365,190 @@ class TestAuthExemptPaths(unittest.TestCase):
|
||||
self.assertIn("/api/ping", self._get_exempt_paths())
|
||||
|
||||
|
||||
class TestFrontendAuthRecovery(unittest.TestCase):
|
||||
"""Expired browser sessions must not leave the Hub polling forever."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
path = os.path.join(
|
||||
_REPO_ROOT, "app", "sovran_systemsos_web", "static", "js", "helpers.js"
|
||||
)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
cls.helpers = f.read()
|
||||
|
||||
def test_api_fetch_redirects_unauthorized_response_to_login(self):
|
||||
self.assertRegex(self.helpers, r"res\.status\s*===\s*401")
|
||||
self.assertIn('window.location.replace("/login")', self.helpers)
|
||||
|
||||
def test_unauthorized_response_does_not_use_local_auto_login(self):
|
||||
# Remote clients must still authenticate with the Hub password.
|
||||
self.assertNotIn('window.location.replace("/auto-login")', self.helpers)
|
||||
|
||||
|
||||
class TestManualLogoutPersistence(unittest.TestCase):
|
||||
"""Explicit logout must take precedence over desktop auto-login."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
path = os.path.join(_REPO_ROOT, "app", "sovran_systemsos_web", "server.py")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
cls.server = f.read()
|
||||
|
||||
def _between(self, start, end):
|
||||
return self.server.split(start, 1)[1].split(end, 1)[0]
|
||||
|
||||
def test_auto_login_honors_manual_logout_cookie(self):
|
||||
route = self._between(
|
||||
'@app.get("/auto-login")',
|
||||
"class LoginRequest",
|
||||
)
|
||||
self.assertIn("request.cookies.get(MANUAL_LOGOUT_COOKIE_NAME)", route)
|
||||
self.assertIn('RedirectResponse(url="/login"', route)
|
||||
|
||||
def test_logout_sets_persistent_manual_logout_cookie(self):
|
||||
route = self._between(
|
||||
'@app.post("/api/logout")',
|
||||
"def _get_sovran_version",
|
||||
)
|
||||
self.assertIn("key=MANUAL_LOGOUT_COOKIE_NAME", route)
|
||||
self.assertIn("max_age=MANUAL_LOGOUT_MAX_AGE", route)
|
||||
self.assertIn("httponly=True", route)
|
||||
|
||||
def test_password_login_clears_manual_logout_cookie(self):
|
||||
route = self._between(
|
||||
'@app.post("/api/login")',
|
||||
'@app.post("/api/logout")',
|
||||
)
|
||||
self.assertIn(
|
||||
"response.delete_cookie(key=MANUAL_LOGOUT_COOKIE_NAME)", route
|
||||
)
|
||||
|
||||
|
||||
class TestHubBrowserProfilePersistence(unittest.TestCase):
|
||||
"""The desktop launcher must keep a persistent browser profile.
|
||||
|
||||
The hub_manual_logout marker (and the session cookie) are stored in this
|
||||
profile. If the launcher used an ephemeral /tmp profile that it deleted on
|
||||
exit, closing and reopening the Hub window would wipe the marker and
|
||||
/auto-login would silently log the user back in without a password.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
path = os.path.join(
|
||||
_REPO_ROOT, "modules", "core", "sovran-hub.nix"
|
||||
)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
cls.wrapper = f.read()
|
||||
|
||||
def test_profile_is_not_under_tmp(self):
|
||||
# The profile must live in a persistent per-user location, not /tmp.
|
||||
self.assertNotIn("/tmp/sovran-hub-brave", self.wrapper)
|
||||
|
||||
def test_profile_is_not_deleted_on_exit(self):
|
||||
# There must be no trap that removes the user-data-dir on exit.
|
||||
self.assertNotRegex(self.wrapper, r"rm\s+-rf\s+.*HUB_DATA")
|
||||
self.assertNotIn("trap '", self.wrapper)
|
||||
|
||||
def test_profile_is_persistent_per_user_location(self):
|
||||
self.assertIn("sovran-hub-browser", self.wrapper)
|
||||
# It should honour XDG_STATE_HOME (standard, persistent per-user dir).
|
||||
self.assertIn("XDG_STATE_HOME", self.wrapper)
|
||||
|
||||
def test_launcher_still_uses_user_data_dir(self):
|
||||
self.assertIn("--user-data-dir=", self.wrapper)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistent session store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSessionStore(unittest.TestCase):
|
||||
"""Sessions must persist across Hub restarts so rebuild/update polling
|
||||
keeps working after nixos-rebuild switch restarts the Hub service."""
|
||||
|
||||
def _store_path(self, tmpdir, name="hub-sessions.json"):
|
||||
return os.path.join(tmpdir, name)
|
||||
|
||||
def test_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self._store_path(tmpdir)
|
||||
future = time.time() + 3600
|
||||
sessions = {"token-a": future, "token-b": future + 10}
|
||||
self.assertTrue(save_session_store(path, sessions))
|
||||
self.assertEqual(load_session_store(path), sessions)
|
||||
|
||||
def test_missing_file_returns_empty(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
self.assertEqual(load_session_store(self._store_path(tmpdir)), {})
|
||||
|
||||
def test_malformed_json_returns_empty(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self._store_path(tmpdir)
|
||||
with open(path, "w") as f:
|
||||
f.write("{not json")
|
||||
self.assertEqual(load_session_store(path), {})
|
||||
|
||||
def test_non_dict_json_returns_empty(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self._store_path(tmpdir)
|
||||
with open(path, "w") as f:
|
||||
json.dump(["token"], f)
|
||||
self.assertEqual(load_session_store(path), {})
|
||||
|
||||
def test_expired_sessions_discarded(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self._store_path(tmpdir)
|
||||
now = time.time()
|
||||
save_session_store(path, {"alive": now + 3600, "dead": now - 1})
|
||||
loaded = load_session_store(path)
|
||||
self.assertIn("alive", loaded)
|
||||
self.assertNotIn("dead", loaded)
|
||||
|
||||
def test_invalid_entries_skipped(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self._store_path(tmpdir)
|
||||
now = time.time()
|
||||
with open(path, "w") as f:
|
||||
json.dump({
|
||||
"good": now + 3600,
|
||||
"": now + 3600, # empty token
|
||||
"bool-expiry": True, # bool is not a valid expiry
|
||||
"str-expiry": "soon", # non-numeric expiry
|
||||
"none-expiry": None,
|
||||
}, f)
|
||||
loaded = load_session_store(path)
|
||||
self.assertEqual(list(loaded.keys()), ["good"])
|
||||
|
||||
def test_file_mode_is_0600(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self._store_path(tmpdir)
|
||||
save_session_store(path, {"token": time.time() + 60})
|
||||
mode = os.stat(path).st_mode & 0o777
|
||||
self.assertEqual(mode, 0o600)
|
||||
|
||||
def test_save_overwrites_existing_store(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self._store_path(tmpdir)
|
||||
future = time.time() + 3600
|
||||
save_session_store(path, {"old": future})
|
||||
save_session_store(path, {"new": future})
|
||||
self.assertEqual(load_session_store(path), {"new": future})
|
||||
|
||||
def test_empty_store_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self._store_path(tmpdir)
|
||||
self.assertTrue(save_session_store(path, {}))
|
||||
self.assertEqual(load_session_store(path), {})
|
||||
|
||||
def test_no_leftover_temp_files(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = self._store_path(tmpdir)
|
||||
save_session_store(path, {"token": time.time() + 60})
|
||||
leftovers = [n for n in os.listdir(tmpdir) if n.startswith(".hub_sessions_tmp")]
|
||||
self.assertEqual(leftovers, [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tech-support.nix validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user