2 Commits

26 changed files with 1047 additions and 964 deletions
+1 -151
View File
@@ -1,151 +1 @@
<div align="center"> ### Testing Branch
<img src="iso/assets/sovran-hub-icon.svg" alt="Sovran Systems" width="160" />
# Sovran_SystemsOS
`Base Development` · NixOS Flake · AGPL-3.0
[Sovran Systems](https://sovransystems.com)
</div>
---
## Table of Contents
1. [What This Repo Is](#what-this-repo-is)
2. [Architecture](#architecture)
3. [Module Catalog](#module-catalog)
4. [The Three Modes (internal reference)](#the-three-modes-internal-reference)
5. [Build & Deploy Reference](#build--deploy-reference)
6. [Networking & Reverse Proxy](#networking--reverse-proxy)
7. [Security Posture](#security-posture)
8. [Backups & Recovery](#backups--recovery)
9. [License](#license)
---
## What This Repo Is
Sovran_SystemsOS is defined entirely as a **Nix flake** (`flake.nix`) and built from source. There is no pre-built binary — the System Installer is produced from this tree. Everything the system does is declared here.
The control center is the **Hub** — a built-in panel that lets the operator launch, monitor, and toggle services without touching a terminal. Under the hood, the Hub writes to `custom.nix`, which feeds back into the flake.
## Architecture
```
┌─────────────────────────┐
│ flake.nix │
│ inputs: nixpkgs, │
│ nix-bitcoin, nixvim, │
│ btc-clients │
└───────────┬─────────────┘
│ nixosModules.Sovran_SystemsOS
┌──────────────────────────┐ imports ┌──────────────────────────┐
│ configuration.nix │────────────▶│ modules/modules.nix │
│ boot / fs / users / │ │ core/* + services + opt │
│ desktop / nix settings │ │ features │
└──────────────────────────┘ └──────────┬───────────────┘
▲ │
│ ./role-state.nix (mode/role) ▼
│ ./custom.nix (user overrides) ┌────────────────────┐
│ │ modules/*.nix │
└───────── sovran-hub writes ───────▶│ synapse / wordpress│
│ nextcloud / etc. │
└────────────────────┘
```
- **`flake.nix`** declares two NixOS configurations:
- `nixosConfigurations.nixos` — the running system.
- `nixosConfigurations.sovran_systemsos-iso` — the System Installer.
- **`configuration.nix`** owns host concerns (boot, filesystems, users, desktop, locale, Nix settings, firewall, audio, backups).
- **`modules/modules.nix`** is the service router. Every other module is opt-in via flags read from `role-state.nix` and `custom.nix`.
## Module Catalog
Defaults follow the import order in `modules/modules.nix`. Toggles live in `custom.nix` (the Hub writes them) and `role-state.nix`.
| Module | Default | Purpose |
|---|---|---|
| `core/*` | **on** | Roles, Caddy, Njalla, Hub, desktop, perf, ssh-bootstrap |
| `php.nix`, `credentials.nix` | **on** | Required by web services & secrets |
| `synapse.nix` | **on** | Matrix homeserver |
| `wordpress.nix` | **on** | WordPress + PHP-FPM vhost |
| `nextcloud.nix` | **on** | Files / calendar / contacts |
| `vaultwarden.nix` | **on** | Bitwarden-compatible secrets vault |
| `bitcoinecosystem.nix` | **on** | bitcoind/electrs/LND/RTL/BTCPay (over Tor) |
| `wallet-autoconnect.nix` | **on** | Sparrow/Bisq ↔ node handshake |
| `haven.nix` | off | Nostr relay |
| `element-calling.nix` | off | LiveKit + JWT for E2E calling |
| `mempool.nix` | off | Mempool.space dashboard |
| `bitcoin-core.nix` | off | Switch node to Bitcoin Core (replaces default Bitcoin Knots + BIP110) |
| `rdp.nix` | off | xrdp remote desktop |
| `sshd.nix` | off | Public-facing OpenSSH |
> Tor is wired directly into the Bitcoin stack. In `modules/bitcoinecosystem.nix`, `bitcoind`, `electrs`, and `lnd` all set `tor.enforce = true` and `tor.proxy = true`, and onion services are exposed for them.
## The Three Modes (internal reference)
Selected by `role-state.nix`, resolved by `modules/core/role-logic.nix`. All three configurations are produced from this same flake.
| Mode | What's enabled on top of the base NixOS + GNOME |
|---|---|
| **Desktop** | Private daily-driver. Sparrow + Bisq included. |
| **Node** | Desktop + full Bitcoin stack (bitcoind/electrs/LND/RTL/BTCPay over Tor). |
| **Server+Desktop** | Node + self-hosting services (Synapse, Nextcloud, WordPress, Vaultwarden, Element Calling, etc.). |
## Build & Deploy Reference
Internal commands. Run from the flake root.
| Action | Command |
|---|---|
| Build the System Installer | `nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage` |
| Switch now | `sudo nixos-rebuild switch --flake .#nixos` |
| Test in current boot only | `sudo nixos-rebuild test --flake .#nixos` |
| Stage for next boot | `sudo nixos-rebuild boot --flake .#nixos` |
| Build only (no activation) | `nixos-rebuild build --flake .#nixos` |
| Update pinned inputs | `nix flake update` (then rebuild) |
| Rollback last switch | `sudo nixos-rebuild switch --rollback` |
| Garbage-collect (>7 days) | Automatic weekly; manual: `sudo nix-collect-garbage -d` |
## Networking & Reverse Proxy
- **Firewall on by default** (`networking.firewall.enable = true`). Port are opened by the module that needs it.
- **Caddy** (`modules/core/caddy.nix`) terminates TLS for all HTTP services.
- **Njalla** dynamic DNS (`modules/core/njalla.nix`) keeps records in sync via a 15-minute cron job.
- **Tor** is enabled with `torsocks` available. The Bitcoin stack uses it directly — see [Security Posture](#security-posture).
- **SSH:** localhost-only by default (`core/sshd-localhost.nix`).
## Security Posture
Facts about the defaults, straight from `configuration.nix` and the modules:
- **Reproducible builds.** Every artifact derives from `flake.lock`. The same commit produces the same OS.
- **Bitcoin stack over Tor.** In `modules/bitcoinecosystem.nix`, `bitcoind`, `electrs`, and `lnd` all set `tor.enforce = true`, and onion services are exposed for `bitcoind`, `electrs`, `lnd`, and friends.
- **Firewall on, public sshd off, RDP off, auto-login off, fail2bain active**
- **Kernel surface trimmed.** `boot.blacklistedKernelModules = [ "rxrpc" ];`
- **Weekly garbage collection** with `--delete-older-than 7d`.
## Backups & Recovery
`services.rsnapshot` snapshots hourly and daily to `/run/media/Second_Drive/BTCEcoandBackup/NixOS_Snapshot_Backup`:
```
backup /home/ localhost/
backup /var/lib/ localhost/
backup /etc/nixos/ localhost/
backup /etc/nix-bitcoin-secrets/ localhost/
retain hourly 5
retain daily 5
cron hourly 0 * * * *
cron daily 50 21 * * *
```
The second drive is mounted by label (`BTCEcoandBackup`) with `nofail` so a missing drive doesn't block boot.
## License
Licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](./LICENSE).
+46 -280
View File
@@ -204,7 +204,7 @@ FEATURE_REGISTRY = [
{"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"}, {"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
{"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"}, {"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"},
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"}, {"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"}, {"port": "7882-7894", "protocol": "UDP", "description": "LiveKit media streams"},
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"}, {"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"}, {"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"}, {"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
@@ -223,15 +223,27 @@ FEATURE_REGISTRY = [
"port_requirements": [], "port_requirements": [],
}, },
{ {
"id": "bitcoin-core", "id": "bip110",
"name": "Bitcoin Core", "name": "Bitcoin Knots + BIP110",
"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.", "description": "Only one Bitcoin node implementation can be active at a time: Bitcoin Knots (default), Bitcoin Knots + BIP110, or Bitcoin Core. Enabling this option replaces the default Bitcoin Knots with Bitcoin Knots + BIP110 consensus changes. It will disable the currently active alternative.",
"category": "bitcoin", "category": "bitcoin",
"needs_domain": False, "needs_domain": False,
"domain_name": None, "domain_name": None,
"needs_ddns": False, "needs_ddns": False,
"extra_fields": [], "extra_fields": [],
"conflicts_with": [], "conflicts_with": ["bitcoin-core"],
"port_requirements": [],
},
{
"id": "bitcoin-core",
"name": "Bitcoin Core",
"description": "Only one Bitcoin node implementation can be active at a time: Bitcoin Knots (default), Bitcoin Knots + BIP110, or Bitcoin Core. Enabling this option replaces the default Bitcoin Knots with Bitcoin Core. It will disable the currently active alternative.",
"category": "bitcoin",
"needs_domain": False,
"domain_name": None,
"needs_ddns": False,
"extra_fields": [],
"conflicts_with": ["bip110"],
"port_requirements": [], "port_requirements": [],
}, },
{ {
@@ -265,16 +277,13 @@ 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"}
# Map feature IDs to their systemd units in config.json # Map feature IDs to their systemd units in config.json
FEATURE_SERVICE_MAP = { FEATURE_SERVICE_MAP = {
"rdp": "gnome-remote-desktop.service", "rdp": "gnome-remote-desktop.service",
"haven": "haven-relay.service", "haven": "haven-relay.service",
"element-calling": "livekit.service", "element-calling": "livekit.service",
"mempool": "mempool.service", "mempool": "mempool.service",
"bip110": None,
"bitcoin-core": None, "bitcoin-core": None,
"btcpay-web": "btcpayserver.service", "btcpay-web": "btcpayserver.service",
"sshd": "sshd.service", "sshd": "sshd.service",
@@ -286,7 +295,7 @@ _PORTS_MATRIX_FEDERATION = [
] ]
_PORTS_ELEMENT_CALLING = [ _PORTS_ELEMENT_CALLING = [
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"}, {"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"}, {"port": "7882-7894", "protocol": "UDP", "description": "LiveKit media streams"},
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"}, {"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"}, {"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"}, {"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
@@ -322,6 +331,7 @@ SERVICE_DOMAIN_MAP: dict[str, str] = {
# For features that share a unit, disambiguate by icon field # For features that share a unit, disambiguate by icon field
FEATURE_ICON_MAP = { FEATURE_ICON_MAP = {
"bip110": "bip110",
"bitcoin-core": "bitcoin-core", "bitcoin-core": "bitcoin-core",
} }
@@ -342,7 +352,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = {
ROLE_FEATURES: dict[str, set[str] | None] = { ROLE_FEATURES: dict[str, set[str] | None] = {
"server_plus_desktop": None, "server_plus_desktop": None,
"desktop": {"rdp", "sshd"}, "desktop": {"rdp", "sshd"},
"node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "sshd"}, "node": {"rdp", "bip110", "bitcoin-core", "mempool", "btcpay-web", "sshd"},
} }
SERVICE_DESCRIPTIONS: dict[str, str] = { SERVICE_DESCRIPTIONS: dict[str, str] = {
@@ -642,37 +652,6 @@ templates = Jinja2Templates(directory=os.path.join(_BASE_DIR, "templates"))
# ── Static asset cache-busting ──────────────────────────────────── # ── Static asset cache-busting ────────────────────────────────────
def _compute_asset_version() -> str:
"""Return a 16-char asset version from Nix store hash or static/template metadata."""
nix_match = re.search(r"/nix/store/([a-z0-9]{32})-", os.path.realpath(_BASE_DIR))
if nix_match:
return nix_match.group(1)[:16]
hasher = hashlib.sha256()
for root in (
os.path.join(_BASE_DIR, "static"),
os.path.join(_BASE_DIR, "templates"),
):
if not os.path.isdir(root):
continue
for dirpath, dirnames, filenames in os.walk(root):
dirnames.sort()
for filename in sorted(filenames):
path = os.path.join(dirpath, filename)
try:
stat = os.stat(path)
except OSError:
continue
hasher.update(path.encode())
hasher.update(b"\0")
hasher.update(f"{stat.st_mtime_ns}:{stat.st_size}".encode())
hasher.update(b"\0")
return hasher.hexdigest()[:16]
ASSET_VERSION = _compute_asset_version()
def _file_hash(filename: str) -> str: def _file_hash(filename: str) -> str:
"""Return first 8 chars of the MD5 hex digest for a static file.""" """Return first 8 chars of the MD5 hex digest for a static file."""
path = os.path.join(_BASE_DIR, "static", filename) path = os.path.join(_BASE_DIR, "static", filename)
@@ -915,7 +894,7 @@ def _get_firewall_allowed_ports() -> dict[str, set[int]]:
def _port_range_to_ints(port_str: str) -> list[int]: def _port_range_to_ints(port_str: str) -> list[int]:
"""Convert a port string like ``"443"``, ``"30000-40000"`` to a list of ints.""" """Convert a port string like ``"443"``, ``"7882-7894"`` to a list of ints."""
port_str = port_str.strip() port_str = port_str.strip()
if re.match(r'^\d+$', port_str): if re.match(r'^\d+$', port_str):
return [int(port_str)] return [int(port_str)]
@@ -1509,9 +1488,7 @@ def _read_hub_overrides() -> tuple[dict, str | None, str | None, str | None]:
r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;', r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;',
section, section,
): ):
feat_id = m.group(1) features[m.group(1)] = m.group(2) == "true"
if feat_id not in DEPRECATED_FEATURE_IDS:
features[feat_id] = m.group(2) == "true"
for m in re.finditer( for m in re.finditer(
r'sovran_systemsOS\.web\.btcpayserver\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;', r'sovran_systemsOS\.web\.btcpayserver\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;',
section, section,
@@ -1544,8 +1521,6 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
"""Write the Hub Managed section inside custom.nix.""" """Write the Hub Managed section inside custom.nix."""
lines = [] lines = []
for feat_id, enabled in features.items(): for feat_id, enabled in features.items():
if feat_id in DEPRECATED_FEATURE_IDS:
continue
val = "true" if enabled else "false" val = "true" if enabled else "false"
if feat_id == "btcpay-web": if feat_id == "btcpay-web":
lines.append(f" sovran_systemsOS.web.btcpayserver = lib.mkForce {val};") lines.append(f" sovran_systemsOS.web.btcpayserver = lib.mkForce {val};")
@@ -1591,40 +1566,6 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
f.write(content) f.write(content)
def _migrate_strip_deprecated_features() -> None:
"""One-time migration: remove deprecated feature lines from the Hub Managed
section of custom.nix. Any feature id in DEPRECATED_FEATURE_IDS is dropped
while all other Hub-managed settings (other features, nostr_npub, timezone,
locale) are preserved byte-for-byte in meaning.
This is a no-op (and never raises) if CUSTOM_NIX is missing, unreadable, or
contains no deprecated lines.
"""
try:
with open(CUSTOM_NIX, "r") as f:
content = f.read()
except (FileNotFoundError, OSError):
return
# Quick-exit: if none of the deprecated ids appear, nothing to do.
hub_begin = content.find(HUB_BEGIN)
hub_end = content.find(HUB_END)
if hub_begin == -1 or hub_end == -1:
return
section = content[hub_begin:hub_end]
if not any(f"features.{dep_id}" in section for dep_id in DEPRECATED_FEATURE_IDS):
return
try:
features, nostr_npub, timezone, locale = _read_hub_overrides()
# _read_hub_overrides already excludes DEPRECATED_FEATURE_IDS, so
# calling _write_hub_overrides with its output drops the stale lines.
_write_hub_overrides(features, nostr_npub, timezone, locale)
except Exception:
# Never let a migration failure break startup.
logger.exception("_migrate_strip_deprecated_features: unexpected error (non-fatal)")
# ── Feature status helpers ───────────────────────────────────────── # ── Feature status helpers ─────────────────────────────────────────
def _is_feature_enabled_in_config(feature_id: str) -> bool | None: def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
@@ -1634,7 +1575,7 @@ def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
return False # Default off in Node role; only on via explicit hub toggle return False # Default off in Node role; only on via explicit hub toggle
unit = FEATURE_SERVICE_MAP.get(feature_id) unit = FEATURE_SERVICE_MAP.get(feature_id)
if unit is None: if unit is None:
return None # bitcoin-core — can't determine from config return None # bip110, bitcoin-core — can't determine from config
cfg = load_config() cfg = load_config()
for svc in cfg.get("services", []): for svc in cfg.get("services", []):
if svc.get("unit") == unit: if svc.get("unit") == unit:
@@ -1953,10 +1894,7 @@ def _verify_support_removed() -> bool:
@app.get("/login", response_class=HTMLResponse) @app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request): async def login_page(request: Request):
return templates.TemplateResponse("login.html", { return templates.TemplateResponse("login.html", {"request": request})
"request": request,
"asset_version": ASSET_VERSION,
})
@app.get("/auto-login") @app.get("/auto-login")
@@ -2023,7 +1961,6 @@ async def api_logout(request: Request):
async def index(request: Request): async def index(request: Request):
return templates.TemplateResponse("index.html", { return templates.TemplateResponse("index.html", {
"request": request, "request": request,
"asset_version": ASSET_VERSION,
}) })
@@ -2032,7 +1969,6 @@ async def onboarding(request: Request):
_ensure_onboarding_reopened_for_migration() _ensure_onboarding_reopened_for_migration()
return templates.TemplateResponse("onboarding.html", { return templates.TemplateResponse("onboarding.html", {
"request": request, "request": request,
"asset_version": ASSET_VERSION,
"onboarding_js_hash": _ONBOARDING_JS_HASH, "onboarding_js_hash": _ONBOARDING_JS_HASH,
}) })
@@ -2253,16 +2189,6 @@ _BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime
# Cache for ``bitcoind --version`` output (available even before RPC is ready) # Cache for ``bitcoind --version`` output (available even before RPC is ready)
_btcd_version_cache: tuple[float, str | None] = (0.0, None) _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) ───────── # ── Generic service version detection (NixOS store path) ─────────
@@ -2377,160 +2303,12 @@ def _get_bitcoin_version_info() -> dict | None:
return 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,
timeout=10,
)
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: def _get_bitcoind_version() -> str | None:
"""Run ``bitcoind --version`` and return the raw version string, or None on error. """Run ``bitcoind --version`` and return the raw version string, or None on error.
Parses the first output line to extract the token after "version ". Parses the first output line to extract the token after "version ".
For example: "Bitcoin Knots daemon version v29.3.knots20260508" For example: "Bitcoin Knots daemon version v29.3.knots20260210+bip110-v0.4.1"
returns "v29.3.knots20260508". returns "v29.3.knots20260210+bip110-v0.4.1".
Works regardless of whether the RPC server is ready (IBD, warmup, etc.). Works regardless of whether the RPC server is ready (IBD, warmup, etc.).
Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL). Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL).
@@ -2565,13 +2343,26 @@ def _get_bitcoind_version() -> str | None:
def _format_bitcoin_version(raw_version: str, icon: str = "") -> str: def _format_bitcoin_version(raw_version: str, icon: str = "") -> str:
"""Format a raw version string from ``bitcoind --version`` for tile display. """Format a raw version string from ``bitcoind --version`` for tile display.
For the BIP110 tile (icon == "bip110") a " (bip110)" tag is appended, Strips the ``+bip110-vX.Y.Z`` patch suffix so the base version is shown
since mainline Bitcoin Knots (29.3.knots20260508+) now includes BIP-110 cleanly (e.g. "v29.3.knots20260210+bip110-v0.4.1""v29.3.knots20260210").
and no longer carries a separate ``+bip110-vX.Y.Z`` suffix. For the BIP110 tile (icon == "bip110") a " (bip110 vX.Y.Z)" tag is appended
including the patch version.
""" """
display = raw_version # Extract the BIP110 patch version before stripping the suffix
if icon == "bip110" and "(bip110)" not in display.lower(): bip110_ver = ""
display += " (bip110)" bip_match = re.search(r"\+bip110-v(\S+)", raw_version)
if bip_match:
bip110_ver = bip_match.group(1)
# Strip the +bip110... suffix for the base Knots version
display = re.sub(r"\+bip110\S*", "", raw_version)
# For BIP110 tile, append both the tag and the patch version
if icon == "bip110":
if bip110_ver:
display += f" (bip110 v{bip110_ver})"
elif "(bip110)" not in display.lower():
display += " (bip110)"
return display return display
@@ -2639,19 +2430,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") @app.get("/api/services")
async def api_services(): async def api_services():
cfg = load_config() cfg = load_config()
@@ -2832,8 +2610,6 @@ async def api_services():
btc_ver = _format_bitcoin_version(raw_ver, icon=icon) btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
service_data["bitcoin_version"] = btc_ver # backwards compat service_data["bitcoin_version"] = btc_ver # backwards compat
service_data["version"] = btc_ver service_data["version"] = btc_ver
if icon == "bip110":
service_data["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
return service_data return service_data
results = await asyncio.gather(*[get_status(s) for s in services]) results = await asyncio.gather(*[get_status(s) for s in services])
@@ -3118,8 +2894,6 @@ async def api_service_detail(unit: str, icon: str | None = None):
btc_ver = _format_bitcoin_version(raw_ver, icon=icon) btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
service_detail["bitcoin_version"] = btc_ver # backwards compat service_detail["bitcoin_version"] = btc_ver # backwards compat
service_detail["version"] = btc_ver service_detail["version"] = btc_ver
if icon == "bip110":
service_detail["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
return service_detail return service_detail
@@ -4787,14 +4561,6 @@ async def _startup_recover_stale_status():
await loop.run_in_executor(None, _recover_stale_status, REBUILD_STATUS, REBUILD_LOG, REBUILD_UNIT) await loop.run_in_executor(None, _recover_stale_status, REBUILD_STATUS, REBUILD_LOG, REBUILD_UNIT)
@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."""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _migrate_strip_deprecated_features)
async def _background_domain_reachability_checker(): async def _background_domain_reachability_checker():
"""Periodically curl configured domains and cache reachability results.""" """Periodically curl configured domains and cache reachability results."""
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY) await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
@@ -155,69 +155,6 @@
white-space: nowrap; white-space: nowrap;
} }
/* ── BIP-110 status badge (tile + detail modal) ───────────────────── */
.tile-bip110-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 0.64rem;
font-weight: 600;
border-radius: 4px;
padding: 2px 6px;
margin-top: 4px;
white-space: nowrap;
letter-spacing: 0.02em;
}
.tile-bip110-badge--active {
background: rgba(109, 191, 139, 0.18);
color: var(--green);
border: 1px solid rgba(109, 191, 139, 0.3);
}
.tile-bip110-badge--locked_in {
background: rgba(94, 173, 138, 0.15);
color: var(--accent-color);
border: 1px solid rgba(94, 173, 138, 0.3);
}
.tile-bip110-badge--signaling {
background: rgba(94, 173, 138, 0.12);
color: var(--accent-color);
border: 1px solid rgba(94, 173, 138, 0.2);
}
.tile-bip110-badge--not_signaling {
background: rgba(229, 165, 10, 0.12);
color: var(--yellow);
border: 1px solid rgba(229, 165, 10, 0.25);
}
.tile-bip110-badge--unsupported {
background: rgba(94, 122, 106, 0.12);
color: var(--grey);
border: 1px solid rgba(94, 122, 106, 0.2);
}
.tile-bip110-badge--unknown {
background: transparent;
color: var(--text-dim);
border: 1px solid var(--border-color);
}
.bip110-status-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.bip110-source-label {
color: var(--text-dim);
font-size: 0.75rem;
}
/* ── Service detail modal sections ───────────────────────────────── */ /* ── Service detail modal sections ───────────────────────────────── */
.svc-detail-section { .svc-detail-section {
@@ -413,11 +413,16 @@ function handleFeatureToggle(feat, newEnabled) {
}); });
} }
if (feat.id === "bitcoin-core") { if (conflictNames.length > 0) {
var confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will replace Bitcoin Knots + BIP110 as the active node. Your timechain data will be preserved — you will not need to re-download the timechain. Continue?"; var confirmMsg;
if (feat.id === "bip110") {
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
} else if (feat.id === "bitcoin-core") {
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
} else {
confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?";
}
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck); openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
} else if (conflictNames.length > 0) {
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
} else { } else {
proceedAfterConflictCheck(); proceedAfterConflictCheck();
} }
@@ -60,17 +60,3 @@ async function apiFetch(path, options) {
} }
return res.json(); return res.json();
} }
// ── BIP-110 badge state config ────────────────────────────────────
// Shared lookup used by tiles.js and service-detail.js.
// Keys match the "state" values returned by /api/bitcoin/bip110.
var BIP110_BADGE_CONFIG = {
active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' },
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' },
signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' },
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' },
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' },
unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' }
};
@@ -107,21 +107,6 @@ async function openServiceDetailModal(unit, name, icon) {
'</div>' + '</div>' +
'</div>'; '</div>';
// Section B2: BIP-110 live status (bip110 tile only)
if (icon === 'bip110' && data.bip110) {
var bip110 = data.bip110;
var bip110State = bip110.state || 'unknown';
var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown;
var bip110Source = bip110.source ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
html += '<div class="svc-detail-section">' +
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
'<div class="bip110-status-row">' +
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
bip110Source +
'</div>' +
'</div>';
}
// Section C: Domain diagnostics (domain services) // Section C: Domain diagnostics (domain services)
if (data.needs_domain) { if (data.needs_domain) {
var steps = data.domain_check_steps || []; var steps = data.domain_check_steps || [];
@@ -257,7 +242,7 @@ async function openServiceDetailModal(unit, name, icon) {
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary"; var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
// Section title: use a more specific label for mutually-exclusive Bitcoin node features // Section title: use a more specific label for mutually-exclusive Bitcoin node features
var addonSectionTitle = (feat.id === "bitcoin-core") var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core")
? "\u20BF Bitcoin Node Selection" ? "\u20BF Bitcoin Node Selection"
: "\uD83D\uDD27 Addon Feature"; : "\uD83D\uDD27 Addon Feature";
+1 -34
View File
@@ -4,21 +4,6 @@
// Keyed by tileId: { progress: float, timestamp: ms } // Keyed by tileId: { progress: float, timestamp: ms }
var _btcSyncPrev = {}; var _btcSyncPrev = {};
// ── BIP-110 badge helper ──────────────────────────────────────────
function _renderBip110Badge(bip110) {
if (!bip110) return '';
var state = bip110.state || 'unknown';
var cfg = BIP110_BADGE_CONFIG[state] || BIP110_BADGE_CONFIG.unknown;
return '<div class="tile-bip110-badge ' + cfg.cls + '" title="' + escHtml(cfg.title) + '">' + escHtml(cfg.label) + '</div>';
}
function _firstElementFromHtml(html) {
var tmp = document.createElement("div");
tmp.innerHTML = html;
return tmp.firstElementChild || null;
}
// ── Render: initial build ───────────────────────────────────────── // ── Render: initial build ─────────────────────────────────────────
function buildTiles(services, categoryLabels) { function buildTiles(services, categoryLabels) {
@@ -180,8 +165,7 @@ function buildTile(svc) {
var ver = svc.version || svc.bitcoin_version || ''; var ver = svc.version || svc.bitcoin_version || '';
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : ''; var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
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>' + versionLabel + '<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>' + versionLabel + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
tile.style.cursor = "pointer"; tile.style.cursor = "pointer";
tile.addEventListener("click", function() { tile.addEventListener("click", function() {
@@ -281,23 +265,6 @@ function updateTiles(services) {
} }
} }
} }
// 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 version label (or after tile-name if no version)
var anchorEl = tile.querySelector(".tile-version") || tile.querySelector(".tile-name");
if (anchorEl) {
var newBadgeEl = _firstElementFromHtml(badgeHtml);
if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl);
}
}
}
} }
} }
} }
@@ -557,7 +557,7 @@ async function loadStep4() {
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;to</th><th>Purpose</th></tr></thead>'; html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward&nbsp;to</th><th>Purpose</th></tr></thead>';
html += '<tbody>'; html += '<tbody>';
html += '<tr><td class="port-req-port">7881</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit WebRTC signalling</td></tr>'; html += '<tr><td class="port-req-port">7881</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit WebRTC signalling</td></tr>';
html += '<tr><td class="port-req-port">7882</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media (UDP mux)</td></tr>'; html += '<tr><td class="port-req-port">78827894</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media streams</td></tr>';
html += '<tr><td class="port-req-port">5349</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN over TLS</td></tr>'; html += '<tr><td class="port-req-port">5349</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN over TLS</td></tr>';
html += '<tr><td class="port-req-port">3478</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN (STUN/relay)</td></tr>'; html += '<tr><td class="port-req-port">3478</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN (STUN/relay)</td></tr>';
html += '<tr><td class="port-req-port">3000040000</td><td class="port-req-proto">TCP/UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN relay (WebRTC)</td></tr>'; html += '<tr><td class="port-req-port">3000040000</td><td class="port-req-proto">TCP/UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN relay (WebRTC)</td></tr>';
+22 -22
View File
@@ -4,17 +4,17 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran_SystemsOS Hub</title> <title>Sovran_SystemsOS Hub</title>
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/buttons.css" />
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/header.css" />
<link rel="stylesheet" href="/static/css/layout.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/layout.css" />
<link rel="stylesheet" href="/static/css/tiles.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/tiles.css" />
<link rel="stylesheet" href="/static/css/modals.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/modals.css" />
<link rel="stylesheet" href="/static/css/features.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/features.css" />
<link rel="stylesheet" href="/static/css/onboarding.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/onboarding.css" />
<link rel="stylesheet" href="/static/css/support.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/support.css" />
<link rel="stylesheet" href="/static/css/domain-setup.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/domain-setup.css" />
<link rel="stylesheet" href="/static/css/security.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/security.css" />
</head> </head>
<body> <body>
@@ -263,16 +263,16 @@
</div> </div>
</div> </div>
<script src="/static/js/constants.js?v={{ asset_version }}"></script> <script src="/static/js/constants.js"></script>
<script src="/static/js/state.js?v={{ asset_version }}"></script> <script src="/static/js/state.js"></script>
<script src="/static/js/helpers.js?v={{ asset_version }}"></script> <script src="/static/js/helpers.js"></script>
<script src="/static/js/tiles.js?v={{ asset_version }}"></script> <script src="/static/js/tiles.js"></script>
<script src="/static/js/service-detail.js?v={{ asset_version }}"></script> <script src="/static/js/service-detail.js"></script>
<script src="/static/js/support.js?v={{ asset_version }}"></script> <script src="/static/js/support.js"></script>
<script src="/static/js/update.js?v={{ asset_version }}"></script> <script src="/static/js/update.js"></script>
<script src="/static/js/rebuild.js?v={{ asset_version }}"></script> <script src="/static/js/rebuild.js"></script>
<script src="/static/js/features.js?v={{ asset_version }}"></script> <script src="/static/js/features.js"></script>
<script src="/static/js/security.js?v={{ asset_version }}"></script> <script src="/static/js/security.js"></script>
<script src="/static/js/events.js?v={{ asset_version }}"></script> <script src="/static/js/events.js"></script>
</body> </body>
</html> </html>
@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sovran Hub — Login</title> <title>Sovran Hub — Login</title>
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" /> <link rel="stylesheet" href="/static/css/buttons.css" />
</head> </head>
<body> <body>
<div class="login-wrapper"> <div class="login-wrapper">
-166
View File
@@ -1,166 +0,0 @@
import unittest
from unittest.mock import patch
from pathlib import Path
import sys
import types
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
def _install_web_stubs():
if "fastapi" in sys.modules:
return
class _HTTPException(Exception):
def __init__(self, status_code=None, detail=None):
super().__init__(detail)
self.status_code = status_code
self.detail = detail
class _FastAPI:
def __init__(self, *args, **kwargs):
pass
def mount(self, *args, **kwargs):
return None
def add_middleware(self, *args, **kwargs):
return None
def __getattr__(self, _name):
def _decorator_factory(*args, **kwargs):
def _decorator(func):
return func
return _decorator
return _decorator_factory
class _BaseModel:
pass
class _StaticFiles:
def __init__(self, *args, **kwargs):
pass
class _Jinja2Templates:
def __init__(self, *args, **kwargs):
pass
class _BaseHTTPMiddleware:
pass
fastapi_module = types.ModuleType("fastapi")
fastapi_module.FastAPI = _FastAPI
fastapi_module.HTTPException = _HTTPException
sys.modules["fastapi"] = fastapi_module
responses_module = types.ModuleType("fastapi.responses")
responses_module.HTMLResponse = object
responses_module.JSONResponse = object
responses_module.RedirectResponse = object
sys.modules["fastapi.responses"] = responses_module
staticfiles_module = types.ModuleType("fastapi.staticfiles")
staticfiles_module.StaticFiles = _StaticFiles
sys.modules["fastapi.staticfiles"] = staticfiles_module
templating_module = types.ModuleType("fastapi.templating")
templating_module.Jinja2Templates = _Jinja2Templates
sys.modules["fastapi.templating"] = templating_module
requests_module = types.ModuleType("fastapi.requests")
requests_module.Request = object
sys.modules["fastapi.requests"] = requests_module
pydantic_module = types.ModuleType("pydantic")
pydantic_module.BaseModel = _BaseModel
sys.modules["pydantic"] = pydantic_module
starlette_base_module = types.ModuleType("starlette.middleware.base")
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
sys.modules["starlette.middleware.base"] = starlette_base_module
starlette_middleware_module = types.ModuleType("starlette.middleware")
starlette_middleware_module.base = starlette_base_module
sys.modules["starlette.middleware"] = starlette_middleware_module
starlette_module = types.ModuleType("starlette")
starlette_module.middleware = starlette_middleware_module
sys.modules["starlette"] = starlette_module
_install_web_stubs()
from sovran_systemsos_web import server
class Bip110StatusTests(unittest.TestCase):
def _status(self, deploy_info, net_info):
with patch.object(server, "_get_bitcoin_deployment_info", return_value=deploy_info), patch.object(
server, "_get_bitcoin_version_info", return_value=net_info
):
return server._get_bip110_status()
def test_started_reduced_data_reports_signaling(self):
deploy_info = {
"deployments": {
"reduced_data": {
"type": "bip9",
"active": False,
"bip9": {
"bit": 4,
"status": "started",
"statistics": {"elapsed": 833, "count": 4, "threshold": 1109},
"signalling": "--#--",
},
}
}
}
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
self.assertEqual(
result,
{"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"},
)
def test_active_reduced_data_reports_active(self):
deploy_info = {
"deployments": {"reduced_data": {"active": True, "bip9": {"bit": 4, "status": "active"}}}
}
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
self.assertEqual(result["state"], "active")
self.assertTrue(result["supported"])
self.assertTrue(result["signaling"])
self.assertEqual(result["source"], "getdeploymentinfo")
def test_locked_in_reduced_data_reports_locked_in(self):
deploy_info = {
"deployments": {"reduced_data": {"active": False, "bip9": {"bit": 4, "status": "locked_in"}}}
}
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
self.assertEqual(result["state"], "locked_in")
self.assertTrue(result["supported"])
self.assertTrue(result["signaling"])
self.assertEqual(result["source"], "getdeploymentinfo")
def test_no_bip110_deployment_and_plain_subversion_reports_unsupported(self):
deploy_info = {
"deployments": {
"taproot": {"type": "bip9", "active": True, "bip9": {"bit": 2, "status": "active"}},
}
}
result = self._status(deploy_info, {"subversion": "/Satoshi:27.0.0/"})
self.assertEqual(
result,
{"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"},
)
def test_node_unreachable_reports_unknown(self):
result = self._status(None, None)
self.assertEqual(result, {"supported": False, "signaling": False, "state": "unknown", "source": "none"})
if __name__ == "__main__":
unittest.main()
-52
View File
@@ -1,52 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#153126"/>
<stop offset="55%" stop-color="#0F241B"/>
<stop offset="100%" stop-color="#091C14"/>
</linearGradient>
<linearGradient id="outerArc" x1="70" y1="40" x2="190" y2="210" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#42F39A"/>
<stop offset="45%" stop-color="#28D978"/>
<stop offset="100%" stop-color="#1AA45D"/>
</linearGradient>
<linearGradient id="innerArc" x1="90" y1="60" x2="180" y2="190" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#27C86F"/>
<stop offset="100%" stop-color="#157E49"/>
</linearGradient>
<filter id="innerShade" x="-10%" y="-10%" width="120%" height="120%">
<feOffset dx="0" dy="2"/>
<feGaussianBlur stdDeviation="5" result="blur"/>
<feComposite in="blur" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 .18 0"/>
</filter>
</defs>
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
<rect x="1.5" y="1.5" width="253" height="253" rx="46.5" ry="46.5"
fill="none" stroke="rgba(255,255,255,0.08)"/>
<rect x="6" y="6" width="244" height="244" rx="42" ry="42"
fill="none" filter="url(#innerShade)"/>
<path d="M128 32 A96 96 0 1 1 58 196"
fill="none"
stroke="url(#outerArc)"
stroke-width="12"
stroke-linecap="round"/>
<path d="M128 56 A72 72 0 1 1 76 178"
fill="none"
stroke="url(#innerArc)"
stroke-width="10"
stroke-linecap="round"/>
<circle cx="128" cy="128" r="8" fill="#F2FFF7"/>
<circle cx="128" cy="128" r="18" fill="none" stroke="#7BFFC0" stroke-opacity="0.14" stroke-width="4"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

+3 -9
View File
@@ -26,13 +26,6 @@
nix.settings = { nix.settings = {
experimental-features = [ "nix-command" "flakes" ]; experimental-features = [ "nix-command" "flakes" ];
download-buffer-size = 524288000; download-buffer-size = 524288000;
# Network resilience for cache.nixos.org (Fastly) flakiness.
connect-timeout = 10; # fail-fast on dead TCP connects (default: 0 = unlimited)
stalled-download-timeout = 90; # default 300s; retry sooner on stalled transfers
download-attempts = 7; # default 5
http-connections = 25; # cap concurrency (helps MTU/middlebox paths)
fallback = true; # build locally if a substitute can't be fetched
}; };
# ── Networking ────────────────────────────────────────────── # ── Networking ──────────────────────────────────────────────
@@ -70,6 +63,7 @@
# ── Desktop ──────────────────────────────────────────────── # ── Desktop ────────────────────────────────────────────────
services.displayManager.gdm.enable = true; services.displayManager.gdm.enable = true;
services.displayManager.gdm.autoSuspend = false; services.displayManager.gdm.autoSuspend = false;
services.displayManager.gdm.wayland = true;
services.desktopManager.gnome.enable = true; services.desktopManager.gnome.enable = true;
services.printing.enable = true; services.printing.enable = true;
systemd.enableEmergencyMode = false; systemd.enableEmergencyMode = false;
@@ -145,10 +139,10 @@
ranger fastfetch gedit openssl pwgen ranger fastfetch gedit openssl pwgen
aspell aspellDicts.en lm_sensors aspell aspellDicts.en lm_sensors
hunspell hunspellDicts.en_US hunspell hunspellDicts.en_US
synadm brave dua synadm brave dua bitwarden-desktop
gparted pv unzip parted screen zenity gparted pv unzip parted screen zenity
libargon2 gnome-terminal libreoffice-fresh libargon2 gnome-terminal libreoffice-fresh
dig firefox wp-cli axel dig firefox element-desktop wp-cli axel
lk-jwt-service livekit-libwebrtc livekit-cli livekit lk-jwt-service livekit-libwebrtc livekit-cli livekit
matrix-synapse age matrix-synapse age
]; ];
+93
View File
@@ -0,0 +1,93 @@
# Sovran Hub — Manual Backup
The manual backup service copies critical system data from your Sovran Pro to an external USB drive, providing a third copy of your data (your Sovran Pro already maintains an automatic internal backup on its second drive).
Backups are written to:
```
<USB drive>/Sovran_SystemsOS_Backup/<timestamp>/
```
where `<timestamp>` is formatted as `YYYYMMDD_HHMMSS`.
---
## Backup Stages
The script always attempts all four stages, but skips stages that are irrelevant to the system's configured role (see [Per-Role Breakdown](#per-role-breakdown) below).
| Stage | Directory | Contents |
|-------|-----------|----------|
| **1/4 — NixOS config** | `/etc/nixos/` | Full NixOS system configuration: `role-state.nix`, `custom.nix`, flake files, and any other config managed by the Hub |
| **2/4 — Secrets** | `/etc/nix-bitcoin-secrets` | Bitcoin/LND secrets stored under `/etc/` |
| **3/4 — Home directory** | `/home/` | All user home directories (`.cache/` and Trash are excluded) |
| **4/4 — System data** | `/var/lib/` | Full service data tree, including Vaultwarden, bitcoind, LND, sovran-hub config, domains, secrets, and other `/var/lib` service directories (logs excluded as appropriate) |
---
## Per-Role Breakdown
The script detects the system role at runtime by reading `/var/lib/sovran-hub/config.json` (falling back to `/etc/nixos/role-state.nix`) and adjusts its behaviour accordingly.
### Server + Desktop (default)
All services are enabled: Bitcoin, Matrix Synapse, Vaultwarden, WordPress, Nextcloud.
| Stage | Status | Notes |
|-------|--------|-------|
| Stage 1 — NixOS config | ✅ Backed up | Full server configuration |
| Stage 2 — Secrets | ✅ Backed up | `/etc/nix-bitcoin-secrets` |
| Stage 3 — Home directory | ✅ Backed up | Desktop user data |
| Stage 4 — System data (`/var/lib`) | ✅ Backed up | Includes Vaultwarden, bitcoind, LND, sovran-hub config, domains, secrets, and all other service data under `/var/lib` (logs excluded) |
This produces the largest backup. All four stages generate meaningful data.
### Desktop Only
All server services are disabled (`bitcoin = false`, `synapse = false`, `vaultwarden = false`, `wordpress = false`, `nextcloud = false`). Only GNOME desktop is active.
| Stage | Status | Notes |
|-------|--------|-------|
| Stage 1 — NixOS config | ✅ Backed up | Simpler config (no server services) |
| Stage 2 — Secrets | ⏭️ Skipped | `/etc/nix-bitcoin-secrets` is not applicable for Desktop Only role |
| Stage 3 — Home directory | ✅ Backed up | **The most important data for this role** |
| Stage 4 — System data (`/var/lib`) | ✅ Backed up | Full `/var/lib` backup with `/var/lib/lnd` excluded for Desktop Only role |
This produces the smallest and fastest backup. Stages 1 and 3 are the primary sources of meaningful data.
### Node (Bitcoin-only)
Only the Bitcoin ecosystem is active: `bitcoind`, `electrs`, `lnd`, `rtl`, `btcpay`, `mempool`, and `bip110`. All other server services are disabled.
| Stage | Status | Notes |
|-------|--------|-------|
| Stage 1 — NixOS config | ✅ Backed up | Node-specific configuration |
| Stage 2 — Secrets | ✅ Backed up | `/etc/nix-bitcoin-secrets` |
| Stage 3 — Home directory | ✅ Backed up | User data |
| Stage 4 — System data (`/var/lib`) | ✅ Backed up | **Critical** — includes Lightning wallet/channel data plus all other `/var/lib` service data |
All four stages run, matching Server + Desktop behaviour. Some non-Bitcoin service directories under `/var/lib` may be sparse or absent depending on role.
---
## Backup Manifest
After all stages complete, the script writes a `BACKUP_MANIFEST.txt` file inside the timestamped backup directory. This file records the date, hostname, detected role, target drive, and a directory listing of everything that was backed up.
---
## Running the Backup
The backup is triggered from the Sovran Hub web UI. You can also run it directly:
```bash
# Auto-detect the first external USB drive
sudo bash /path/to/sovran-hub-backup.sh
# Specify a target drive explicitly
sudo BACKUP_TARGET=/run/media/<user>/<drive> bash /path/to/sovran-hub-backup.sh
```
The script requires at least **10 GB** of free space on the target drive and will refuse to write to internal system drives.
Logs are written to `/var/log/sovran-hub-backup.log` and the current status (`RUNNING`, `SUCCESS`, or `FAILED`) is tracked in `/var/log/sovran-hub-backup.status`.
+472
View File
@@ -0,0 +1,472 @@
# Remote Deployment via Headscale (Self-Hosted Tailscale)
This guide covers the Sovran Systems remote deployment system built on [Headscale](https://headscale.net) — a self-hosted, open-source implementation of the Tailscale coordination server. Freshly booted ISOs automatically join a private WireGuard mesh VPN without any per-machine key pre-generation.
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────────┐
│ Internet │
└────────────┬─────────────────────┬──────────────────────┘
│ │
▼ ▼
┌────────────────────┐ ┌─────────────────────────────────┐
│ Admin Workstation │ │ Sovran VPS │
│ │ │ ┌─────────────────────────────┐ │
│ tailscale up │ │ │ Headscale (port 8080) │ │
│ --login-server │◄──┼─►│ Coordination server │ │
│ hs.example.com │ │ ├─────────────────────────────┤ │
│ │ │ │ Provisioning API (9090) │ │
└────────────────────┘ │ │ POST /register │ │
│ │ GET /machines │ │
│ │ GET /health │ │
│ ├─────────────────────────────┤ │
│ │ Caddy (80/443) │ │
│ │ hs.example.com → :8080 │ │
│ │ prov.example.com → :9090 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
│ WireGuard mesh (Tailnet)
┌─────────────────────────────────┐
│ Deploy Target Machine │
│ │
│ Boot live ISO → │
│ sovran-auto-provision → │
│ POST /register → │
│ tailscale up --authkey=... │
└─────────────────────────────────┘
```
**Components:**
- **`sovran-provisioner.nix`** — NixOS module deployed on a separate VPS; runs Headscale + provisioning API + Caddy.
- **Live ISO** (`iso/common.nix`) — Auto-registers with the provisioning server and joins the Tailnet on boot.
- **`remote-deploy.nix`** — Post-install NixOS module that uses Tailscale/Headscale for ongoing access.
---
## Part 1: VPS Setup — Deploy `sovran-provisioner.nix`
### Prerequisites
- A NixOS VPS (any provider) with a public IP
- Two DNS A records pointing to your VPS:
- `hs.yourdomain.com` → VPS IP (Headscale coordination server)
- `prov.yourdomain.com` → VPS IP (Provisioning API)
- Ports 80, 443 (TCP) and 3478 (UDP, STUN/DERP) open in your VPS firewall
### DNS Records
| Type | Name | Value |
|------|-----------------------|------------|
| A | `hs.yourdomain.com` | `<VPS IP>` |
| A | `prov.yourdomain.com` | `<VPS IP>` |
### NixOS Configuration
Add the following to your VPS's `/etc/nixos/configuration.nix`:
```nix
{ config, lib, pkgs, ... }:
{
imports = [
./hardware-configuration.nix
/path/to/sovran-provisioner.nix # or fetch from the repo
];
sovranProvisioner = {
enable = true;
domain = "prov.yourdomain.com";
headscaleDomain = "hs.yourdomain.com";
# Optional: customise defaults
headscaleUser = "sovran-deploy"; # namespace for deploy machines
adminUser = "admin"; # namespace for your workstation
keyExpiry = "1h"; # pre-auth keys expire after 1 hour
rateLimitMax = 10; # max registrations per window
rateLimitWindow = 60; # window in seconds
};
# Required for Caddy ACME (Let's Encrypt)
networking.hostName = "sovran-vps";
system.stateVersion = "24.11";
}
```
### Deploy
```bash
nixos-rebuild switch
```
Caddy will automatically obtain TLS certificates via Let's Encrypt.
### Retrieve the Enrollment Token
```bash
cat /var/lib/sovran-provisioner/enroll-token
```
Keep this token secret — it is used to authenticate ISO registrations. The token is auto-generated on first boot and stored at this path. You never need to set it manually. Just `cat` it from the VPS and copy it to `iso/secrets/enroll-token` before building the ISO.
---
## Part 2: Admin Workstation Setup
Join your Tailnet as an admin so you can reach deployed machines:
### Install Tailscale
Follow the [Tailscale installation guide](https://tailscale.com/download) for your OS, or on NixOS:
```nix
services.tailscale.enable = true;
```
### Join the Tailnet
```bash
sudo tailscale up --login-server https://hs.yourdomain.com --accept-dns=false
```
> **Note:** The `--accept-dns=false` flag prevents Tailscale from taking over your system DNS resolver. This is important if you are behind a VPN (see [Troubleshooting](#troubleshooting) below).
Tailscale prints a URL. Open it and copy the node key (starts with `mkey:`).
### Approve the Node in Headscale
On the VPS, first find the numeric user ID for the `admin` user, then register the node:
```bash
# Look up the numeric ID for the admin user (Headscale 0.28.0 requires -u <id>)
headscale users list -o json
# Register the node using the numeric user ID
headscale nodes register -u <admin-user-id> --key mkey:xxxxxxxxxxxxxxxx
```
Your workstation is now on the Tailnet. You can list nodes:
```bash
headscale nodes list
```
---
## Part 3: Building the Deploy ISO
### Add Secrets (gitignored)
The secrets directory `iso/secrets/` is gitignored. Populate it before building:
```bash
# Copy the enrollment token from the VPS
ssh root@<VPS> cat /var/lib/sovran-provisioner/enroll-token > iso/secrets/enroll-token
# Set the provisioner URL
echo "https://prov.yourdomain.com" > iso/secrets/provisioner-url
```
These files are baked into the ISO at build time. If the files are absent the ISO still builds — the auto-provision service exits cleanly with "No enroll token found, skipping auto-provision", leaving DIY users unaffected.
### Build the ISO
```bash
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
```
The resulting ISO is in `./result/iso/`.
---
## Part 4: Deployment Workflow
### Step-by-Step
1. **Hand the ISO to the remote person** — they burn it to a USB drive and boot.
2. **ISO boots and auto-registers**`sovran-auto-provision.service` runs automatically:
- Reads `enroll-token` and `provisioner-url` from `/etc/sovran/`
- `POST https://prov.yourdomain.com/register` with hostname + MAC
- Receives a Headscale pre-auth key
- Runs `tailscale up --login-server=... --authkey=...`
- The machine appears in `headscale nodes list` within ~30 seconds
3. **Approve the node (if not using auto-approve)** — on the VPS:
```bash
headscale nodes list
# Note the node key for the new machine
```
4. **SSH from your workstation** — once the machine is on the Tailnet:
```bash
# Get the machine's Tailscale IP
headscale nodes list | grep sovran-deploy-
# SSH in
ssh root@100.64.x.x # password: sovran-remote (live ISO default)
```
5. **Run the headless installer**:
The `--deploy-key` is your SSH public key that gets injected into `root`'s `authorized_keys` on the deployed machine. This grants full root access for initial setup. Generate it once on your workstation if you haven't already:
```bash
ssh-keygen -t ed25519 -f ~/.ssh/sovran-deploy -C "sovran-deploy"
```
After deployment is complete and you disable deploy mode, this key is removed.
```bash
sudo sovran-install-headless.sh \
--disk /dev/sda \
--role server \
--deploy-key "$(cat ~/.ssh/sovran-deploy.pub)" \
--headscale-server "https://hs.yourdomain.com" \
--headscale-key "$(headscale preauthkeys create -u $(headscale users list -o json | jq -r '.[] | select(.name=="sovran-deploy") | .id') -e 2h -o json | jq -r '.key')"
```
6. **Machine reboots into Sovran_SystemsOS**`deploy-tailscale-connect.service` runs:
- Reads `/var/lib/secrets/headscale-authkey`
- Joins the Tailnet with a deterministic hostname (`sovran-<hostname>`)
7. **Post-install SSH and RDP**:
```bash
# SSH over Tailnet
ssh root@<tailscale-ip>
# RDP over Tailnet (desktop role) — Sovran_SystemsOS uses GNOME Remote Desktop (native Wayland RDP)
# Retrieve the auto-generated RDP password:
ssh root@<tailscale-ip> cat /var/lib/gnome-remote-desktop/rdp-password
# Then connect with any RDP client (Remmina, GNOME Connections, Microsoft Remote Desktop):
# Host: <tailscale-ip>:3389 User: sovran Password: <from above>
```
8. **Disable deploy mode** — edit `/etc/nixos/custom.nix` on the target, set `enable = false`, then:
```bash
sudo nixos-rebuild switch
```
---
## Part 5: Post-Install Access
### SSH
```bash
# Over Tailnet
ssh root@100.64.x.x
```
### RDP (desktop/server roles)
Sovran_SystemsOS uses **GNOME Remote Desktop** (native Wayland RDP — not xfreerdp). The RDP service auto-generates credentials on first boot.
**Username:** `sovran`
**Password:** auto-generated — retrieve it via SSH:
```bash
ssh root@<tailscale-ip> cat /var/lib/gnome-remote-desktop/rdp-password
```
Connect using any RDP client (Remmina, GNOME Connections, Microsoft Remote Desktop) to `<tailscale-ip>:3389`.
---
## Security Model
| Concern | Mitigation |
|---------|-----------|
| Enrollment token theft | Token only triggers key generation; it does not grant access to the machine itself |
| Rogue device joins Tailnet | Visible in `headscale nodes list`; removable instantly with `headscale nodes delete` |
| Pre-auth key reuse | Keys are ephemeral and expire in 1 hour (configurable via `keyExpiry`) |
| Rate limiting | Provisioning API limits to 10 registrations/minute by default (configurable) |
| SSH access | Requires ed25519 key injected at install time; password authentication disabled |
| Credential storage | Auth key written to `/var/lib/secrets/headscale-authkey` (mode 600) on the installed OS |
### Token Rotation
To rotate the enrollment token:
1. On the VPS:
```bash
openssl rand -hex 32 > /var/lib/sovran-provisioner/enroll-token
chmod 600 /var/lib/sovran-provisioner/enroll-token
```
2. Update `iso/secrets/enroll-token` and rebuild the ISO.
Old ISOs with the previous token will fail to register (receive 401).
---
## Monitoring
### List Active Tailnet Nodes
```bash
# On the VPS
headscale nodes list
```
### List Registered Machines (Provisioning API)
```bash
curl -s -H "Authorization: Bearer $(cat /var/lib/sovran-provisioner/enroll-token)" \
https://prov.yourdomain.com/machines | jq .
```
### Health Check
```bash
curl https://prov.yourdomain.com/health
# {"status": "ok"}
```
### Provisioner Logs
```bash
journalctl -u sovran-provisioner -f
```
### Headscale Logs
```bash
journalctl -u headscale -f
```
---
## Cleanup
### Remove a Machine from the Tailnet
```bash
headscale nodes list
headscale nodes delete --identifier <id>
```
### Disable Deploy Mode on an Installed Machine
Edit `/etc/nixos/custom.nix`:
```nix
sovran_systemsOS.deploy.enable = false;
```
Then rebuild:
```bash
nixos-rebuild switch
```
This stops the Tailscale connect service.
### Revoke All Active Pre-Auth Keys
```bash
# List pre-auth keys (Headscale 0.28.0: no --user flag on list)
headscale preauthkeys list
# Expire a specific key — use numeric user ID (-u <id>)
# First find the user ID:
headscale users list -o json
# Then expire the key:
headscale preauthkeys expire -u <user-id> --key <key>
```
---
## Troubleshooting
### VPN Conflicts (Mullvad, WireGuard, etc.)
**Symptom:** `tailscale up` hangs or fails with `connection refused` on port 443, even though `curl https://hs.yourdomain.com/health` works fine.
**Cause:** VPNs like Mullvad route all traffic — including Tailscale's control-plane connections — through the VPN tunnel. Additionally, Tailscale's DNS handler (`--accept-dns=true` by default) hijacks DNS resolution and may prevent correct resolution of your Headscale server even when logged out.
**Solution:**
1. Disconnect your VPN temporarily and retry `tailscale up`.
2. If you need the VPN active, use split tunneling to exclude `tailscaled`:
```bash
# Mullvad CLI
mullvad split-tunnel add $(pidof tailscaled)
```
Or in the Mullvad GUI: **Settings → Split tunneling → Add tailscaled**.
3. Always pass `--accept-dns=false` when enrolling to avoid DNS hijacking:
```bash
sudo tailscale up --login-server https://hs.yourdomain.com --authkey <key> --accept-dns=false
```
---
### "RATELIMIT" in tailscaled Logs
**Symptom:** `journalctl -u tailscaled` shows lines like:
```
[RATELIMIT] format("Received error: %v")
```
**Cause:** This is **NOT** a server-side rate limit from Headscale. It is tailscaled's internal log suppressor de-duplicating repeated connection-refused error messages. The real underlying error is `connection refused`.
**What to check:**
1. Is Headscale actually running? `curl https://hs.yourdomain.com/health`
2. Is your VPN blocking the connection? (see VPN Conflicts above)
3. Is there a firewall blocking port 443?
---
### "connection refused" on Port 443
If `tailscale up` fails but `curl` works, the issue is usually DNS or VPN:
```bash
# Does curl reach Headscale successfully?
curl -v https://hs.yourdomain.com/health
# Force IPv4 vs IPv6 to identify if it's an address-family issue
curl -4 https://hs.yourdomain.com/health
curl -6 https://hs.yourdomain.com/health
# Check what IP headscale resolves to
dig +short hs.yourdomain.com
# What resolver is the system using?
cat /etc/resolv.conf
```
If curl works but tailscale doesn't, tailscaled may be using a different DNS resolver (e.g. its own `100.100.100.100` stub resolver). Fix: pass `--accept-dns=false`.
---
### Headscale User ID Lookup (0.28.0)
Headscale 0.28.0 removed `--user <name>` in favour of `-u <numeric-id>`. To find the numeric ID for a user:
```bash
headscale users list -o json
# Output: [{"id": "1", "name": "sovran-deploy", ...}, ...]
# One-liner to get the ID for a specific user
headscale users list -o json | jq -r '.[] | select(.name=="sovran-deploy") | .id'
```
Then use the numeric ID in subsequent commands:
```bash
headscale preauthkeys create -u 1 -e 1h -o json
headscale nodes register -u 1 --key mkey:xxxx
```
---
## Reference
| Component | Port | Protocol | Description |
|-----------|------|----------|-------------|
| Caddy | 80 | TCP | HTTP → HTTPS redirect |
| Caddy | 443 | TCP | HTTPS (Let's Encrypt) |
| Headscale | 8080 | TCP | Coordination server (proxied by Caddy) |
| Provisioner | 9090 | TCP | Registration API (proxied by Caddy) |
| DERP/STUN | 3478 | UDP | WireGuard relay fallback |
| Tailscale | N/A | WireGuard | Mesh VPN between nodes |
+259
View File
@@ -0,0 +1,259 @@
# Tech Support: Security Design, User Flow, and Incident Response
## Overview
The Sovran Hub includes a **Tech Support** feature that lets Sovran Systems
staff remotely diagnose and fix issues on a user's machine via SSH — without
ever having access to private keys or wallet funds.
Wallet protection is the default. The user must make an active, time-limited
choice to grant support staff access to wallet files, and can revoke that
access at any time.
---
## Implementation Details
### Restricted User Instead of Root
When a user enables support access the Hub:
1. Ensures the `sovran-support` system user exists (declared declaratively in
`modules/core/tech-support.nix`; the Hub also provisions it on demand as a
fallback on non-NixOS systems).
2. Writes the Sovran Systems public SSH key **only** to
`/var/lib/sovran-support/.ssh/authorized_keys`, not to root's
`authorized_keys`.
3. Applies POSIX ACLs (`setfacl -R -m u:sovran-support:---`) to every wallet
directory that exists on disk, denying all access by the support user.
4. Records a timestamped `SUPPORT_ENABLED` event in the audit log at
`/var/log/sovran-support-audit.log`.
When the session ends (or if the Hub cannot create the restricted user), the
key is removed and all ACLs are revoked immediately.
### Protected Wallet Paths
The following directories are locked by default when a support session starts:
| Path | Contents |
|------|----------|
| `/etc/nix-bitcoin-secrets` | nix-bitcoin generated secrets |
| `/var/lib/bitcoind` | Bitcoin Core chainstate and wallet |
| `/var/lib/lnd` | LND wallet and channel database |
| `/home` | User home directories |
Paths are only locked if they exist on disk at the time the session starts.
### POSIX ACL Mechanics
POSIX ACLs on Linux handle access checks in this order:
1. If the process UID matches the file owner UID → use owner permissions
2. **If there is a matching named-user ACL entry → use that entry's
permissions** (clamped by the mask entry)
3. If any group matches → use group permissions
4. Otherwise → use "other" permissions
Setting `u:sovran-support:---` creates a named-user ACL entry with no
permissions. Because the named-user entry is checked before the group/other
entries, the support user cannot access those directories regardless of the
"other" permission bits.
`setfacl` and `getfacl` are provided by the `acl` package, which is added to
`environment.systemPackages` by `modules/core/tech-support.nix`.
### Fallback to Root (When Restricted User Cannot Be Created)
If the `sovran-support` user does not exist and cannot be created (e.g.,
`users.mutableUsers = false` and the declarative module has not been deployed
yet), the Hub falls back to adding the support key to root's
`authorized_keys`. The modal prominently warns the user when this has happened
so they can decide whether to end the session.
### Audit Log
Every session event is appended to `/var/log/sovran-support-audit.log`:
```
[2025-01-15 14:32:01 UTC] SUPPORT_ENABLED: restricted_user=True acl_applied=True protected_paths=4
[2025-01-15 14:45:00 UTC] WALLET_UNLOCKED: duration=3600s expires=2025-01-15 15:45:00 UTC
[2025-01-15 15:45:00 UTC] WALLET_RELOCKED: auto-expired
[2025-01-15 16:01:22 UTC] SUPPORT_DISABLED
```
The last 100 lines of this log are accessible from the Hub UI while a session
is active (or after it ends, until the page is refreshed).
---
## Security Tradeoffs
### What This Protects Against
- **Accidental wallet exposure** — support staff cannot read wallet files
during a normal session; they must ask the user to explicitly grant access.
- **Credential theft** — private keys in the wallet directories are not
visible to the `sovran-support` user by default.
- **Scope creep** — the restricted user account limits the blast radius of an
SSH session compared to direct root access.
### Known Limitations
| Limitation | Mitigation |
|------------|------------|
| Support user still has system-wide bash access | Restrict with `ForceCommand` or AppArmor in the NixOS config if a narrower scope is required |
| ACLs apply only to directories that exist at session start | If new wallet directories are created during a session, they are not auto-protected. Re-lock and re-enable support to pick up new paths |
| Root fallback grants full access | The Hub UI warns the user prominently; users should end the session if they are uncomfortable |
| `setfacl` / ACL filesystem support required | The `acl` package is declared in `tech-support.nix`; most Linux filesystems (ext4, btrfs, xfs) support ACLs by default |
| Wallet access grant is time-limited but lazy-expired | Expiry is checked on the next `/api/support/status` poll (every 10 seconds in the UI); there is a small window after expiry |
### Defense-in-Depth Recommendations
For environments that require stronger isolation, consider layering one or
more additional controls:
- **`ForceCommand`** in `sshd_config` (or `~/.ssh/authorized_keys` command
prefix) to restrict the support user to a specific diagnostic script.
- **`ChrootDirectory`** in the `sshd_config` `Match User sovran-support` block
to confine the session to a prepared directory tree.
- **AppArmor or SELinux** profiles that deny the support process read access
to wallet paths at the kernel level.
- **Namespace/bind-mount overlays** (e.g., via a wrapper systemd unit) to
present a sanitized filesystem view.
---
## User Flow
```
User opens Hub → Clicks "Tech Support" in sidebar
Modal: "Need help from Sovran Systems?"
• Explains what will happen
• Shows Wallet Protection notice
• User clicks "Enable Support Access"
Hub: 1. Creates / verifies sovran-support user
2. Writes SSH key to that user's authorized_keys
3. Applies POSIX ACL deny on all existing wallet paths
4. Saves session metadata + writes SUPPORT_ENABLED to audit log
Modal: "Support Access is Active"
• Live session duration timer
• Wallet Files: Protected panel
Optional: "Grant Wallet Access" (time-limited, user-chosen)
• "End Support Session" button
• "View Audit Log" button
(User grants wallet access)
Hub: • Removes ACL deny entries
• Records WALLET_UNLOCKED event with expiry time
• Starts countdown timer in UI
(Timer expires or user clicks "Re-lock Wallet Now")
Hub: • Re-applies ACL deny entries
• Removes WALLET_UNLOCK_FILE
• Records WALLET_RELOCKED event
(User clicks "End Support Session")
Hub: 1. Removes SSH key from sovran-support authorized_keys
2. Removes SSH key from root authorized_keys (legacy cleanup)
3. Revokes any wallet unlock, re-applies ACL deny
4. Verifies key is gone
5. Records SUPPORT_DISABLED event
Modal: "Support Session Ended — SSH key removed"
• Shows verified removal status
```
---
## Incident Response
### Scenario 1 — You accidentally granted wallet access and are unsure what was copied
**Immediate steps:**
1. Click **"Re-lock Wallet Now"** in the Hub modal, or click
**"End Support Session"** to simultaneously revoke SSH access and wallet
access.
2. Open the **Audit Log** from the Hub modal and note the timestamps of
`WALLET_UNLOCKED` and `WALLET_RELOCKED` events.
3. Check `/var/log/auth.log` (or `journalctl -u sshd`) for SSH login events
by `sovran-support` during the unlocked window.
**Assessment:**
- If no SSH login occurred during the wallet-unlocked window, your keys are
safe.
- If an SSH login did occur, treat private keys as potentially compromised.
**Recovery if keys may be compromised:**
| Wallet | Recovery action |
|--------|----------------|
| LND | Move all funds out using `lncli sendcoins` to a freshly generated on-chain address; close channels; recreate wallet |
| Sparrow | Sweep funds to a new wallet generated on an air-gapped device |
| Bisq | Withdraw all BSQ and BTC to external wallets; delete the Bisq data directory and recreate |
| nix-bitcoin secrets | Rotate all secrets with `nix-bitcoin-secrets generate` and redeploy |
**Report the incident:**
Contact Sovran Systems immediately at support@sovransystems.com with:
- The audit log output (`/var/log/sovran-support-audit.log`)
- The SSH auth log for the affected time window
- A description of what you were troubleshooting
---
### Scenario 2 — Support session cannot be ended (button fails or server is unresponsive)
**Manual key removal (run as root on the device):**
```bash
# Remove from support user's authorized_keys
rm -f /var/lib/sovran-support/.ssh/authorized_keys
# Remove from root's authorized_keys (fallback / legacy)
sed -i '/sovransystemsos-support/d' /root/.ssh/authorized_keys
# Remove wallet unlock state
rm -f /var/lib/secrets/support-wallet-unlock
# Re-apply wallet ACL protections
setfacl -R -m u:sovran-support:--- /etc/nix-bitcoin-secrets \
/var/lib/bitcoind /var/lib/lnd /home 2>/dev/null || true
# Restart sshd to drop any active connections
systemctl restart sshd
```
---
### Scenario 3 — You see an unexpected SUPPORT_ENABLED in the audit log
This should never happen without physical or remote access to the Hub web
interface. If you see an unexpected entry:
1. Immediately run the manual key removal commands above.
2. Change the Sovran Hub web interface password.
3. Check `/var/log/nginx/access.log` (or Caddy access logs) for unexpected
requests to `/api/support/enable`.
4. Consider rebooting the device to clear any in-memory state.
5. Report the incident to Sovran Systems.
---
*This document is part of the Sovran_SystemsOS repository. For the
authoritative and up-to-date version, see the repository.*
Generated
+92 -41
View File
@@ -1,15 +1,34 @@
{ {
"nodes": { "nodes": {
"btc-clients": { "bip110": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1780397635, "lastModified": 1777892922,
"narHash": "sha256-6WH7LKD6i91VLWoz4mEpoULtqVinCEZxG7ZjJPMSi3k=", "narHash": "sha256-Yo53Ae0eQa5nByGoTsdJQAIK7kR9UlSDEWT88Qy/6g8=",
"owner": "emmanuelrosa",
"repo": "bitcoin-knots-bip-110-nix",
"rev": "dfe7221629f14d81ce1b4fc96d9500982d1baa58",
"type": "github"
},
"original": {
"owner": "emmanuelrosa",
"repo": "bitcoin-knots-bip-110-nix",
"type": "github"
}
},
"btc-clients": {
"inputs": {
"nixpkgs": "nixpkgs_2",
"oldNixpkgs": "oldNixpkgs"
},
"locked": {
"lastModified": 1778689657,
"narHash": "sha256-ezeTjnY34JhUuQtGz2u5UZu/hKh6qR1H6clmCkNcZ9o=",
"owner": "emmanuelrosa", "owner": "emmanuelrosa",
"repo": "btc-clients-nix", "repo": "btc-clients-nix",
"rev": "feacd7684dc6bfcd49c57764944a2049bbd71924", "rev": "f943183938d5725b8cecafc99f25f05fd084e7fb",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -52,11 +71,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1778716662, "lastModified": 1777988971,
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", "narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=",
"owner": "hercules-ci", "owner": "hercules-ci",
"repo": "flake-parts", "repo": "flake-parts",
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", "rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -87,16 +106,16 @@
"inputs": { "inputs": {
"extra-container": "extra-container", "extra-container": "extra-container",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2", "nixpkgs": "nixpkgs_3",
"nixpkgs-25_05": "nixpkgs-25_05", "nixpkgs-25_05": "nixpkgs-25_05",
"nixpkgs-unstable": "nixpkgs-unstable" "nixpkgs-unstable": "nixpkgs-unstable"
}, },
"locked": { "locked": {
"lastModified": 1779253922, "lastModified": 1778603697,
"narHash": "sha256-k5DpYVfyy27ELuEiV+51EfVg7B6vKUW63NWeA6eKGd0=", "narHash": "sha256-da2UUYhmvo9tASz+Lsd6ns5IiNgIn8HIA9BIUGa81Lc=",
"owner": "fort-nix", "owner": "fort-nix",
"repo": "nix-bitcoin", "repo": "nix-bitcoin",
"rev": "1496f842477976c085cd96f1837ea12444014088", "rev": "407796c767d526836b87ac1430ee965bd3b90a8b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -108,15 +127,16 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1780218263, "lastModified": 1777728799,
"narHash": "sha256-T/f0pPDrH3Qc1VXyQXbK7yfHWRn90l3xwplc/nsxin4=", "narHash": "sha256-z7jjYQqhkFKab92VQ3duB7QVO7f7Y62qTFrJYXO/lyo=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "7fc393d1b46fa000d48ff14e8b6a3c9985f03af0", "rev": "4b2287113c2f9a2331c04899b2e2e5ab92dea9c5",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "nixos",
"ref": "master",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
@@ -139,27 +159,27 @@
}, },
"nixpkgs-stable": { "nixpkgs-stable": {
"locked": { "locked": {
"lastModified": 1780453794, "lastModified": 1751274312,
"narHash": "sha256-bXMRa9VTsHSPXL4Cw8R6JJLQeY3Y/IP4+YJCYVmQ7FY=", "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "6b316287bae2ee04c9b93c8c858d930fd07d7338", "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "nixos",
"ref": "nixos-26.05", "ref": "nixos-24.11",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
"nixpkgs-unstable": { "nixpkgs-unstable": {
"locked": { "locked": {
"lastModified": 1778869304, "lastModified": 1777826146,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", "narHash": "sha256-wQ/iN5Zp5VIa3ebBibijPnLyKhor+xEbDy4d0goa9Zs=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb", "rev": "73c703c22422b8951895a960959dbbaca7296492",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -171,11 +191,26 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1778737229, "lastModified": 1777728799,
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=", "narHash": "sha256-z7jjYQqhkFKab92VQ3duB7QVO7f7Y62qTFrJYXO/lyo=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5", "rev": "4b2287113c2f9a2331c04899b2e2e5ab92dea9c5",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1777673416,
"narHash": "sha256-5c2POKPOjU40Kh0MirOdScBLG0bu9TAuPYAtPRNZMBs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "26ef669cffa904b6f6832ab57b77892a37c1a671",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -185,13 +220,13 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_3": { "nixpkgs_4": {
"locked": { "locked": {
"lastModified": 1780243769, "lastModified": 1778443072,
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=", "narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c", "rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -201,13 +236,13 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_4": { "nixpkgs_5": {
"locked": { "locked": {
"lastModified": 1780336545, "lastModified": 1778580735,
"narHash": "sha256-vhVhuXzFrIOfcssC/9hDHx7MHzDKjF3keHuREOQqQiQ=", "narHash": "sha256-t+8AVV8ExvOmslz2sLIgw/hJBKlyl65rJvxjvvjHgpE=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "4df1b885d76a54e1aa1a318f8d16fd6005b6401f", "rev": "48d91f2c0ce7b9e589f967d4f685153dd765dcdd",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -220,15 +255,15 @@
"nixvim": { "nixvim": {
"inputs": { "inputs": {
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"nixpkgs": "nixpkgs_4", "nixpkgs": "nixpkgs_5",
"systems": "systems_2" "systems": "systems_2"
}, },
"locked": { "locked": {
"lastModified": 1780646548, "lastModified": 1778682931,
"narHash": "sha256-Ckyl/l1XBmEwnaHcHD8PvBZk1uph0NqwbJ//CAvB7iE=", "narHash": "sha256-6pfRbQfEBwFEkpm3ZDSi73xugqU55Ebvkt1cptJGmJk=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixvim", "repo": "nixvim",
"rev": "816a15282e58678dde831477964987d0262d4293", "rev": "9ad88c3f9778cd6d16fc85a80f3045b45af749e7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -237,11 +272,28 @@
"type": "github" "type": "github"
} }
}, },
"oldNixpkgs": {
"locked": {
"lastModified": 1727619874,
"narHash": "sha256-a4Jcd+vjQAzF675/7B1LN3U2ay22jfDAVA8pOml5J/0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "6710d0dd013f55809648dfb1265b8f85447d30a6",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "6710d0dd013f55809648dfb1265b8f85447d30a6",
"repo": "nixpkgs",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"bip110": "bip110",
"btc-clients": "btc-clients", "btc-clients": "btc-clients",
"nix-bitcoin": "nix-bitcoin", "nix-bitcoin": "nix-bitcoin",
"nixpkgs": "nixpkgs_3", "nixpkgs": "nixpkgs_4",
"nixpkgs-stable": "nixpkgs-stable", "nixpkgs-stable": "nixpkgs-stable",
"nixvim": "nixvim" "nixvim": "nixvim"
} }
@@ -263,16 +315,15 @@
}, },
"systems_2": { "systems_2": {
"locked": { "locked": {
"lastModified": 1774449309, "lastModified": 1681028828,
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=", "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems", "owner": "nix-systems",
"repo": "default", "repo": "default",
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150", "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nix-systems", "owner": "nix-systems",
"ref": "future-26.11",
"repo": "default", "repo": "default",
"type": "github" "type": "github"
} }
+4 -2
View File
@@ -6,10 +6,11 @@
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release"; nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
nixvim.url = "github:nix-community/nixvim"; nixvim.url = "github:nix-community/nixvim";
btc-clients.url = "github:emmanuelrosa/btc-clients-nix"; btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05"; nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11";
bip110.url = "github:emmanuelrosa/bitcoin-knots-bip-110-nix";
}; };
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }: outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, bip110, ... }:
let let
overlay-stable = final: prev: { overlay-stable = final: prev: {
@@ -55,6 +56,7 @@
btc-clients.packages.${pkgs.system}.bisq2 btc-clients.packages.${pkgs.system}.bisq2
btc-clients.packages.${pkgs.system}.sparrow btc-clients.packages.${pkgs.system}.sparrow
]; ];
sovran_systemsOS.packages.bip110 = bip110.packages.${pkgs.system}.bitcoind-knots-bip-110;
}; };
}; };
}; };
-52
View File
@@ -1,52 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#153126"/>
<stop offset="55%" stop-color="#0F241B"/>
<stop offset="100%" stop-color="#091C14"/>
</linearGradient>
<linearGradient id="outerArc" x1="70" y1="40" x2="190" y2="210" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#42F39A"/>
<stop offset="45%" stop-color="#28D978"/>
<stop offset="100%" stop-color="#1AA45D"/>
</linearGradient>
<linearGradient id="innerArc" x1="90" y1="60" x2="180" y2="190" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#27C86F"/>
<stop offset="100%" stop-color="#157E49"/>
</linearGradient>
<filter id="innerShade" x="-10%" y="-10%" width="120%" height="120%">
<feOffset dx="0" dy="2"/>
<feGaussianBlur stdDeviation="5" result="blur"/>
<feComposite in="blur" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="
0 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 .18 0"/>
</filter>
</defs>
<rect width="256" height="256" rx="48" ry="48" fill="url(#bg)"/>
<rect x="1.5" y="1.5" width="253" height="253" rx="46.5" ry="46.5"
fill="none" stroke="rgba(255,255,255,0.08)"/>
<rect x="6" y="6" width="244" height="244" rx="42" ry="42"
fill="none" filter="url(#innerShade)"/>
<path d="M128 32 A96 96 0 1 1 58 196"
fill="none"
stroke="url(#outerArc)"
stroke-width="12"
stroke-linecap="round"/>
<path d="M128 56 A72 72 0 1 1 76 178"
fill="none"
stroke="url(#innerArc)"
stroke-width="10"
stroke-linecap="round"/>
<circle cx="128" cy="128" r="8" fill="#F2FFF7"/>
<circle cx="128" cy="128" r="18" fill="none" stroke="#7BFFC0" stroke-opacity="0.14" stroke-width="4"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

+23
View File
@@ -0,0 +1,23 @@
{ config, lib, pkgs, ... }:
let
cfg = config.sovran_systemsOS;
in
{
options.sovran_systemsOS.packages.bip110 = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
description = "BIP110 Bitcoin package";
};
config = lib.mkIf (
cfg.features.bip110 &&
cfg.packages.bip110 != null
) {
services.bitcoind.package = lib.mkForce cfg.packages.bip110;
environment.systemPackages = [
cfg.packages.bip110
];
};
}
+1 -1
View File
@@ -4,7 +4,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
services.bitcoind = { services.bitcoind = {
enable = true; enable = true;
package = pkgs.bitcoind-knots; package = config.nix-bitcoin.pkgs.bitcoind-knots;
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node"; dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
txindex = true; txindex = true;
tor.proxy = true; tor.proxy = true;
+2 -1
View File
@@ -24,7 +24,7 @@
}) })
# ── Bitcoin Node Only Role ──────────────────────────────── # ── Bitcoin Node Only Role ────────────────────────────────
# Bitcoin ecosystem + mempool, BTCPay runs but not exposed via Caddy # Bitcoin ecosystem + mempool + bip110, BTCPay runs but not exposed via Caddy
(lib.mkIf config.sovran_systemsOS.roles.node { (lib.mkIf config.sovran_systemsOS.roles.node {
sovran_systemsOS.services = { sovran_systemsOS.services = {
bitcoin = lib.mkDefault true; bitcoin = lib.mkDefault true;
@@ -36,6 +36,7 @@
sovran_systemsOS.features = { sovran_systemsOS.features = {
mempool = lib.mkDefault true; mempool = lib.mkDefault true;
bip110 = lib.mkDefault true;
}; };
sovran_systemsOS.web.btcpayserver = lib.mkDefault false; sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
+1 -24
View File
@@ -43,24 +43,12 @@
# ── Features (default OFF — user can enable in custom.nix) ── # ── Features (default OFF — user can enable in custom.nix) ──
features = { features = {
haven = lib.mkEnableOption "Haven NOSTR relay"; haven = lib.mkEnableOption "Haven NOSTR relay";
bip110 = lib.mkEnableOption "BIP-110 Bitcoin Better Money";
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer"; mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
element-calling = lib.mkEnableOption "Element Video and Audio Calling"; element-calling = lib.mkEnableOption "Element Video and Audio Calling";
bitcoin-core = lib.mkEnableOption "Bitcoin Core"; bitcoin-core = lib.mkEnableOption "Bitcoin Core";
rdp = lib.mkEnableOption "Gnome Remote Desktop"; rdp = lib.mkEnableOption "Gnome Remote Desktop";
sshd = lib.mkEnableOption "SSH remote access"; 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 {
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.";
};
}; };
# ── Web exposure (controls Caddy vhosts) ────────────────── # ── Web exposure (controls Caddy vhosts) ──────────────────
@@ -101,15 +89,4 @@
description = "Nostr public key (npub1...) for Haven relay"; description = "Nostr public key (npub1...) for Haven relay";
}; };
}; };
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.
''
];
};
} }
+9 -26
View File
@@ -29,7 +29,10 @@ let
] ]
# ── Bitcoin Base (node implementations) ──────────────────── # ── Bitcoin Base (node implementations) ────────────────────
++ lib.optionals cfg.services.bitcoin [ ++ lib.optionals cfg.services.bitcoin [
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [ { name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; credentials = [
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
]; }
{ name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; credentials = [
{ label = "Tor Address Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; } { 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 = [ { name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
@@ -135,11 +138,7 @@ let
RC=0 RC=0
echo " Step 1/3: nix flake update " echo " Step 1/3: nix flake update "
if ! nix flake update --flake /etc/nixos --print-build-logs \ if ! nix flake update --flake /etc/nixos --print-build-logs 2>&1; then
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1; then
echo "[ERROR] nix flake update failed" echo "[ERROR] nix flake update failed"
RC=1 RC=1
fi fi
@@ -147,11 +146,7 @@ let
if [ "$RC" -eq 0 ]; then if [ "$RC" -eq 0 ]; then
echo " Step 2/3: nixos-rebuild " echo " Step 2/3: nixos-rebuild "
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \ SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1)
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1)
SWITCH_RC=$? SWITCH_RC=$?
echo "$SWITCH_OUT" echo "$SWITCH_OUT"
if [ "$SWITCH_RC" -eq 0 ]; then if [ "$SWITCH_RC" -eq 0 ]; then
@@ -160,11 +155,7 @@ let
echo "" echo ""
echo " Build succeeded a reboot is required to apply this update" echo " Build succeeded a reboot is required to apply this update"
echo " (Critical system components changed; running nixos-rebuild boot instead)" echo " (Critical system components changed; running nixos-rebuild boot instead)"
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \ if nixos-rebuild boot --flake /etc/nixos --print-build-logs 2>&1; then
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1; then
echo "REBOOT_REQUIRED" > "$STATUS" echo "REBOOT_REQUIRED" > "$STATUS"
exit 0 exit 0
else else
@@ -218,11 +209,7 @@ let
echo "" echo ""
echo "" echo ""
echo " Rebuilding system configuration " echo " Rebuilding system configuration "
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \ SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1)
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1)
SWITCH_RC=$? SWITCH_RC=$?
echo "$SWITCH_OUT" echo "$SWITCH_OUT"
if [ "$SWITCH_RC" -eq 0 ]; then if [ "$SWITCH_RC" -eq 0 ]; then
@@ -235,11 +222,7 @@ let
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)"
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \ if nixos-rebuild boot --flake /etc/nixos --print-build-logs 2>&1; then
--option connect-timeout 10 \
--option stalled-download-timeout 90 \
--option download-attempts 7 \
--option fallback true 2>&1; then
echo "REBOOT_REQUIRED" > "$STATUS" echo "REBOOT_REQUIRED" > "$STATUS"
else else
echo "[ERROR] nixos-rebuild boot also failed" echo "[ERROR] nixos-rebuild boot also failed"
+4 -3
View File
@@ -130,7 +130,7 @@ EOF
keyFile = livekitKeyFile; keyFile = livekitKeyFile;
settings = { settings = {
rtc.use_external_ip = true; rtc.use_external_ip = true;
rtc.udp_port = 7882; rtc.udp_port = "7882-7894";
room.auto_create = false; room.auto_create = false;
turn = { turn = {
enabled = true; enabled = true;
@@ -141,9 +141,10 @@ EOF
}; };
networking.firewall.allowedTCPPorts = [ 5349 7881 ]; networking.firewall.allowedTCPPorts = [ 5349 7881 ];
networking.firewall.allowedUDPPorts = [ 3478 7882 ]; networking.firewall.allowedUDPPorts = [ 3478 ];
networking.firewall.allowedUDPPortRanges = [ networking.firewall.allowedUDPPortRanges = [
{ from = 30000; to = 40000; } { from = 7882; to = 7894; }
{ from = 30000; to = 40000;}
]; ];
networking.firewall.allowedTCPPortRanges = [ networking.firewall.allowedTCPPortRanges = [
{ from = 30000; to = 40000; } { from = 30000; to = 40000; }
+1
View File
@@ -31,6 +31,7 @@
# ── Features (default OFF — enable in custom.nix) ───────── # ── Features (default OFF — enable in custom.nix) ─────────
./haven.nix ./haven.nix
./bip110.nix
./element-calling.nix ./element-calling.nix
./mempool.nix ./mempool.nix
./bitcoin-core.nix ./bitcoin-core.nix