Compare commits
53 Commits
629f5f76c2
...
stable
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bf8814fa7 | |||
| d90b5b091b | |||
| 4275ac1d2f | |||
| 07b36d62d2 | |||
| 5fb8279d61 | |||
| 6eb63d3f85 | |||
| 2702854513 | |||
| 106537cc63 | |||
| dabb96e1b3 | |||
| 2b5a154b99 | |||
| e475b0f47d | |||
| 8f81f8f1e2 | |||
| cd753a7e28 | |||
| 7ac1985508 | |||
| 0ecf2eb651 | |||
| 18c7095aaf | |||
| dcad276c59 | |||
| 06988d0ff0 | |||
| 69b84153b4 | |||
| df08a7c413 | |||
| 602464189f | |||
| 67f4cdc99e | |||
| f8c717db25 | |||
| 268abddb28 | |||
| c1119b03a8 | |||
| 0c273b758d | |||
| 6f98c478e8 | |||
| 875a6a9297 | |||
| 1dbfe3cd94 | |||
| e0d4b3544d | |||
| 3e3fbed470 | |||
| ada9f25c41 | |||
| 66cacaaf9d | |||
| 15cd07d12f | |||
| fae57c0375 | |||
| 3745eedd74 | |||
| 1cd4fc8b40 | |||
| 732ab6f2aa | |||
| bea26c55c3 | |||
| a841665b07 | |||
| d574f96379 | |||
| 2388039b63 | |||
| aa69d40f08 | |||
| 31cb48cc2b | |||
| 170bd14a34 | |||
| 2553e0dce0 | |||
| b8e7b2b4cc | |||
| 24098a209a | |||
| 8ad7509b02 | |||
| a350d4e2f7 | |||
| ec3782991d | |||
| bc4b4630a3 | |||
| 342f60ce0d |
@@ -1 +1,159 @@
|
||||
### Testing Branch
|
||||
<div align="center">
|
||||
|
||||
<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>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS desktop showing application dock and PRIVACY. SOVEREIGNTY. BITCOIN. tagline" width="800" />
|
||||
|
||||
*The Sovran_SystemsOS desktop — "Privacy. Sovereignty. Bitcoin."*
|
||||
|
||||
</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).
|
||||
|
||||
@@ -204,7 +204,7 @@ FEATURE_REGISTRY = [
|
||||
{"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
|
||||
{"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"},
|
||||
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
|
||||
{"port": "7882-7894", "protocol": "UDP", "description": "LiveKit media streams"},
|
||||
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
|
||||
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
|
||||
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
|
||||
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
|
||||
@@ -222,28 +222,16 @@ FEATURE_REGISTRY = [
|
||||
"conflicts_with": [],
|
||||
"port_requirements": [],
|
||||
},
|
||||
{
|
||||
"id": "bip110",
|
||||
"name": "Bitcoin Knots + BIP110",
|
||||
"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",
|
||||
"needs_domain": False,
|
||||
"domain_name": None,
|
||||
"needs_ddns": False,
|
||||
"extra_fields": [],
|
||||
"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.",
|
||||
"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.",
|
||||
"category": "bitcoin",
|
||||
"needs_domain": False,
|
||||
"domain_name": None,
|
||||
"needs_ddns": False,
|
||||
"extra_fields": [],
|
||||
"conflicts_with": ["bip110"],
|
||||
"conflicts_with": [],
|
||||
"port_requirements": [],
|
||||
},
|
||||
{
|
||||
@@ -277,13 +265,16 @@ 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
|
||||
FEATURE_SERVICE_MAP = {
|
||||
"rdp": "gnome-remote-desktop.service",
|
||||
"haven": "haven-relay.service",
|
||||
"element-calling": "livekit.service",
|
||||
"mempool": "mempool.service",
|
||||
"bip110": None,
|
||||
"bitcoin-core": None,
|
||||
"btcpay-web": "btcpayserver.service",
|
||||
"sshd": "sshd.service",
|
||||
@@ -295,7 +286,7 @@ _PORTS_MATRIX_FEDERATION = [
|
||||
]
|
||||
_PORTS_ELEMENT_CALLING = [
|
||||
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
|
||||
{"port": "7882-7894", "protocol": "UDP", "description": "LiveKit media streams"},
|
||||
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
|
||||
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
|
||||
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
|
||||
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
|
||||
@@ -331,7 +322,6 @@ SERVICE_DOMAIN_MAP: dict[str, str] = {
|
||||
|
||||
# For features that share a unit, disambiguate by icon field
|
||||
FEATURE_ICON_MAP = {
|
||||
"bip110": "bip110",
|
||||
"bitcoin-core": "bitcoin-core",
|
||||
}
|
||||
|
||||
@@ -352,7 +342,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = {
|
||||
ROLE_FEATURES: dict[str, set[str] | None] = {
|
||||
"server_plus_desktop": None,
|
||||
"desktop": {"rdp", "sshd"},
|
||||
"node": {"rdp", "bip110", "bitcoin-core", "mempool", "btcpay-web", "sshd"},
|
||||
"node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "sshd"},
|
||||
}
|
||||
|
||||
SERVICE_DESCRIPTIONS: dict[str, str] = {
|
||||
@@ -652,6 +642,37 @@ templates = Jinja2Templates(directory=os.path.join(_BASE_DIR, "templates"))
|
||||
|
||||
# ── 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:
|
||||
"""Return first 8 chars of the MD5 hex digest for a static file."""
|
||||
path = os.path.join(_BASE_DIR, "static", filename)
|
||||
@@ -894,7 +915,7 @@ def _get_firewall_allowed_ports() -> dict[str, set[int]]:
|
||||
|
||||
|
||||
def _port_range_to_ints(port_str: str) -> list[int]:
|
||||
"""Convert a port string like ``"443"``, ``"7882-7894"`` to a list of ints."""
|
||||
"""Convert a port string like ``"443"``, ``"30000-40000"`` to a list of ints."""
|
||||
port_str = port_str.strip()
|
||||
if re.match(r'^\d+$', port_str):
|
||||
return [int(port_str)]
|
||||
@@ -1488,7 +1509,9 @@ 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*;',
|
||||
section,
|
||||
):
|
||||
features[m.group(1)] = m.group(2) == "true"
|
||||
feat_id = m.group(1)
|
||||
if feat_id not in DEPRECATED_FEATURE_IDS:
|
||||
features[feat_id] = m.group(2) == "true"
|
||||
for m in re.finditer(
|
||||
r'sovran_systemsOS\.web\.btcpayserver\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;',
|
||||
section,
|
||||
@@ -1521,6 +1544,8 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
|
||||
"""Write the Hub Managed section inside custom.nix."""
|
||||
lines = []
|
||||
for feat_id, enabled in features.items():
|
||||
if feat_id in DEPRECATED_FEATURE_IDS:
|
||||
continue
|
||||
val = "true" if enabled else "false"
|
||||
if feat_id == "btcpay-web":
|
||||
lines.append(f" sovran_systemsOS.web.btcpayserver = lib.mkForce {val};")
|
||||
@@ -1566,6 +1591,40 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
||||
@@ -1575,7 +1634,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
|
||||
unit = FEATURE_SERVICE_MAP.get(feature_id)
|
||||
if unit is None:
|
||||
return None # bip110, bitcoin-core — can't determine from config
|
||||
return None # bitcoin-core — can't determine from config
|
||||
cfg = load_config()
|
||||
for svc in cfg.get("services", []):
|
||||
if svc.get("unit") == unit:
|
||||
@@ -1894,7 +1953,10 @@ def _verify_support_removed() -> bool:
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request,
|
||||
"asset_version": ASSET_VERSION,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/auto-login")
|
||||
@@ -1961,6 +2023,7 @@ async def api_logout(request: Request):
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse("index.html", {
|
||||
"request": request,
|
||||
"asset_version": ASSET_VERSION,
|
||||
})
|
||||
|
||||
|
||||
@@ -1969,6 +2032,7 @@ async def onboarding(request: Request):
|
||||
_ensure_onboarding_reopened_for_migration()
|
||||
return templates.TemplateResponse("onboarding.html", {
|
||||
"request": request,
|
||||
"asset_version": ASSET_VERSION,
|
||||
"onboarding_js_hash": _ONBOARDING_JS_HASH,
|
||||
})
|
||||
|
||||
@@ -2189,6 +2253,16 @@ _BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime
|
||||
# Cache for ``bitcoind --version`` output (available even before RPC is ready)
|
||||
_btcd_version_cache: tuple[float, str | None] = (0.0, None)
|
||||
|
||||
# Cache for ``bitcoin-cli getdeploymentinfo`` output (BIP-110 live status)
|
||||
_btc_deployment_cache: tuple[float, dict | None] = (0.0, None)
|
||||
|
||||
# Bitcoin Knots exposes BIP-110 as the `reduced_data` versionbits deployment
|
||||
# (RDTS, bit 4) in getdeploymentinfo. See Knots src/deploymentinfo.cpp,
|
||||
# src/kernel/chainparams.cpp, and doc/bips.md.
|
||||
BIP110_DEPLOYMENT_NAMES = {"reduced_data", "rdts", "bip110", "uasf-bip110"}
|
||||
BIP110_VERSIONBITS_BIT = 4
|
||||
BIP110_SUBVERSION_MARKERS = {"bip110", "uasf-bip110", "reduced_data", "rdts"}
|
||||
|
||||
|
||||
# ── Generic service version detection (NixOS store path) ─────────
|
||||
|
||||
@@ -2303,12 +2377,160 @@ def _get_bitcoin_version_info() -> dict | 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:
|
||||
"""Run ``bitcoind --version`` and return the raw version string, or None on error.
|
||||
|
||||
Parses the first output line to extract the token after "version ".
|
||||
For example: "Bitcoin Knots daemon version v29.3.knots20260210+bip110-v0.4.1"
|
||||
returns "v29.3.knots20260210+bip110-v0.4.1".
|
||||
For example: "Bitcoin Knots daemon version v29.3.knots20260508"
|
||||
returns "v29.3.knots20260508".
|
||||
|
||||
Works regardless of whether the RPC server is ready (IBD, warmup, etc.).
|
||||
Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL).
|
||||
@@ -2343,25 +2565,12 @@ def _get_bitcoind_version() -> str | None:
|
||||
def _format_bitcoin_version(raw_version: str, icon: str = "") -> str:
|
||||
"""Format a raw version string from ``bitcoind --version`` for tile display.
|
||||
|
||||
Strips the ``+bip110-vX.Y.Z`` patch suffix so the base version is shown
|
||||
cleanly (e.g. "v29.3.knots20260210+bip110-v0.4.1" → "v29.3.knots20260210").
|
||||
For the BIP110 tile (icon == "bip110") a " (bip110 vX.Y.Z)" tag is appended
|
||||
including the patch version.
|
||||
For the BIP110 tile (icon == "bip110") a " (bip110)" tag is appended,
|
||||
since mainline Bitcoin Knots (29.3.knots20260508+) now includes BIP-110
|
||||
and no longer carries a separate ``+bip110-vX.Y.Z`` suffix.
|
||||
"""
|
||||
# Extract the BIP110 patch version before stripping the suffix
|
||||
bip110_ver = ""
|
||||
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 = raw_version
|
||||
if icon == "bip110" and "(bip110)" not in display.lower():
|
||||
display += " (bip110)"
|
||||
return display
|
||||
|
||||
@@ -2430,6 +2639,19 @@ async def api_bitcoin_version():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/bitcoin/bip110")
|
||||
async def api_bitcoin_bip110():
|
||||
"""Return live BIP-110 deployment/signaling status from bitcoin-cli.
|
||||
|
||||
Always returns HTTP 200. When bitcoind is unreachable or the node is mid-IBD
|
||||
the response will contain ``state = "unknown"`` so the UI can render a neutral
|
||||
badge rather than an error toast.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
status = await loop.run_in_executor(None, _get_bip110_status)
|
||||
return status
|
||||
|
||||
|
||||
@app.get("/api/services")
|
||||
async def api_services():
|
||||
cfg = load_config()
|
||||
@@ -2610,6 +2832,8 @@ async def api_services():
|
||||
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
||||
service_data["bitcoin_version"] = btc_ver # backwards compat
|
||||
service_data["version"] = btc_ver
|
||||
if icon == "bip110":
|
||||
service_data["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
|
||||
return service_data
|
||||
|
||||
results = await asyncio.gather(*[get_status(s) for s in services])
|
||||
@@ -2894,6 +3118,8 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
||||
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
||||
service_detail["bitcoin_version"] = btc_ver # backwards compat
|
||||
service_detail["version"] = btc_ver
|
||||
if icon == "bip110":
|
||||
service_detail["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
|
||||
return service_detail
|
||||
|
||||
|
||||
@@ -4561,6 +4787,14 @@ async def _startup_recover_stale_status():
|
||||
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():
|
||||
"""Periodically curl configured domains and cache reachability results."""
|
||||
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
|
||||
|
||||
@@ -155,6 +155,69 @@
|
||||
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 ───────────────────────────────── */
|
||||
|
||||
.svc-detail-section {
|
||||
|
||||
@@ -413,16 +413,11 @@ function handleFeatureToggle(feat, newEnabled) {
|
||||
});
|
||||
}
|
||||
|
||||
if (conflictNames.length > 0) {
|
||||
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?";
|
||||
}
|
||||
if (feat.id === "bitcoin-core") {
|
||||
var confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will replace Bitcoin Knots + BIP110 as the active node. Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
|
||||
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
||||
} else if (conflictNames.length > 0) {
|
||||
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
||||
} else {
|
||||
proceedAfterConflictCheck();
|
||||
}
|
||||
|
||||
@@ -60,3 +60,17 @@ async function apiFetch(path, options) {
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
// ── BIP-110 badge state config ────────────────────────────────────
|
||||
// Shared lookup used by tiles.js and service-detail.js.
|
||||
// Keys match the "state" values returned by /api/bitcoin/bip110.
|
||||
|
||||
var BIP110_BADGE_CONFIG = {
|
||||
active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' },
|
||||
locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' },
|
||||
signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' },
|
||||
not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' },
|
||||
unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' },
|
||||
unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' }
|
||||
};
|
||||
|
||||
@@ -107,6 +107,21 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// Section B2: BIP-110 live status (bip110 tile only)
|
||||
if (icon === 'bip110' && data.bip110) {
|
||||
var bip110 = data.bip110;
|
||||
var bip110State = bip110.state || 'unknown';
|
||||
var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown;
|
||||
var bip110Source = bip110.source ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
|
||||
'<div class="bip110-status-row">' +
|
||||
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
|
||||
bip110Source +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Section C: Domain diagnostics (domain services)
|
||||
if (data.needs_domain) {
|
||||
var steps = data.domain_check_steps || [];
|
||||
@@ -242,7 +257,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||
|
||||
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
||||
var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core")
|
||||
var addonSectionTitle = (feat.id === "bitcoin-core")
|
||||
? "\u20BF Bitcoin Node Selection"
|
||||
: "\uD83D\uDD27 Addon Feature";
|
||||
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
// Keyed by tileId: { progress: float, timestamp: ms }
|
||||
var _btcSyncPrev = {};
|
||||
|
||||
// ── BIP-110 badge helper ──────────────────────────────────────────
|
||||
|
||||
function _renderBip110Badge(bip110) {
|
||||
if (!bip110) return '';
|
||||
var state = bip110.state || 'unknown';
|
||||
var cfg = BIP110_BADGE_CONFIG[state] || BIP110_BADGE_CONFIG.unknown;
|
||||
return '<div class="tile-bip110-badge ' + cfg.cls + '" title="' + escHtml(cfg.title) + '">' + escHtml(cfg.label) + '</div>';
|
||||
}
|
||||
|
||||
function _firstElementFromHtml(html) {
|
||||
var tmp = document.createElement("div");
|
||||
tmp.innerHTML = html;
|
||||
return tmp.firstElementChild || null;
|
||||
}
|
||||
|
||||
// ── Render: initial build ─────────────────────────────────────────
|
||||
|
||||
function buildTiles(services, categoryLabels) {
|
||||
@@ -165,7 +180,8 @@ function buildTile(svc) {
|
||||
|
||||
var ver = svc.version || svc.bitcoin_version || '';
|
||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</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 + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></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 + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() {
|
||||
@@ -265,6 +281,23 @@ 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 to</th><th>Purpose</th></tr></thead>';
|
||||
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">7882–7894</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">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">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">30000–40000</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>';
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sovran_SystemsOS Hub</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css" />
|
||||
<link rel="stylesheet" href="/static/css/header.css" />
|
||||
<link rel="stylesheet" href="/static/css/layout.css" />
|
||||
<link rel="stylesheet" href="/static/css/tiles.css" />
|
||||
<link rel="stylesheet" href="/static/css/modals.css" />
|
||||
<link rel="stylesheet" href="/static/css/features.css" />
|
||||
<link rel="stylesheet" href="/static/css/onboarding.css" />
|
||||
<link rel="stylesheet" href="/static/css/support.css" />
|
||||
<link rel="stylesheet" href="/static/css/domain-setup.css" />
|
||||
<link rel="stylesheet" href="/static/css/security.css" />
|
||||
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/layout.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/tiles.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/modals.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/features.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/onboarding.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/support.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/domain-setup.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/security.css?v={{ asset_version }}" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -263,16 +263,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/constants.js"></script>
|
||||
<script src="/static/js/state.js"></script>
|
||||
<script src="/static/js/helpers.js"></script>
|
||||
<script src="/static/js/tiles.js"></script>
|
||||
<script src="/static/js/service-detail.js"></script>
|
||||
<script src="/static/js/support.js"></script>
|
||||
<script src="/static/js/update.js"></script>
|
||||
<script src="/static/js/rebuild.js"></script>
|
||||
<script src="/static/js/features.js"></script>
|
||||
<script src="/static/js/security.js"></script>
|
||||
<script src="/static/js/events.js"></script>
|
||||
<script src="/static/js/constants.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/state.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/helpers.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/tiles.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/service-detail.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/support.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/update.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/rebuild.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/features.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/security.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/events.js?v={{ asset_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sovran Hub — Login</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css" />
|
||||
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-wrapper">
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 442 KiB |
@@ -0,0 +1,52 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
+9
-3
@@ -26,6 +26,13 @@
|
||||
nix.settings = {
|
||||
experimental-features = [ "nix-command" "flakes" ];
|
||||
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 ──────────────────────────────────────────────
|
||||
@@ -63,7 +70,6 @@
|
||||
# ── Desktop ────────────────────────────────────────────────
|
||||
services.displayManager.gdm.enable = true;
|
||||
services.displayManager.gdm.autoSuspend = false;
|
||||
services.displayManager.gdm.wayland = true;
|
||||
services.desktopManager.gnome.enable = true;
|
||||
services.printing.enable = true;
|
||||
systemd.enableEmergencyMode = false;
|
||||
@@ -139,10 +145,10 @@
|
||||
ranger fastfetch gedit openssl pwgen
|
||||
aspell aspellDicts.en lm_sensors
|
||||
hunspell hunspellDicts.en_US
|
||||
synadm brave dua bitwarden-desktop
|
||||
synadm brave dua
|
||||
gparted pv unzip parted screen zenity
|
||||
libargon2 gnome-terminal libreoffice-fresh
|
||||
dig firefox element-desktop wp-cli axel
|
||||
dig firefox wp-cli axel
|
||||
lk-jwt-service livekit-libwebrtc livekit-cli livekit
|
||||
matrix-synapse age
|
||||
];
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,472 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,259 +0,0 @@
|
||||
# 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
+41
-92
@@ -1,34 +1,15 @@
|
||||
{
|
||||
"nodes": {
|
||||
"bip110": {
|
||||
"btc-clients": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1777892922,
|
||||
"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=",
|
||||
"lastModified": 1781013869,
|
||||
"narHash": "sha256-XlEUtL+8M6kbPdmIh4sQQ7G02/1CwHQEk1RPvIMEWOs=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "btc-clients-nix",
|
||||
"rev": "f943183938d5725b8cecafc99f25f05fd084e7fb",
|
||||
"rev": "9a6c78204dc8961840375b110bca595b1f6f084c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -71,11 +52,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1777988971,
|
||||
"narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=",
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -106,16 +87,16 @@
|
||||
"inputs": {
|
||||
"extra-container": "extra-container",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-25_05": "nixpkgs-25_05",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778603697,
|
||||
"narHash": "sha256-da2UUYhmvo9tASz+Lsd6ns5IiNgIn8HIA9BIUGa81Lc=",
|
||||
"lastModified": 1779253922,
|
||||
"narHash": "sha256-k5DpYVfyy27ELuEiV+51EfVg7B6vKUW63NWeA6eKGd0=",
|
||||
"owner": "fort-nix",
|
||||
"repo": "nix-bitcoin",
|
||||
"rev": "407796c767d526836b87ac1430ee965bd3b90a8b",
|
||||
"rev": "1496f842477976c085cd96f1837ea12444014088",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -127,16 +108,15 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777728799,
|
||||
"narHash": "sha256-z7jjYQqhkFKab92VQ3duB7QVO7f7Y62qTFrJYXO/lyo=",
|
||||
"lastModified": 1780218263,
|
||||
"narHash": "sha256-T/f0pPDrH3Qc1VXyQXbK7yfHWRn90l3xwplc/nsxin4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4b2287113c2f9a2331c04899b2e2e5ab92dea9c5",
|
||||
"rev": "7fc393d1b46fa000d48ff14e8b6a3c9985f03af0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "master",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -159,27 +139,27 @@
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1751274312,
|
||||
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
|
||||
"lastModified": 1780902259,
|
||||
"narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
|
||||
"rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-24.11",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1777826146,
|
||||
"narHash": "sha256-wQ/iN5Zp5VIa3ebBibijPnLyKhor+xEbDy4d0goa9Zs=",
|
||||
"lastModified": 1778869304,
|
||||
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "73c703c22422b8951895a960959dbbaca7296492",
|
||||
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -191,26 +171,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1777728799,
|
||||
"narHash": "sha256-z7jjYQqhkFKab92VQ3duB7QVO7f7Y62qTFrJYXO/lyo=",
|
||||
"lastModified": 1778737229,
|
||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"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",
|
||||
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -220,13 +185,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1778443072,
|
||||
"narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=",
|
||||
"lastModified": 1780749050,
|
||||
"narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32",
|
||||
"rev": "a799d3e3886da994fa307f817a6bc705ae538eeb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -236,13 +201,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_5": {
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1778580735,
|
||||
"narHash": "sha256-t+8AVV8ExvOmslz2sLIgw/hJBKlyl65rJvxjvvjHgpE=",
|
||||
"lastModified": 1780336545,
|
||||
"narHash": "sha256-vhVhuXzFrIOfcssC/9hDHx7MHzDKjF3keHuREOQqQiQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "48d91f2c0ce7b9e589f967d4f685153dd765dcdd",
|
||||
"rev": "4df1b885d76a54e1aa1a318f8d16fd6005b6401f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -255,15 +220,15 @@
|
||||
"nixvim": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs_5",
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778682931,
|
||||
"narHash": "sha256-6pfRbQfEBwFEkpm3ZDSi73xugqU55Ebvkt1cptJGmJk=",
|
||||
"lastModified": 1780995253,
|
||||
"narHash": "sha256-6Lsoyw2XPvY8YNMCtPnsyw0JVVtHsXP2xtrFJBBTAOQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "9ad88c3f9778cd6d16fc85a80f3045b45af749e7",
|
||||
"rev": "43a7e6f82978ac975c3bba6728869b231e7a1ba0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -272,28 +237,11 @@
|
||||
"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": {
|
||||
"inputs": {
|
||||
"bip110": "bip110",
|
||||
"btc-clients": "btc-clients",
|
||||
"nix-bitcoin": "nix-bitcoin",
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs-stable": "nixpkgs-stable",
|
||||
"nixvim": "nixvim"
|
||||
}
|
||||
@@ -315,15 +263,16 @@
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"lastModified": 1774449309,
|
||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"ref": "future-26.11",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
|
||||
@@ -6,11 +6,10 @@
|
||||
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
|
||||
nixvim.url = "github:nix-community/nixvim";
|
||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11";
|
||||
bip110.url = "github:emmanuelrosa/bitcoin-knots-bip-110-nix";
|
||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, bip110, ... }:
|
||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }:
|
||||
|
||||
let
|
||||
overlay-stable = final: prev: {
|
||||
@@ -56,7 +55,6 @@
|
||||
btc-clients.packages.${pkgs.system}.bisq2
|
||||
btc-clients.packages.${pkgs.system}.sparrow
|
||||
];
|
||||
sovran_systemsOS.packages.bip110 = bip110.packages.${pkgs.system}.bitcoind-knots-bip-110;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -1,23 +0,0 @@
|
||||
{ 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
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
|
||||
services.bitcoind = {
|
||||
enable = true;
|
||||
package = config.nix-bitcoin.pkgs.bitcoind-knots;
|
||||
package = pkgs.bitcoind-knots;
|
||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||
txindex = true;
|
||||
tor.proxy = true;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
})
|
||||
|
||||
# ── Bitcoin Node Only Role ────────────────────────────────
|
||||
# Bitcoin ecosystem + mempool + bip110, BTCPay runs but not exposed via Caddy
|
||||
# Bitcoin ecosystem + mempool, BTCPay runs but not exposed via Caddy
|
||||
(lib.mkIf config.sovran_systemsOS.roles.node {
|
||||
sovran_systemsOS.services = {
|
||||
bitcoin = lib.mkDefault true;
|
||||
@@ -36,7 +36,6 @@
|
||||
|
||||
sovran_systemsOS.features = {
|
||||
mempool = lib.mkDefault true;
|
||||
bip110 = lib.mkDefault true;
|
||||
};
|
||||
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
|
||||
|
||||
+24
-1
@@ -43,12 +43,24 @@
|
||||
# ── Features (default OFF — user can enable in custom.nix) ──
|
||||
features = {
|
||||
haven = lib.mkEnableOption "Haven NOSTR relay";
|
||||
bip110 = lib.mkEnableOption "BIP-110 Bitcoin Better Money";
|
||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||
sshd = lib.mkEnableOption "SSH remote access";
|
||||
|
||||
# Deprecated: BIP-110 is now built into mainline Bitcoin Knots and is the
|
||||
# default node. This option is retained ONLY so that existing machines with
|
||||
# `sovran_systemsOS.features.bip110 = lib.mkForce true;` left in their local
|
||||
# custom.nix continue to evaluate. It has no effect and will be removed in a
|
||||
# future release once the Hub has cleaned up old custom.nix files.
|
||||
bip110 = lib.mkOption {
|
||||
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) ──────────────────
|
||||
@@ -89,4 +101,15 @@
|
||||
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.
|
||||
''
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,10 +29,7 @@ let
|
||||
]
|
||||
# ── Bitcoin Base (node implementations) ────────────────────
|
||||
++ lib.optionals cfg.services.bitcoin [
|
||||
{ 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 = [
|
||||
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||
]; }
|
||||
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
||||
@@ -138,7 +135,11 @@ let
|
||||
RC=0
|
||||
|
||||
echo "── Step 1/3: nix flake update ────────────────────"
|
||||
if ! nix flake update --flake /etc/nixos --print-build-logs 2>&1; then
|
||||
if ! nix flake update --flake /etc/nixos --print-build-logs \
|
||||
--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"
|
||||
RC=1
|
||||
fi
|
||||
@@ -146,7 +147,11 @@ let
|
||||
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
echo "── Step 2/3: nixos-rebuild ──────────────────────────"
|
||||
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1)
|
||||
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1)
|
||||
SWITCH_RC=$?
|
||||
echo "$SWITCH_OUT"
|
||||
if [ "$SWITCH_RC" -eq 0 ]; then
|
||||
@@ -155,7 +160,11 @@ let
|
||||
echo ""
|
||||
echo " ✓ Build succeeded — a reboot is required to apply this update"
|
||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||
if nixos-rebuild boot --flake /etc/nixos --print-build-logs 2>&1; then
|
||||
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1; then
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
exit 0
|
||||
else
|
||||
@@ -209,7 +218,11 @@ let
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "── Rebuilding system configuration ──────────────"
|
||||
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs 2>&1)
|
||||
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1)
|
||||
SWITCH_RC=$?
|
||||
echo "$SWITCH_OUT"
|
||||
if [ "$SWITCH_RC" -eq 0 ]; then
|
||||
@@ -222,7 +235,11 @@ let
|
||||
echo ""
|
||||
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||
if nixos-rebuild boot --flake /etc/nixos --print-build-logs 2>&1; then
|
||||
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1; then
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot also failed"
|
||||
|
||||
@@ -130,7 +130,7 @@ EOF
|
||||
keyFile = livekitKeyFile;
|
||||
settings = {
|
||||
rtc.use_external_ip = true;
|
||||
rtc.udp_port = "7882-7894";
|
||||
rtc.udp_port = 7882;
|
||||
room.auto_create = false;
|
||||
turn = {
|
||||
enabled = true;
|
||||
@@ -141,9 +141,8 @@ EOF
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
|
||||
networking.firewall.allowedUDPPorts = [ 3478 ];
|
||||
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
|
||||
networking.firewall.allowedUDPPortRanges = [
|
||||
{ from = 7882; to = 7894; }
|
||||
{ from = 30000; to = 40000; }
|
||||
];
|
||||
networking.firewall.allowedTCPPortRanges = [
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
|
||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||
./haven.nix
|
||||
./bip110.nix
|
||||
./element-calling.nix
|
||||
./mempool.nix
|
||||
./bitcoin-core.nix
|
||||
|
||||
Reference in New Issue
Block a user