Compare commits

..
5 Commits
11 changed files with 368 additions and 24 deletions

No files matched your search

+83
View File
@@ -7,6 +7,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
--- ---
## [Unreleased]
### Fixed
- Fix Bitcoin Knots → Bitcoin Core switch hanging in the Hub UI
- Sessions are now persisted to `/var/lib/secrets/hub-sessions.json` so the
browser login survives the Hub service restart that `nixos-rebuild switch`
performs during activation. Previously the in-memory session store was
wiped by that restart, the `/api/rebuild/status` poll started returning
401, and the rebuild modal spun forever showing "Applying changes…" while
the switch result was never displayed.
- Rebuild and update modals now bail out and reload the page after sustained
polling failures instead of hanging indefinitely.
- Rebuild/update scripts stream `nixos-rebuild` output into the live log
(it was buffered until completion, making long rebuilds look frozen).
---
## [1.1.0] - 2026-08-11
### Added
- Speed up Hub service status loading
- Update Documentation
- Njalla.nix: fail on ImportError; fix redundant except clause
- Correctly escape DDNS placeholder in Nix string
- Security hardening: fix all 8 blocking findings for PR #419
- Security hardening: fix DDNS injection, Nix injection, reboot auth, support key, sudo rules
- Harden btcpay and nbxplorer config handling
- Add provenance headers and fix flake checks for PR409
- Restore upstream nix-bitcoin fetchNodeModules for mempool and RTL packages
- Refactor Nix build configuration for mempool
- Update npm dependencies and patch handling in default.nix
- Update npmDepsHash for backend and frontend packages
- Mempool: use postPatch to copy lock file for npmDeps fetcher
- Mempool: use preBuild cd instead of postUnpack so npmDeps fetcher finds lock files
- Mempool: fix npmDeps fetcher — postUnpack instead of sourceRoot, postPatch instead of patches
- Mempool: switch backend and frontend to buildNpmPackage with placeholder npmDepsHash
- Update npmDepsHash with correct hash value
- RTL: switch to buildNpmPackage with placeholder npmDepsHash
- Add bitcoind.rpc.users.btcpayserver for NBXplorer
- Restore original flake.nix with nixosModules.Sovran_SystemsOS export
- Add vendored RTL package and fix rtl.nix to use it
- Vendor mempool packages and wire mempool module to vendored pkgs
- Refactor onion service configuration for LND
- Add missing bitcoind-rpc-public-whitelist.nix
- Remove joinmarket-ob-watcher from onion-services defaults
- Tailor: make bitcoin modules truly Sovran-only (lnd-only) and delete stubs.nix
- Vendor: replace nix-bitcoin flake input with minimal vendored modules (nixpkgs-only)
- Remove duplicate 1.0.6 release notes from CHANGELOG
### Changed
- Clean mempool module comment typo
- Move vendor/nix-bitcoin to modules/bitcoin, remove overlays
- Nix flake update - drop nix-bitcoin
### Fixed
- Correct RTL and Mempool Hub versions
- Fix DDNS URL validation: replace ${IP} temporarily for validator, keep placeholder for storage
- Fix all 8 security hardening blockers for PR #423
- Fix Nix interpolation in DDNS runner
- Fix IP validation in DDNS and document journalctl sudo rule
- Report configured BTCPay Server version
- Fix Matrix SIGPIPE and RTL v0.15.8 config schema regressions
- Add NBXplorer cookie auth and WorkingDirectory for BTCPay service
- Fix BTCPay startup regression: add home dirs to service users
- Address btcpay hardening review feedback
- Register bitcoin-HMAC-btcpayserver as managed secret owned by bitcoind user
- Fix RTL: use fetchNodeModules instead of npm ci in buildPhase
- Fix PostgreSQL ensureUsers: use ensureDBOwnership instead of ensureClauses
- Fix lnd macaroons: replace invalid 'enable' with 'user'
- Fix postgresql ensurePermissions -> ensureClauses for nixpkgs unstable
- Fix btcpayserver.nix to use pkgs.stable overlay
- Fix btcpayserver syntax error and pin to nixpkgs-stable (2.4.2)
- Fix mempool package: remove fetchNodeModules dependency
- Fix typo in onion-addresses service check
- Fix formatting of extraGroups in btcpayserver.nix
- Add missing semicolon after extraGroups in btcpayserver.nix
- Remove services.clightning.enable assignment that fails on f13ff45
- Remove clightning and clightning-rest from stubs to avoid duplicate on f13ff45
- Add stubs for nixpkgs-unstable 2026-08 where services.clightning removed
- Keep ISO artifacts out of the repo and auto-update README on release
[1.1.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.0
## [1.0.6] - 2026-08-07 ## [1.0.6] - 2026-08-07
### Added ### Added
+12 -12
View File
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
are ready. are ready.
[Visit the Website](https://sovransystems.com) · [Visit the Website](https://sovransystems.com) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso) · [Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso) ·
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) · [Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso.sha256) · [Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso.sha256) ·
[Build from Source](#build-from-source) [Build from Source](#build-from-source)
<img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" /> <img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
@@ -342,8 +342,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
### 1. Download the ISO and checksum ### 1. Download the ISO and checksum
- [Download Sovran_SystemsOS-1.0.6.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso) - [Download Sovran_SystemsOS-1.1.0.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.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.0.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso.sha256)
The download may take some time. Do not rename or modify the ISO before The download may take some time. Do not rename or modify the ISO before
verifying it, and keep both files in the same folder. verifying it, and keep both files in the same folder.
@@ -361,16 +361,16 @@ checksum exactly.
Open a terminal in the download folder and run: Open a terminal in the download folder and run:
```bash ```bash
sha256sum --check Sovran_SystemsOS-1.0.6.iso.sha256 sha256sum --check Sovran_SystemsOS-1.1.0.iso.sha256
``` ```
A successful comparison reports: A successful comparison reports:
```text ```text
Sovran_SystemsOS-1.0.6.iso: OK Sovran_SystemsOS-1.1.0.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.0.iso` and compare the output
against the checksum file manually. against the checksum file manually.
</details> </details>
@@ -381,11 +381,11 @@ against the checksum file manually.
Open Terminal in the download folder and run: Open Terminal in the download folder and run:
```bash ```bash
shasum -a 256 Sovran_SystemsOS-1.0.6.iso shasum -a 256 Sovran_SystemsOS-1.1.0.iso
``` ```
Compare the value shown in Terminal with the value inside Compare the value shown in Terminal with the value inside
`Sovran_SystemsOS-1.0.6.iso.sha256`. `Sovran_SystemsOS-1.1.0.iso.sha256`.
</details> </details>
@@ -395,7 +395,7 @@ Compare the value shown in Terminal with the value inside
Open PowerShell in the download folder and run: Open PowerShell in the download folder and run:
```powershell ```powershell
Get-FileHash .\Sovran_SystemsOS-1.0.6.iso -Algorithm SHA256 Get-FileHash .\Sovran_SystemsOS-1.1.0.iso -Algorithm SHA256
``` ```
Compare the value under `Hash` with the published checksum. Compare the value under `Hash` with the published checksum.
@@ -410,7 +410,7 @@ match exactly.
1. Download and install [Balena Etcher](https://etcher.balena.io), then 1. Download and install [Balena Etcher](https://etcher.balena.io), then
connect the USB drive. connect the USB drive.
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.0.6.iso`. 2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.0.iso`.
3. Choose **Select target**, select the USB drive, and review your selection 3. Choose **Select target**, select the USB drive, and review your selection
carefully. carefully.
4. Choose **Flash** and wait for the writing and verification process to 4. Choose **Flash** and wait for the writing and verification process to
@@ -855,7 +855,7 @@ primary location for collaboration. Please read our
## Privacy. Sovereignty. Bitcoin. ## Privacy. Sovereignty. Bitcoin.
[Visit Sovran Systems](https://sovransystems.com) · [Visit Sovran Systems](https://sovransystems.com) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso) · [Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.0.iso) ·
[View the License](LICENSE) [View the License](LICENSE)
</div> </div>
+1 -1
View File
@@ -1 +1 @@
1.0.6 1.1.0
@@ -11,7 +11,11 @@ from __future__ import annotations
import base64 import base64
import ipaddress import ipaddress
import json
import os
import re import re
import tempfile
import time
import urllib.parse import urllib.parse
# ── Nix string escaping ──────────────────────────────────────────────────────── # ── Nix string escaping ────────────────────────────────────────────────────────
@@ -239,3 +243,71 @@ def _validate_ssh_pubkey(key: str) -> str:
if len(decoded) < 20: if len(decoded) < 20:
raise ValueError("SSH public key payload is too short") raise ValueError("SSH public key payload is too short")
return key 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
+64 -4
View File
@@ -52,6 +52,8 @@ from .security_helpers import (
_SSH_PUBKEY_ALGORITHMS, _SSH_PUBKEY_ALGORITHMS,
_bech32_decode, _bech32_decode,
_bech32_convertbits_decode, _bech32_convertbits_decode,
load_session_store,
save_session_store,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -144,8 +146,27 @@ HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
SESSION_COOKIE_NAME = "hub_session" SESSION_COOKIE_NAME = "hub_session"
SESSION_MAX_AGE = 86400 # 24 hours SESSION_MAX_AGE = 86400 # 24 hours
# 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
# Knots → Core switch changes services.bitcoind.package, which is on the Hub's
# 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: 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 # Failed login tracking: ip → list of failure timestamps
_login_failures: dict[str, list[float]] = {} _login_failures: dict[str, list[float]] = {}
@@ -606,38 +627,74 @@ def _get_or_create_session_secret() -> bytes:
return token_hex 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: def _create_session() -> str:
"""Create a new opaque session token and register it in the store.""" """Create a new opaque session token and register it in the store."""
_load_sessions_once()
_purge_expired_sessions() _purge_expired_sessions()
token = secrets.token_hex(32) token = secrets.token_hex(32)
_sessions[token] = time.time() + SESSION_MAX_AGE _sessions[token] = time.time() + SESSION_MAX_AGE
_persist_sessions(force=True)
return token return token
def _destroy_session(token: str) -> None: def _destroy_session(token: str) -> None:
"""Remove a session token from the store.""" """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: 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() now = time.time()
expired = [tok for tok, exp in _sessions.items() if exp <= now] expired = [tok for tok, exp in _sessions.items() if exp <= now]
for tok in expired: for tok in expired:
del _sessions[tok] del _sessions[tok]
if expired:
_persist_sessions(force=True)
def _is_authenticated(request: Request) -> bool: def _is_authenticated(request: Request) -> bool:
"""Return True if the request carries a valid, unexpired session cookie.""" """Return True if the request carries a valid, unexpired session cookie."""
_load_sessions_once()
token = request.cookies.get(SESSION_COOKIE_NAME) token = request.cookies.get(SESSION_COOKIE_NAME)
if not token: if not token:
return False return False
expiry = _sessions.get(token) expiry = _sessions.get(token)
if expiry is None or time.time() >= expiry: 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 return False
# Slide the expiry window on activity # Slide the expiry window on activity
_sessions[token] = time.time() + SESSION_MAX_AGE _sessions[token] = time.time() + SESSION_MAX_AGE
_persist_sessions() # throttled — don't rewrite the store on every poll
return True return True
@@ -6152,6 +6209,9 @@ async def _startup_session_secret():
"""Ensure the session secret exists on disk at startup.""" """Ensure the session secret exists on disk at startup."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _get_or_create_session_secret) 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 ────────────────── # ── Startup: recover stale RUNNING status files ──────────────────
@@ -5,6 +5,10 @@
const POLL_INTERVAL_SERVICES = 5000; const POLL_INTERVAL_SERVICES = 5000;
const POLL_INTERVAL_UPDATES = 1800000; const POLL_INTERVAL_UPDATES = 1800000;
const UPDATE_POLL_INTERVAL = 2000; const UPDATE_POLL_INTERVAL = 2000;
// Max consecutive failed rebuild/update status polls before the page gives up
// waiting and reloads to re-sync (2s interval → ~2 minutes of failures).
// A brief Hub restart during activation only causes a handful of failures.
const STATUS_POLL_MAX_FAILURES = 60;
const REBOOT_CHECK_INTERVAL = 5000; const REBOOT_CHECK_INTERVAL = 5000;
const REBOOT_FETCH_TIMEOUT = 12000; const REBOOT_FETCH_TIMEOUT = 12000;
const REBOOT_REQUEST_TIMEOUT = 4000; const REBOOT_REQUEST_TIMEOUT = 4000;
@@ -8,6 +8,7 @@ function openRebuildModal() {
_rebuildLogOffset = 0; _rebuildLogOffset = 0;
_rebuildServerDown = false; _rebuildServerDown = false;
_rebuildFinished = false; _rebuildFinished = false;
_rebuildPollFailures = 0;
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; } if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
var action = _rebuildIsEnabling ? "Enabling" : "Disabling"; var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
var label = _rebuildFeatureName || "feature"; var label = _rebuildFeatureName || "feature";
@@ -45,6 +46,7 @@ async function pollRebuildStatus() {
if (_rebuildFinished) return; if (_rebuildFinished) return;
try { try {
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset); var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
_rebuildPollFailures = 0;
if (_rebuildServerDown) { _rebuildServerDown = false; } if (_rebuildServerDown) { _rebuildServerDown = false; }
if (data.log) appendRebuildLog(data.log); if (data.log) appendRebuildLog(data.log);
_rebuildLogOffset = data.offset; _rebuildLogOffset = data.offset;
@@ -57,6 +59,18 @@ async function pollRebuildStatus() {
onRebuildDone(data.result === "success"); onRebuildDone(data.result === "success");
} }
} catch (err) { } catch (err) {
_rebuildPollFailures += 1;
// The Hub restarts itself during activation, which briefly drops this poll.
// If polling stays broken long past a normal restart, the page's session
// almost certainly no longer matches the server (e.g. the Hub restarted
// and sessions were not recovered). Reload to re-authenticate and show
// the real feature state instead of spinning forever.
if (_rebuildPollFailures >= STATUS_POLL_MAX_FAILURES) {
_rebuildFinished = true;
stopRebuildPoll();
window.location.reload();
return;
}
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; } if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
} }
} }
@@ -9,6 +9,7 @@ let _updatePollTimer = null;
let _updateLogOffset = 0; let _updateLogOffset = 0;
let _serverWasDown = false; let _serverWasDown = false;
let _updateFinished = false; let _updateFinished = false;
let _updatePollFailures = 0; // consecutive failed update-status polls
let _supportTimerInt = null; let _supportTimerInt = null;
let _supportEnabledAt = null; let _supportEnabledAt = null;
let _supportStatus = null; // last fetched /api/support/status payload let _supportStatus = null; // last fetched /api/support/status payload
@@ -25,6 +26,7 @@ let _rebuildLogOffset = 0;
let _rebuildPollTimer = null; let _rebuildPollTimer = null;
let _rebuildFinished = false; let _rebuildFinished = false;
let _rebuildServerDown = false; let _rebuildServerDown = false;
let _rebuildPollFailures = 0; // consecutive failed rebuild-status polls
let _pendingToggle = null; // {feature, extra} waiting for domain/confirm let _pendingToggle = null; // {feature, extra} waiting for domain/confirm
let _rebuildFeatureName = ""; let _rebuildFeatureName = "";
let _rebuildIsEnabling = true; let _rebuildIsEnabling = true;
@@ -33,6 +33,7 @@ function _doOpenUpdateModal() {
_updateLogOffset = 0; _updateLogOffset = 0;
_serverWasDown = false; _serverWasDown = false;
_updateFinished = false; _updateFinished = false;
_updatePollFailures = 0;
if ($modalLog) $modalLog.textContent = ""; if ($modalLog) $modalLog.textContent = "";
if ($modalStatus) $modalStatus.textContent = "Starting update…"; if ($modalStatus) $modalStatus.textContent = "Starting update…";
if ($modalSpinner) $modalSpinner.classList.add("spinning"); if ($modalSpinner) $modalSpinner.classList.add("spinning");
@@ -94,6 +95,7 @@ async function pollUpdateStatus() {
if (_updateFinished) return; if (_updateFinished) return;
try { try {
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset); var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
_updatePollFailures = 0;
if (_serverWasDown) { if (_serverWasDown) {
_serverWasDown = false; _serverWasDown = false;
if (!data.running) { if (!data.running) {
@@ -143,6 +145,16 @@ async function pollUpdateStatus() {
onUpdateDone(false); onUpdateDone(false);
} }
} catch (err) { } catch (err) {
_updatePollFailures += 1;
// Same guard as the rebuild modal: if polling stays broken long past a
// normal Hub restart, reload to re-authenticate and show the real state
// instead of spinning forever.
if (_updatePollFailures >= STATUS_POLL_MAX_FAILURES) {
_updateFinished = true;
stopUpdatePoll();
window.location.reload();
return;
}
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; } if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
} }
} }
+11 -7
View File
@@ -185,13 +185,14 @@ let
if [ "$RC" -eq 0 ]; then if [ "$RC" -eq 0 ]; then
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──" echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
BOOT_OUT=$(nixos-rebuild boot --flake /etc/nixos --print-build-logs \ # Stream output straight into $LOG (see rebuild-script) so the Hub UI
# shows live progress instead of an empty log during long builds.
nixos-rebuild boot --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \ --option connect-timeout 10 \
--option stalled-download-timeout 90 \ --option stalled-download-timeout 90 \
--option download-attempts 7 \ --option download-attempts 7 \
--option fallback true 2>&1) --option fallback true
BOOT_RC=$? BOOT_RC=$?
echo "$BOOT_OUT"
if [ "$BOOT_RC" -ne 0 ]; then if [ "$BOOT_RC" -ne 0 ]; then
echo "[ERROR] nixos-rebuild boot failed" echo "[ERROR] nixos-rebuild boot failed"
RC=1 RC=1
@@ -240,20 +241,23 @@ let
echo "══════════════════════════════════════════════════" echo "══════════════════════════════════════════════════"
echo "" echo ""
echo "── Rebuilding system configuration ──────────────" echo "── Rebuilding system configuration ──────────────"
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \ # Stream output straight into $LOG (tee'd by the exec redirect above) so
# the Hub UI shows live progress. Capturing the output in a variable
# kept the log empty for the entire build+activation, which made long
# rebuilds (e.g. the Bitcoin Knots → Core switch) look like a hang.
nixos-rebuild switch --flake /etc/nixos --print-build-logs \
--option connect-timeout 10 \ --option connect-timeout 10 \
--option stalled-download-timeout 90 \ --option stalled-download-timeout 90 \
--option download-attempts 7 \ --option download-attempts 7 \
--option fallback true 2>&1) --option fallback true
SWITCH_RC=$? SWITCH_RC=$?
echo "$SWITCH_OUT"
if [ "$SWITCH_RC" -eq 0 ]; then if [ "$SWITCH_RC" -eq 0 ]; then
echo "" echo ""
echo "══════════════════════════════════════════════════" echo "══════════════════════════════════════════════════"
echo " ✓ Rebuild completed successfully" echo " ✓ Rebuild completed successfully"
echo "══════════════════════════════════════════════════" echo "══════════════════════════════════════════════════"
echo "SUCCESS" > "$STATUS" echo "SUCCESS" > "$STATUS"
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then elif grep -q "switchInhibitors\|Pre-switch checks failed" "$LOG"; then
echo "" echo ""
echo " ✓ Build succeeded — a reboot is required to apply this rebuild" echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
echo " (Critical system components changed; running nixos-rebuild boot instead)" echo " (Critical system components changed; running nixos-rebuild boot instead)"
+93
View File
@@ -14,6 +14,7 @@ import json
import os import os
import sys import sys
import tempfile import tempfile
import time
import unittest import unittest
# Add the app package to the path so we can import without the full FastAPI tree. # 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, _validate_ssh_pubkey,
_DDNS_ALLOWED_HOSTNAMES, _DDNS_ALLOWED_HOSTNAMES,
_bech32_decode, _bech32_decode,
load_session_store,
save_session_store,
) )
from sovran_systemsos_web import support_ops # noqa: E402 from sovran_systemsos_web import support_ops # noqa: E402
@@ -362,6 +365,96 @@ class TestAuthExemptPaths(unittest.TestCase):
self.assertIn("/api/ping", self._get_exempt_paths()) self.assertIn("/api/ping", self._get_exempt_paths())
# ---------------------------------------------------------------------------
# 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 # tech-support.nix validation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------