Compare commits
7
Commits
v1.1.3
...
be5d47cb9c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be5d47cb9c | ||
|
|
ddf87a1c1c | ||
|
|
f0e4c33a5f | ||
|
|
a6ef13688e | ||
|
|
ef1c045e0e | ||
|
|
e27bf0ef1f | ||
|
|
362fa0b36c |
@@ -510,13 +510,15 @@ setup, supported hardware, and Royal Membership.
|
|||||||
|
|
||||||
## For developers
|
## For developers
|
||||||
|
|
||||||
Sovran_SystemsOS combines [NixOS](https://nixos.org), an in-repository
|
Sovran_SystemsOS combines [NixOS](https://nixos.org), the standalone
|
||||||
Bitcoin and Lightning stack, desktop packages from
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) flake for
|
||||||
|
Bitcoin and Lightning, desktop packages from
|
||||||
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
||||||
Sovran Hub. The Bitcoin modules under `modules/bitcoin/` were adapted from
|
Sovran Hub. The Bitcoin and Lightning modules live in the Sovran_Bitcoin
|
||||||
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) and are now maintained
|
repository and are consumed as a flake input. OS-specific Bitcoin customizations
|
||||||
here. Builds no longer import or fetch nix-bitcoin. Legacy `nix-bitcoin.*`
|
(Second_Drive paths, operator user, Hub integration) are bridged by
|
||||||
option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
`modules/sovran-bitcoin-integration.nix`. Legacy `nix-bitcoin.*` option names
|
||||||
|
and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||||
|
|
||||||
### Development workflow
|
### Development workflow
|
||||||
|
|
||||||
@@ -536,8 +538,9 @@ option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
|||||||
|
|
||||||
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
||||||
for declarative, pinned system configuration
|
for declarative, pinned system configuration
|
||||||
- `modules/bitcoin/` for the in-repository Bitcoin and Lightning stack
|
- [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) for the
|
||||||
- `packages/` for Sovran-maintained package definitions and patches
|
Bitcoin and Lightning stack (consumed as a flake input)
|
||||||
|
- `modules/sovran-bitcoin-integration.nix` for OS-specific Bitcoin overrides
|
||||||
- [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the
|
- [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the
|
||||||
Sparrow, Bisq, and Bisq 2 packages
|
Sparrow, Bisq, and Bisq 2 packages
|
||||||
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
|
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
|
||||||
@@ -628,12 +631,11 @@ sudo nixos-rebuild switch --rollback
|
|||||||
| `flake.lock` | Pins dependencies for reproducible builds |
|
| `flake.lock` | Pins dependencies for reproducible builds |
|
||||||
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
||||||
| `modules/` | Core modules, self-hosted services, and optional features |
|
| `modules/` | Core modules, self-hosted services, and optional features |
|
||||||
| `modules/bitcoin/` | In-repository Bitcoin and Lightning service modules |
|
| `modules/sovran-bitcoin-integration.nix` | OS-specific Bitcoin overrides (Second_Drive, operator, Hub wiring) |
|
||||||
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
||||||
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
||||||
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
||||||
| `iso/` | Installer configuration, installer code, and installer assets |
|
| `iso/` | Installer configuration, installer code, and installer assets |
|
||||||
| `packages/` | Sovran-maintained package definitions and patches |
|
|
||||||
| `tests/` | Security and Nix integration checks |
|
| `tests/` | Security and Nix integration checks |
|
||||||
| `assets/` | Documentation images |
|
| `assets/` | Documentation images |
|
||||||
| `custom.template.nix` | Template for local features and service overrides |
|
| `custom.template.nix` | Template for local features and service overrides |
|
||||||
@@ -689,14 +691,12 @@ rebuilds the machine into the selected declarative state.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Core platform: roles, Hub, desktop integration, Caddy, domains, support, remote deployment | `modules/core/` |
|
| Core platform: roles, Hub, desktop integration, Caddy, domains, support, remote deployment | `modules/core/` |
|
||||||
| Shared credentials | `modules/credentials.nix` |
|
| Shared credentials | `modules/credentials.nix` |
|
||||||
| Bitcoin and Lightning stack | `modules/bitcoinecosystem.nix` |
|
| Bitcoin and Lightning stack | [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) flake input + `modules/sovran-bitcoin-integration.nix` |
|
||||||
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
||||||
| Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` |
|
|
||||||
| Matrix Synapse | `modules/synapse.nix` |
|
| Matrix Synapse | `modules/synapse.nix` |
|
||||||
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
||||||
| Optional Haven Nostr relay | `modules/haven.nix` |
|
| Optional Haven Nostr relay | `modules/haven.nix` |
|
||||||
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
|
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
|
||||||
| Optional Mempool explorer | `modules/mempool.nix` |
|
|
||||||
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
|
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
|
||||||
|
|
||||||
Feature availability and defaults may change as Sovran_SystemsOS develops.
|
Feature availability and defaults may change as Sovran_SystemsOS develops.
|
||||||
|
|||||||
+7
-5
@@ -35,12 +35,14 @@ external networks and are outside a “fully offline” model.
|
|||||||
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
||||||
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
||||||
|
|
||||||
### In-repository Bitcoin stack
|
### Bitcoin stack
|
||||||
|
|
||||||
Bitcoin and Lightning modules are maintained under `modules/bitcoin/`. They were
|
Bitcoin and Lightning modules are maintained in the standalone
|
||||||
adapted from nix-bitcoin, but Sovran builds do not import or fetch nix-bitcoin.
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||||
The `nix-bitcoin.*` option namespace and `/etc/nix-bitcoin-secrets` path remain
|
and consumed as a flake input. OS-specific customizations (Second_Drive paths,
|
||||||
only for upgrade compatibility.
|
operator user, Hub integration) are bridged by
|
||||||
|
`modules/sovran-bitcoin-integration.nix`. The `nix-bitcoin.*` option namespace
|
||||||
|
and `/etc/nix-bitcoin-secrets` path remain only for upgrade compatibility.
|
||||||
|
|
||||||
### Supply chain and integrity
|
### Supply chain and integrity
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
## nix-bitcoin
|
## nix-bitcoin
|
||||||
|
|
||||||
Portions of `modules/bitcoin/` and selected package definitions were adapted
|
The Bitcoin and Lightning modules were originally adapted from
|
||||||
from [fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
[fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
||||||
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||||
The current Sovran build does not import or fetch nix-bitcoin.
|
They now live in the standalone
|
||||||
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||||
|
and are consumed by Sovran_SystemsOS as a flake input. The current Sovran build
|
||||||
|
does not import or fetch nix-bitcoin.
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
"""
|
|
||||||
Structured audit logging for NWC wallet operations.
|
|
||||||
|
|
||||||
Writes append-only JSON lines to /var/log/sovran-nwc-audit.log.
|
|
||||||
Log file is owned by albyhub:albyhub with mode 0600.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
AUDIT_LOG_PATH = "/var/log/sovran-nwc-audit.log"
|
|
||||||
_AUDIT_LOCK = threading.Lock()
|
|
||||||
_initialized = False
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_log_file() -> None:
|
|
||||||
"""Ensure audit log file exists with correct permissions."""
|
|
||||||
global _initialized
|
|
||||||
if _initialized:
|
|
||||||
return
|
|
||||||
with _AUDIT_LOCK:
|
|
||||||
if _initialized:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
# Create directory if needed
|
|
||||||
os.makedirs(os.path.dirname(AUDIT_LOG_PATH), exist_ok=True)
|
|
||||||
# Create file if it doesn't exist
|
|
||||||
if not os.path.exists(AUDIT_LOG_PATH):
|
|
||||||
with open(AUDIT_LOG_PATH, "w") as f:
|
|
||||||
pass
|
|
||||||
# Set restrictive permissions
|
|
||||||
os.chmod(AUDIT_LOG_PATH, 0o600)
|
|
||||||
# Try to set ownership to albyhub user (best effort)
|
|
||||||
try:
|
|
||||||
import pwd
|
|
||||||
import grp
|
|
||||||
albyhub_uid = pwd.getpwnam("albyhub").pw_uid
|
|
||||||
albyhub_gid = grp.getgrnam("albyhub").gr_gid
|
|
||||||
os.chown(AUDIT_LOG_PATH, albyhub_uid, albyhub_gid)
|
|
||||||
except Exception:
|
|
||||||
pass # Best effort; may not have permissions
|
|
||||||
_initialized = True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to initialize audit log: %s", exc)
|
|
||||||
|
|
||||||
|
|
||||||
def audit_log(event: str, **fields: Any) -> None:
|
|
||||||
"""Write a structured audit log entry.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event type identifier (e.g., "wallet_created", "invoice_issued")
|
|
||||||
**fields: Additional key-value fields to include in the log entry
|
|
||||||
"""
|
|
||||||
_ensure_log_file()
|
|
||||||
|
|
||||||
entry = {
|
|
||||||
"ts": time.time(),
|
|
||||||
"event": event,
|
|
||||||
**fields,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with _AUDIT_LOCK:
|
|
||||||
with open(AUDIT_LOG_PATH, "a") as f:
|
|
||||||
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Failed to write audit log: %s", exc)
|
|
||||||
@@ -1,881 +0,0 @@
|
|||||||
"""
|
|
||||||
Alby Hub manager — shared backend for Lightning Wallet Connections API and recovery CLI.
|
|
||||||
|
|
||||||
Interfaces with the local Alby Hub instance at
|
|
||||||
http://127.0.0.1:18080 by default (override with NWC_ALBY_HUB_API_BASE).
|
|
||||||
All sensitive values (passwords, bearer tokens, pairing URIs, macaroon
|
|
||||||
contents, Nostr private keys) are redacted from any exception messages
|
|
||||||
or log output.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from . import nwc_audit as _audit_mod
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Constants ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
DEFAULT_API_BASE = os.environ.get(
|
|
||||||
"NWC_ALBY_HUB_API_BASE",
|
|
||||||
"http://127.0.0.1:18080",
|
|
||||||
)
|
|
||||||
DEFAULT_UNLOCK_PASSWORD_FILE = "/var/lib/albyhub/unlock-password"
|
|
||||||
DEFAULT_MACAROON_FILE = os.environ.get(
|
|
||||||
"NWC_LND_MACAROON_FILE", "/run/lnd/albyhub.macaroon"
|
|
||||||
)
|
|
||||||
DEFAULT_LND_ADDRESS = os.environ.get("NWC_LND_ADDRESS", "127.0.0.1:10009")
|
|
||||||
DEFAULT_LND_CERT_FILE = os.environ.get("NWC_LND_CERT_FILE", "/var/lib/lnd/tls.cert")
|
|
||||||
DEFAULT_LND_SOCKET = "/run/lnd/lnd.socket"
|
|
||||||
|
|
||||||
LNURL_DESCRIPTION_DEFAULT = "Pay via Lightning"
|
|
||||||
NWC_MIN_SENDABLE_MSAT = 1000
|
|
||||||
NWC_MAX_SENDABLE_MSAT = 1_000_000_000
|
|
||||||
|
|
||||||
# Metadata key used to mark managed isolated wallets
|
|
||||||
_MANAGED_APP_STORE_ID = "uncle-jim"
|
|
||||||
_MANAGED_META_KEY = "app_store_app_id"
|
|
||||||
|
|
||||||
RECEIVE_ONLY_SCOPES = [
|
|
||||||
"get_info",
|
|
||||||
"get_balance",
|
|
||||||
"make_invoice",
|
|
||||||
"lookup_invoice",
|
|
||||||
"list_transactions",
|
|
||||||
"notifications",
|
|
||||||
]
|
|
||||||
|
|
||||||
LIMITED_SEND_SCOPES = RECEIVE_ONLY_SCOPES + ["pay_invoice"]
|
|
||||||
|
|
||||||
# ── Exceptions ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class AlbyHubError(Exception):
|
|
||||||
"""Base error from the Alby Hub manager.
|
|
||||||
|
|
||||||
The message string is safe to surface to the user — it never
|
|
||||||
contains raw secret material.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, code: str, message: str) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.code = code
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"[{self.code}] {self.args[0]}"
|
|
||||||
|
|
||||||
|
|
||||||
class AlbyHubHttpError(AlbyHubError):
|
|
||||||
def __init__(self, status_code: int, message: str) -> None:
|
|
||||||
super().__init__(f"http_{status_code}", message)
|
|
||||||
self.status_code = status_code
|
|
||||||
|
|
||||||
|
|
||||||
# ── Manager class ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class AlbyHubManager:
|
|
||||||
"""Thread-safe manager for Alby Hub API operations."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
api_base: str = DEFAULT_API_BASE,
|
|
||||||
unlock_password_file: str = DEFAULT_UNLOCK_PASSWORD_FILE,
|
|
||||||
macaroon_file: str = DEFAULT_MACAROON_FILE,
|
|
||||||
lnd_address: str = DEFAULT_LND_ADDRESS,
|
|
||||||
lnd_cert_file: str = DEFAULT_LND_CERT_FILE,
|
|
||||||
) -> None:
|
|
||||||
self.api_base = api_base.rstrip("/")
|
|
||||||
self.unlock_password_file = unlock_password_file
|
|
||||||
self.macaroon_file = macaroon_file
|
|
||||||
self.lnd_address = lnd_address
|
|
||||||
self.lnd_cert_file = lnd_cert_file
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._token: str | None = None
|
|
||||||
|
|
||||||
# ── Low-level HTTP ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
body: dict | None = None,
|
|
||||||
token: str | None = None,
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> dict:
|
|
||||||
"""Make a raw HTTP request to the local Alby Hub API.
|
|
||||||
|
|
||||||
Returns the parsed JSON response body.
|
|
||||||
Raises AlbyHubHttpError on non-2xx responses.
|
|
||||||
Secrets in response bodies are never included in raised exceptions.
|
|
||||||
"""
|
|
||||||
url = f"{self.api_base}{path}"
|
|
||||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
||||||
headers: dict[str, str] = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json",
|
|
||||||
}
|
|
||||||
if token:
|
|
||||||
headers["Authorization"] = "Bearer " + token
|
|
||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
||||||
raw = resp.read()
|
|
||||||
if not raw:
|
|
||||||
return {}
|
|
||||||
return json.loads(raw)
|
|
||||||
except urllib.error.HTTPError as exc:
|
|
||||||
code = exc.code
|
|
||||||
# Read and discard the body — we do NOT include it in the exception
|
|
||||||
try:
|
|
||||||
exc.read()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise AlbyHubHttpError(code, f"Hub API {method} {path} returned HTTP {code}") from None
|
|
||||||
except (urllib.error.URLError, TimeoutError, OSError):
|
|
||||||
raise AlbyHubError(
|
|
||||||
"hub_unreachable",
|
|
||||||
f"Hub API {method} {path} is unreachable",
|
|
||||||
) from None
|
|
||||||
|
|
||||||
def _authenticated_request(
|
|
||||||
self,
|
|
||||||
method: str,
|
|
||||||
path: str,
|
|
||||||
body: dict | None = None,
|
|
||||||
timeout: int = 30,
|
|
||||||
) -> dict:
|
|
||||||
"""Make an authenticated request; retry once with a fresh token on 401/403."""
|
|
||||||
token = self.ensure_ready()
|
|
||||||
try:
|
|
||||||
return self._request(method, path, body=body, token=token, timeout=timeout)
|
|
||||||
except AlbyHubHttpError as exc:
|
|
||||||
if exc.status_code in (401, 403):
|
|
||||||
with self._lock:
|
|
||||||
self._token = None
|
|
||||||
token = self.ensure_ready()
|
|
||||||
return self._request(method, path, body=body, token=token, timeout=timeout)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _paginate(self, path_template: str, page_size: int = 100) -> list[dict]:
|
|
||||||
"""Paginate a list API completely, collecting all items.
|
|
||||||
|
|
||||||
``path_template`` must contain ``{limit}`` and ``{offset}`` placeholders.
|
|
||||||
"""
|
|
||||||
token = self.ensure_ready()
|
|
||||||
offset = 0
|
|
||||||
results: list[dict] = []
|
|
||||||
while True:
|
|
||||||
path = path_template.format(limit=page_size, offset=offset)
|
|
||||||
page = self._request("GET", path, token=token)
|
|
||||||
# Alby Hub returns apps at the top level or under "apps"/"transactions"
|
|
||||||
total_count: int | None = None
|
|
||||||
if isinstance(page, list):
|
|
||||||
items = page
|
|
||||||
elif isinstance(page, dict):
|
|
||||||
items = page.get("apps") or page.get("transactions") or []
|
|
||||||
if page.get("totalCount") is not None:
|
|
||||||
total_count = int(page.get("totalCount"))
|
|
||||||
else:
|
|
||||||
items = []
|
|
||||||
if not isinstance(items, list):
|
|
||||||
break
|
|
||||||
results.extend(items)
|
|
||||||
if total_count is not None:
|
|
||||||
if len(results) >= total_count:
|
|
||||||
break
|
|
||||||
elif len(items) < page_size:
|
|
||||||
break
|
|
||||||
offset += page_size
|
|
||||||
return results
|
|
||||||
|
|
||||||
# ── Audit log helper ───────────────────────────────────────────
|
|
||||||
|
|
||||||
def _audit(self, event: str, **fields: Any) -> None:
|
|
||||||
"""Emit structured audit log entry."""
|
|
||||||
_audit_mod.audit_log(event, **fields)
|
|
||||||
|
|
||||||
# ── Startup / Auth ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _read_unlock_password(self) -> str:
|
|
||||||
try:
|
|
||||||
with open(self.unlock_password_file, "r") as fh:
|
|
||||||
return fh.read().strip()
|
|
||||||
except OSError as exc:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"unlock_password_unavailable",
|
|
||||||
"Cannot read Alby Hub unlock password",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
def _wait_for_file(self, path: str, timeout: int = 120) -> None:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if os.path.exists(path):
|
|
||||||
return
|
|
||||||
time.sleep(2)
|
|
||||||
raise AlbyHubError(
|
|
||||||
"dependency_unavailable",
|
|
||||||
f"Timed out waiting for required file: {path}",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _wait_for_hub_api(self, timeout: int = 120) -> None:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
self._request("GET", "/api/info", timeout=5)
|
|
||||||
return
|
|
||||||
except AlbyHubError:
|
|
||||||
pass
|
|
||||||
time.sleep(3)
|
|
||||||
raise AlbyHubError("hub_unavailable", "Timed out waiting for Alby Hub API")
|
|
||||||
|
|
||||||
def _hub_setup(self, password: str) -> None:
|
|
||||||
"""Perform /api/setup idempotently."""
|
|
||||||
try:
|
|
||||||
info = self._request("GET", "/api/info", timeout=10)
|
|
||||||
if info.get("setupCompleted"):
|
|
||||||
return
|
|
||||||
except AlbyHubError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
setup_body = {
|
|
||||||
"backendType": "LND",
|
|
||||||
"unlockPassword": password,
|
|
||||||
"lndAddress": self.lnd_address,
|
|
||||||
"lndCertFile": self.lnd_cert_file,
|
|
||||||
"lndMacaroonFile": self.macaroon_file,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
self._request("POST", "/api/setup", body=setup_body, timeout=30)
|
|
||||||
except AlbyHubHttpError as exc:
|
|
||||||
if exc.status_code == 409:
|
|
||||||
return # already setup
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _obtain_token(self, password: str) -> str:
|
|
||||||
info = self._request("GET", "/api/info", timeout=10)
|
|
||||||
if info.get("running"):
|
|
||||||
resp = self._request(
|
|
||||||
"POST",
|
|
||||||
"/api/unlock",
|
|
||||||
body={
|
|
||||||
"unlockPassword": password,
|
|
||||||
"permission": "full",
|
|
||||||
},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
resp = self._request(
|
|
||||||
"POST",
|
|
||||||
"/api/start",
|
|
||||||
body={"unlockPassword": password},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
token = (
|
|
||||||
resp.get("token")
|
|
||||||
or resp.get("accessToken")
|
|
||||||
or resp.get("access_token")
|
|
||||||
)
|
|
||||||
if not token or not isinstance(token, str):
|
|
||||||
raise AlbyHubError("auth_failed", "Alby Hub auth response missing token")
|
|
||||||
return token
|
|
||||||
|
|
||||||
def _wait_for_node_ready(self, token: str, timeout: int = 120) -> None:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
try:
|
|
||||||
status = self._request(
|
|
||||||
"GET", "/api/node/status", token=token, timeout=10
|
|
||||||
)
|
|
||||||
if status.get("isReady") or status.get("running") or status.get("online"):
|
|
||||||
return
|
|
||||||
except AlbyHubError:
|
|
||||||
pass
|
|
||||||
time.sleep(3)
|
|
||||||
raise AlbyHubError("node_not_ready", "Timed out waiting for Alby Hub node to be ready")
|
|
||||||
|
|
||||||
def ensure_ready(self) -> str:
|
|
||||||
"""Ensure Alby Hub is set up, unlocked, and authenticated.
|
|
||||||
|
|
||||||
Returns a valid bearer token. Caches it and uses a lock to
|
|
||||||
prevent concurrent setup races.
|
|
||||||
"""
|
|
||||||
with self._lock:
|
|
||||||
if self._token:
|
|
||||||
return self._token
|
|
||||||
|
|
||||||
password = self._read_unlock_password()
|
|
||||||
self._wait_for_file(self.macaroon_file, timeout=120)
|
|
||||||
self._wait_for_hub_api(timeout=120)
|
|
||||||
self._hub_setup(password)
|
|
||||||
token = self._obtain_token(password)
|
|
||||||
self._wait_for_node_ready(token, timeout=120)
|
|
||||||
self._token = token
|
|
||||||
return token
|
|
||||||
|
|
||||||
# ── App isolation helpers ──────────────────────────────────────
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_metadata(raw: Any) -> dict:
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
return raw
|
|
||||||
if isinstance(raw, str):
|
|
||||||
try:
|
|
||||||
result = json.loads(raw)
|
|
||||||
if isinstance(result, dict):
|
|
||||||
return result
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _is_managed_app(self, app: dict) -> bool:
|
|
||||||
meta = self._parse_metadata(app.get("metadata"))
|
|
||||||
alias = str(meta.get("lnurl_alias", "")).strip().lower()
|
|
||||||
return (
|
|
||||||
meta.get(_MANAGED_META_KEY) == _MANAGED_APP_STORE_ID
|
|
||||||
and bool(alias)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _app_to_wallet_meta(self, app: dict, domain: str | None) -> dict:
|
|
||||||
meta = self._parse_metadata(app.get("metadata"))
|
|
||||||
alias = meta.get("lnurl_alias", "")
|
|
||||||
address = f"{alias}@{domain}" if alias and domain else None
|
|
||||||
|
|
||||||
scopes = app.get("scopes") or []
|
|
||||||
access_preset = (
|
|
||||||
"send_receive_limited" if "pay_invoice" in scopes else "receive_only"
|
|
||||||
)
|
|
||||||
|
|
||||||
balance_msat = int(app.get("balanceMsat", 0) or 0)
|
|
||||||
balance_sats = balance_msat // 1000
|
|
||||||
dust_msat = balance_msat % 1000
|
|
||||||
|
|
||||||
spending_limit_sats: int | None = None
|
|
||||||
max_amount = app.get("maxAmountSat") or 0
|
|
||||||
if max_amount:
|
|
||||||
spending_limit_sats = int(max_amount)
|
|
||||||
|
|
||||||
# Count pending transactions from the budget or transactions list
|
|
||||||
pending_txs = int(app.get("pendingTransactionsCount", 0) or 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": str(app.get("id", "")),
|
|
||||||
"pubkey": app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or "",
|
|
||||||
"name": app.get("name", ""),
|
|
||||||
"alias": alias,
|
|
||||||
"lightning_address": address,
|
|
||||||
"access_preset": access_preset,
|
|
||||||
"spending_limit_sats": spending_limit_sats,
|
|
||||||
"balance_sats": balance_sats,
|
|
||||||
"dust_msat": dust_msat,
|
|
||||||
"pending_transactions": pending_txs,
|
|
||||||
"created_at": app.get("createdAt") or app.get("created_at"),
|
|
||||||
"min_sendable_msat": int(
|
|
||||||
meta.get("lnurl_min_sendable_msat", NWC_MIN_SENDABLE_MSAT)
|
|
||||||
),
|
|
||||||
"max_sendable_msat": int(
|
|
||||||
meta.get("lnurl_max_sendable_msat", NWC_MAX_SENDABLE_MSAT)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _all_managed_apps(self) -> list[dict]:
|
|
||||||
apps = self._paginate("/api/apps?limit={limit}&offset={offset}&order_by=created_at")
|
|
||||||
return [a for a in apps if a.get("isolated") and self._is_managed_app(a)]
|
|
||||||
|
|
||||||
def _find_managed_app(self, identifier: str) -> dict | None:
|
|
||||||
needle = identifier.strip().lower()
|
|
||||||
for app in self._all_managed_apps():
|
|
||||||
if str(app.get("id", "")).lower() == needle:
|
|
||||||
return app
|
|
||||||
pubkey = (
|
|
||||||
app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
|
||||||
).lower()
|
|
||||||
if pubkey == needle:
|
|
||||||
return app
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ── Public API ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def list_wallets(self, domain: str | None = None) -> list[dict]:
|
|
||||||
"""Return all managed isolated app wallets (no secrets)."""
|
|
||||||
wallets = []
|
|
||||||
for app in self._all_managed_apps():
|
|
||||||
app_copy = dict(app)
|
|
||||||
app_copy["pendingTransactionsCount"] = len(self._get_app_pending_txs(int(app["id"])))
|
|
||||||
wallets.append(self._app_to_wallet_meta(app_copy, domain))
|
|
||||||
return wallets
|
|
||||||
|
|
||||||
def create_wallet(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
alias: str,
|
|
||||||
access_preset: str,
|
|
||||||
spending_limit_sats: int | None,
|
|
||||||
domain: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Create a new isolated Alby Hub app (wallet connection).
|
|
||||||
|
|
||||||
Returns a dict containing:
|
|
||||||
``wallet`` — safe metadata (no secrets)
|
|
||||||
``pairing_uri`` — real Alby Hub pairingUri (returned ONCE)
|
|
||||||
``result`` — creation status report
|
|
||||||
"""
|
|
||||||
# Validate uniqueness
|
|
||||||
managed = self._all_managed_apps()
|
|
||||||
for a in managed:
|
|
||||||
meta = self._parse_metadata(a.get("metadata"))
|
|
||||||
if meta.get("lnurl_alias", "").lower() == alias.lower():
|
|
||||||
raise AlbyHubError(
|
|
||||||
"alias_exists", "That Lightning Address alias is already in use."
|
|
||||||
)
|
|
||||||
if (a.get("name") or "").lower() == name.lower():
|
|
||||||
raise AlbyHubError(
|
|
||||||
"wallet_name_exists",
|
|
||||||
"That Wallet Connection name already exists.",
|
|
||||||
)
|
|
||||||
|
|
||||||
scopes = (
|
|
||||||
LIMITED_SEND_SCOPES
|
|
||||||
if access_preset == "send_receive_limited"
|
|
||||||
else RECEIVE_ONLY_SCOPES
|
|
||||||
)
|
|
||||||
max_amount = (
|
|
||||||
spending_limit_sats
|
|
||||||
if access_preset == "send_receive_limited" and spending_limit_sats
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
create_body: dict = {
|
|
||||||
"name": name,
|
|
||||||
"scopes": scopes,
|
|
||||||
"isolated": True,
|
|
||||||
"budgetRenewal": "never",
|
|
||||||
"maxAmountSat": max_amount,
|
|
||||||
"metadata": {
|
|
||||||
_MANAGED_META_KEY: _MANAGED_APP_STORE_ID,
|
|
||||||
"lnurl_alias": alias,
|
|
||||||
"lnurl_description": LNURL_DESCRIPTION_DEFAULT,
|
|
||||||
"lnurl_min_sendable_msat": NWC_MIN_SENDABLE_MSAT,
|
|
||||||
"lnurl_max_sendable_msat": NWC_MAX_SENDABLE_MSAT,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = self._authenticated_request("POST", "/api/apps", body=create_body)
|
|
||||||
pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or ""
|
|
||||||
app_id = resp.get("id")
|
|
||||||
|
|
||||||
# Fetch full app details for accurate metadata
|
|
||||||
app_detail: dict | None = None
|
|
||||||
if app_id is not None:
|
|
||||||
try:
|
|
||||||
app_detail = self._authenticated_request(
|
|
||||||
"GET", f"/api/v2/apps/{app_id}"
|
|
||||||
)
|
|
||||||
except AlbyHubError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if app_detail is None:
|
|
||||||
# Fallback: search recent apps for the one we just created
|
|
||||||
updated = self._all_managed_apps()
|
|
||||||
for a in updated:
|
|
||||||
if str(a.get("id", "")) == str(app_id):
|
|
||||||
app_detail = a
|
|
||||||
break
|
|
||||||
|
|
||||||
wallet_meta = self._app_to_wallet_meta(app_detail or resp, domain)
|
|
||||||
|
|
||||||
# Initial internal transfer for limited wallets
|
|
||||||
funding_result: dict = {"attempted": False, "success": False}
|
|
||||||
if (
|
|
||||||
access_preset == "send_receive_limited"
|
|
||||||
and spending_limit_sats
|
|
||||||
and app_id is not None
|
|
||||||
):
|
|
||||||
funding_result["attempted"] = True
|
|
||||||
try:
|
|
||||||
self._authenticated_request(
|
|
||||||
"POST",
|
|
||||||
"/api/transfers",
|
|
||||||
body={
|
|
||||||
"toAppId": int(app_id),
|
|
||||||
"amountSat": spending_limit_sats,
|
|
||||||
"description": f"Initial funding for {name}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
funding_result["success"] = True
|
|
||||||
except AlbyHubError as exc:
|
|
||||||
funding_result["error"] = exc.code
|
|
||||||
funding_result["message"] = (
|
|
||||||
"The wallet was created successfully and the NWC connection secret is shown "
|
|
||||||
"above, but initial funding failed. Save the NWC secret now. "
|
|
||||||
"Do not recreate this wallet."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log: wallet created
|
|
||||||
self._audit(
|
|
||||||
"wallet_created",
|
|
||||||
wallet_id=str(app_id) if app_id else "unknown",
|
|
||||||
name=name,
|
|
||||||
alias=alias,
|
|
||||||
access_preset=access_preset,
|
|
||||||
spending_limit_sats=spending_limit_sats,
|
|
||||||
lightning_address=wallet_meta.get("lightning_address"),
|
|
||||||
funding_attempted=funding_result["attempted"],
|
|
||||||
funding_success=funding_result["success"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"wallet": wallet_meta,
|
|
||||||
"pairing_uri": pairing_uri, # returned once on create only
|
|
||||||
"result": {
|
|
||||||
"wallet_created": True,
|
|
||||||
"secret_created": bool(pairing_uri),
|
|
||||||
"lightning_address_registered": bool(alias and domain),
|
|
||||||
"funding": funding_result,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_app_balance_msat(self, app: dict) -> int:
|
|
||||||
return int(app.get("balanceMsat", 0) or 0)
|
|
||||||
|
|
||||||
def _get_app_pending_txs(self, app_id: int) -> list[dict]:
|
|
||||||
txs = self._paginate(
|
|
||||||
f"/api/transactions?appId={app_id}&limit={{limit}}&offset={{offset}}"
|
|
||||||
)
|
|
||||||
return [
|
|
||||||
t for t in txs if str(t.get("state", "")).lower() == "pending"
|
|
||||||
]
|
|
||||||
|
|
||||||
def drain_wallet(self, identifier: str) -> dict:
|
|
||||||
"""Drain all whole-satoshi funds from an isolated app to the primary wallet.
|
|
||||||
|
|
||||||
Returns ``{"ok": True, "drained_sats": N, "dust_msat": M}``.
|
|
||||||
Raises AlbyHubError on rejection or failure.
|
|
||||||
"""
|
|
||||||
app = self._find_managed_app(identifier)
|
|
||||||
if app is None:
|
|
||||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
|
||||||
|
|
||||||
app_id = int(app["id"])
|
|
||||||
balance_msat = self._get_app_balance_msat(app)
|
|
||||||
|
|
||||||
if balance_msat < 0:
|
|
||||||
raise AlbyHubError("negative_balance", "Wallet has a negative balance.")
|
|
||||||
|
|
||||||
pending = self._get_app_pending_txs(app_id)
|
|
||||||
if pending:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"pending_transactions",
|
|
||||||
"Wallet has pending transactions and cannot be drained.",
|
|
||||||
)
|
|
||||||
|
|
||||||
transferable_msat = (balance_msat // 1000) * 1000
|
|
||||||
expected_dust_msat = balance_msat - transferable_msat
|
|
||||||
|
|
||||||
if transferable_msat == 0:
|
|
||||||
return {"ok": True, "drained_sats": 0, "dust_msat": expected_dust_msat}
|
|
||||||
|
|
||||||
# Save original permissions
|
|
||||||
original_scopes = list(app.get("scopes") or [])
|
|
||||||
original_max = app.get("maxAmountSat") or 0
|
|
||||||
original_renewal = app.get("budgetRenewal") or "never"
|
|
||||||
|
|
||||||
# Temporarily grant pay_invoice scope with sufficient budget
|
|
||||||
app_pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
|
||||||
if not app_pubkey:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"app_pubkey_missing",
|
|
||||||
"Cannot drain app: app public key not available.",
|
|
||||||
)
|
|
||||||
|
|
||||||
patch_body = {
|
|
||||||
"scopes": sorted(set(original_scopes) | {"pay_invoice"}),
|
|
||||||
"maxAmountSat": 0,
|
|
||||||
"budgetRenewal": "never",
|
|
||||||
}
|
|
||||||
self._authenticated_request("PATCH", f"/api/apps/{app_pubkey}", body=patch_body)
|
|
||||||
|
|
||||||
drain_error: AlbyHubError | None = None
|
|
||||||
drained_sats = 0
|
|
||||||
try:
|
|
||||||
self._authenticated_request(
|
|
||||||
"POST",
|
|
||||||
"/api/transfers",
|
|
||||||
body={
|
|
||||||
"fromAppId": app_id,
|
|
||||||
"amountMsat": transferable_msat,
|
|
||||||
"description": f"Drain isolated subwallet {app.get('name', '')}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
drained_sats = transferable_msat // 1000
|
|
||||||
except AlbyHubError as exc:
|
|
||||||
drain_error = exc
|
|
||||||
finally:
|
|
||||||
# Restore original permissions whether drain succeeded or not
|
|
||||||
restore_body = {
|
|
||||||
"scopes": original_scopes,
|
|
||||||
"maxAmountSat": original_max,
|
|
||||||
"budgetRenewal": original_renewal,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
self._authenticated_request(
|
|
||||||
"PATCH", f"/api/apps/{app_pubkey}", body=restore_body
|
|
||||||
)
|
|
||||||
except AlbyHubError:
|
|
||||||
pass # best-effort restore; don't mask the original error
|
|
||||||
|
|
||||||
if drain_error is not None:
|
|
||||||
raise drain_error
|
|
||||||
|
|
||||||
# Verify remaining balance equals expected dust
|
|
||||||
refreshed = self._authenticated_request("GET", f"/api/v2/apps/{app_id}")
|
|
||||||
remaining_msat = self._get_app_balance_msat(refreshed)
|
|
||||||
if remaining_msat != expected_dust_msat:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"drain_incomplete",
|
|
||||||
"Drain verification failed: final balance does not match expected dust.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log: wallet drained
|
|
||||||
self._audit(
|
|
||||||
"wallet_drained",
|
|
||||||
wallet_id=str(app_id),
|
|
||||||
name=app.get("name", ""),
|
|
||||||
alias=app.get("alias", ""),
|
|
||||||
drained_sats=drained_sats,
|
|
||||||
dust_msat=expected_dust_msat,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"drained_sats": drained_sats,
|
|
||||||
"dust_msat": expected_dust_msat,
|
|
||||||
"remaining_msat": remaining_msat,
|
|
||||||
}
|
|
||||||
|
|
||||||
def delete_wallet(self, identifier: str) -> dict:
|
|
||||||
"""Safely drain and delete an isolated app.
|
|
||||||
|
|
||||||
Returns ``{"ok": True, "drained_sats": N}``.
|
|
||||||
"""
|
|
||||||
app = self._find_managed_app(identifier)
|
|
||||||
if app is None:
|
|
||||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
|
||||||
|
|
||||||
app_id = int(app["id"])
|
|
||||||
|
|
||||||
pending = self._get_app_pending_txs(app_id)
|
|
||||||
if pending:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"pending_transactions",
|
|
||||||
"Wallet has pending transactions and cannot be deleted.",
|
|
||||||
)
|
|
||||||
|
|
||||||
drain_result = self.drain_wallet(identifier)
|
|
||||||
|
|
||||||
# Verify no transferable balance remains
|
|
||||||
refreshed = self._authenticated_request("GET", f"/api/v2/apps/{app_id}")
|
|
||||||
remaining_msat = self._get_app_balance_msat(refreshed)
|
|
||||||
if remaining_msat < 0:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"negative_balance",
|
|
||||||
"Wallet has a negative final balance and cannot be deleted.",
|
|
||||||
)
|
|
||||||
if remaining_msat >= 1000:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"drain_incomplete",
|
|
||||||
f"Drain verification failed: funds still remain.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete by app pubkey
|
|
||||||
pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
|
||||||
if not pubkey:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"app_pubkey_missing",
|
|
||||||
"Cannot delete app: nostr pubkey not available.",
|
|
||||||
)
|
|
||||||
self._authenticated_request(
|
|
||||||
"DELETE",
|
|
||||||
f"/api/apps/{urllib.parse.quote(pubkey, safe='')}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log: wallet deleted
|
|
||||||
self._audit(
|
|
||||||
"wallet_deleted",
|
|
||||||
wallet_id=str(app_id),
|
|
||||||
name=app.get("name", ""),
|
|
||||||
alias=app.get("alias", ""),
|
|
||||||
drained_sats=drain_result.get("drained_sats", 0),
|
|
||||||
dust_msat=remaining_msat,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"drained_sats": drain_result.get("drained_sats", 0),
|
|
||||||
"dust_msat": remaining_msat,
|
|
||||||
}
|
|
||||||
|
|
||||||
def issue_invoice(
|
|
||||||
self, app_id: int, amount_msat: int, description: str = ""
|
|
||||||
) -> str:
|
|
||||||
"""Create an LND invoice attributed to a specific isolated app.
|
|
||||||
|
|
||||||
Returns a valid BOLT11 invoice string.
|
|
||||||
Raises AlbyHubError if the Hub returns an invalid or misattributed invoice.
|
|
||||||
"""
|
|
||||||
resp = self._authenticated_request(
|
|
||||||
"POST",
|
|
||||||
"/api/invoices",
|
|
||||||
body={
|
|
||||||
"amountMsat": amount_msat,
|
|
||||||
"description": description or LNURL_DESCRIPTION_DEFAULT,
|
|
||||||
"appId": app_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
invoice: str = resp.get("invoice") or ""
|
|
||||||
returned_app_id = resp.get("appId")
|
|
||||||
|
|
||||||
if not invoice:
|
|
||||||
raise AlbyHubError("invoice_creation_failed", "Hub returned empty invoice.")
|
|
||||||
|
|
||||||
# Require a valid BOLT11 prefix (mainnet, testnet, signet, regtest)
|
|
||||||
if not re.match(r"^ln", invoice, re.IGNORECASE):
|
|
||||||
raise AlbyHubError(
|
|
||||||
"invalid_invoice", "Hub returned a non-BOLT11 invoice string."
|
|
||||||
)
|
|
||||||
|
|
||||||
if returned_app_id is None or int(returned_app_id) != app_id:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"invoice_attribution_failed",
|
|
||||||
"Invoice attribution mismatch: returned appId does not match.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Audit log: invoice issued via API
|
|
||||||
self._audit(
|
|
||||||
"invoice_issued",
|
|
||||||
app_id=app_id,
|
|
||||||
amount_msat=amount_msat,
|
|
||||||
amount_sat=amount_msat // 1000,
|
|
||||||
invoice_prefix=invoice[:50] + "..." if len(invoice) > 50 else invoice,
|
|
||||||
)
|
|
||||||
|
|
||||||
return invoice
|
|
||||||
|
|
||||||
def find_app_by_alias(self, alias: str) -> dict | None:
|
|
||||||
"""Find a managed isolated app by its ``lnurl_alias`` metadata field."""
|
|
||||||
alias_lower = alias.strip().lower()
|
|
||||||
for a in self._all_managed_apps():
|
|
||||||
meta = self._parse_metadata(a.get("metadata"))
|
|
||||||
if meta.get("lnurl_alias", "").lower() == alias_lower:
|
|
||||||
return a
|
|
||||||
return None
|
|
||||||
|
|
||||||
def rotate_wallet_secret(self, identifier: str) -> dict:
|
|
||||||
"""Rotate the NWC pairing secret for a wallet connection.
|
|
||||||
|
|
||||||
Revokes the old Nostr key and generates a new pairing URI.
|
|
||||||
Returns the new pairing URI (shown ONCE).
|
|
||||||
"""
|
|
||||||
app = self._find_managed_app(identifier)
|
|
||||||
if app is None:
|
|
||||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
|
||||||
|
|
||||||
app_id = int(app["id"])
|
|
||||||
app_pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
|
||||||
if not app_pubkey:
|
|
||||||
raise AlbyHubError(
|
|
||||||
"app_pubkey_missing",
|
|
||||||
"Cannot rotate secret: app public key not available.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Call Alby Hub's rotate secret endpoint (if available)
|
|
||||||
# Alby Hub may not have this endpoint yet; fall back to re-creating the app
|
|
||||||
# For now, we'll delete and re-create with same metadata
|
|
||||||
# This is a safe operation since we drain first
|
|
||||||
name = app.get("name", "")
|
|
||||||
alias = app.get("alias", "")
|
|
||||||
scopes = app.get("scopes") or []
|
|
||||||
max_amount = app.get("maxAmountSat") or 0
|
|
||||||
metadata = app.get("metadata") or {}
|
|
||||||
|
|
||||||
# Drain first
|
|
||||||
self.drain_wallet(identifier)
|
|
||||||
|
|
||||||
# Delete old app
|
|
||||||
self._authenticated_request(
|
|
||||||
"DELETE",
|
|
||||||
f"/api/apps/{urllib.parse.quote(app_pubkey, safe='')}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new app with same parameters
|
|
||||||
create_body: dict = {
|
|
||||||
"name": name,
|
|
||||||
"scopes": scopes,
|
|
||||||
"isolated": True,
|
|
||||||
"budgetRenewal": "never",
|
|
||||||
"maxAmountSat": max_amount,
|
|
||||||
"metadata": metadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp = self._authenticated_request("POST", "/api/apps", body=create_body)
|
|
||||||
new_pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or ""
|
|
||||||
new_app_id = resp.get("id")
|
|
||||||
|
|
||||||
# Audit log: secret rotated
|
|
||||||
self._audit(
|
|
||||||
"wallet_secret_rotated",
|
|
||||||
old_wallet_id=str(app_id),
|
|
||||||
new_wallet_id=str(new_app_id) if new_app_id else "unknown",
|
|
||||||
name=name,
|
|
||||||
alias=alias,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"wallet_id": str(new_app_id) if new_app_id else "",
|
|
||||||
"pairing_uri": new_pairing_uri,
|
|
||||||
"message": "New NWC connection secret generated. Save it now — it will not be shown again.",
|
|
||||||
}
|
|
||||||
|
|
||||||
def health(self) -> dict:
|
|
||||||
"""Return a basic health summary."""
|
|
||||||
try:
|
|
||||||
token = self.ensure_ready()
|
|
||||||
status = self._request(
|
|
||||||
"GET", "/api/node/status", token=token, timeout=10
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"hub_ready": bool(
|
|
||||||
status.get("isReady") or status.get("running")
|
|
||||||
),
|
|
||||||
}
|
|
||||||
except AlbyHubError as exc:
|
|
||||||
return {"ok": False, "error": exc.code, "message": str(exc)}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Module-level singleton ──────────────────────────────────────────
|
|
||||||
|
|
||||||
_manager: AlbyHubManager | None = None
|
|
||||||
_manager_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def get_manager() -> AlbyHubManager:
|
|
||||||
"""Return the module-level singleton AlbyHubManager."""
|
|
||||||
global _manager
|
|
||||||
if _manager is None:
|
|
||||||
with _manager_lock:
|
|
||||||
if _manager is None:
|
|
||||||
_manager = AlbyHubManager()
|
|
||||||
return _manager
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
"""
|
|
||||||
Dedicated LNURL service for Lightning Wallet Connections.
|
|
||||||
|
|
||||||
Runs as ``nwc-lnurl.service`` on 127.0.0.1:8181 (loopback only).
|
|
||||||
Caddy proxies the public Lightning Address domain's LNURL routes to this port.
|
|
||||||
|
|
||||||
Routes:
|
|
||||||
GET /.well-known/lnurlp/{alias}
|
|
||||||
GET /lnurlp/{alias}/callback?amount=<msat>
|
|
||||||
|
|
||||||
All error responses are safe for public consumption — raw Alby Hub bodies
|
|
||||||
and internal credentials are never returned to callers.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import urllib.parse
|
|
||||||
from collections import defaultdict
|
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from . import nwc_hub_manager as _mgr_mod
|
|
||||||
from . import nwc_audit as _audit_mod
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .nwc_hub_manager import AlbyHubManager
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Configuration ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
LNURL_BIND_HOST = "127.0.0.1"
|
|
||||||
LNURL_PORT = int(os.environ.get("NWC_LNURL_PORT", "8181"))
|
|
||||||
DOMAIN_FILE = "/var/lib/domains/lightning"
|
|
||||||
|
|
||||||
NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
|
||||||
|
|
||||||
# Rate limiting configuration
|
|
||||||
RATE_LIMIT_WINDOW_SEC = 60
|
|
||||||
RATE_LIMIT_MAX_REQUESTS = 30
|
|
||||||
_rate_limit_buckets: dict[str, list[float]] = defaultdict(list)
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _read_domain() -> str | None:
|
|
||||||
try:
|
|
||||||
with open(DOMAIN_FILE, "r") as fh:
|
|
||||||
raw = fh.read(256).strip().lower()
|
|
||||||
# Strict FQDN validation: must be a valid hostname with at least one dot
|
|
||||||
# Reject localhost, IP addresses, and single-label names
|
|
||||||
if not re.match(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$", raw):
|
|
||||||
return None
|
|
||||||
# Explicitly reject local/reserved names
|
|
||||||
if raw in {"localhost", "localhost.localdomain", "local"}:
|
|
||||||
return None
|
|
||||||
return raw
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _check_rate_limit(client_ip: str) -> bool:
|
|
||||||
"""Check and update rate limit bucket for client IP. Returns True if allowed."""
|
|
||||||
now = time.monotonic()
|
|
||||||
bucket = _rate_limit_buckets[client_ip]
|
|
||||||
# Prune old entries
|
|
||||||
cutoff = now - RATE_LIMIT_WINDOW_SEC
|
|
||||||
while bucket and bucket[0] < cutoff:
|
|
||||||
bucket.pop(0)
|
|
||||||
if len(bucket) >= RATE_LIMIT_MAX_REQUESTS:
|
|
||||||
return False
|
|
||||||
bucket.append(now)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _lnurl_discovery(alias: str, manager: "AlbyHubManager", client_ip: str = "") -> tuple[dict, int]:
|
|
||||||
alias = alias.strip().lower()
|
|
||||||
if not NWC_ALIAS_RE.match(alias):
|
|
||||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
|
||||||
|
|
||||||
domain = _read_domain()
|
|
||||||
if not domain:
|
|
||||||
return {"status": "ERROR", "reason": "Lightning domain is not configured"}, 503
|
|
||||||
|
|
||||||
try:
|
|
||||||
app = manager.find_app_by_alias(alias)
|
|
||||||
except _mgr_mod.AlbyHubError:
|
|
||||||
return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503
|
|
||||||
|
|
||||||
if app is None:
|
|
||||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
|
||||||
|
|
||||||
meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata"))
|
|
||||||
min_sendable = int(
|
|
||||||
meta.get("lnurl_min_sendable_msat", _mgr_mod.NWC_MIN_SENDABLE_MSAT)
|
|
||||||
)
|
|
||||||
max_sendable = int(
|
|
||||||
meta.get("lnurl_max_sendable_msat", _mgr_mod.NWC_MAX_SENDABLE_MSAT)
|
|
||||||
)
|
|
||||||
|
|
||||||
callback_alias = urllib.parse.quote(alias, safe="")
|
|
||||||
callback = f"https://{domain}/lnurlp/{callback_alias}/callback"
|
|
||||||
description = meta.get("lnurl_description") or f"Pay {alias}"
|
|
||||||
metadata = json.dumps([["text/plain", description]], separators=(",", ":"))
|
|
||||||
|
|
||||||
# Audit log: LNURL discovery
|
|
||||||
_audit_mod.audit_log(
|
|
||||||
"lnurl_discovery",
|
|
||||||
alias=alias,
|
|
||||||
domain=domain,
|
|
||||||
client_ip=client_ip,
|
|
||||||
min_sendable_msat=min_sendable,
|
|
||||||
max_sendable_msat=max_sendable,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tag": "payRequest",
|
|
||||||
"callback": callback,
|
|
||||||
"minSendable": min_sendable,
|
|
||||||
"maxSendable": max_sendable,
|
|
||||||
"metadata": metadata,
|
|
||||||
"commentAllowed": 0,
|
|
||||||
}, 200
|
|
||||||
|
|
||||||
|
|
||||||
def _lnurl_callback(
|
|
||||||
alias: str, amount_str: str | None, manager: "AlbyHubManager", client_ip: str = ""
|
|
||||||
) -> tuple[dict, int]:
|
|
||||||
payload, status_code = _lnurl_discovery(alias, manager, client_ip)
|
|
||||||
if status_code != 200:
|
|
||||||
return payload, status_code
|
|
||||||
|
|
||||||
if amount_str is None:
|
|
||||||
return {"status": "ERROR", "reason": "Missing amount parameter"}, 400
|
|
||||||
if not re.match(r"^\d+$", amount_str):
|
|
||||||
return {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Amount must be an integer millisatoshi value",
|
|
||||||
}, 400
|
|
||||||
|
|
||||||
amount_msat = int(amount_str)
|
|
||||||
min_sendable = int(payload["minSendable"])
|
|
||||||
max_sendable = int(payload["maxSendable"])
|
|
||||||
|
|
||||||
if amount_msat < min_sendable:
|
|
||||||
return {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Amount is below the minimum sendable value",
|
|
||||||
}, 400
|
|
||||||
if amount_msat > max_sendable:
|
|
||||||
return {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Amount is above the maximum sendable value",
|
|
||||||
}, 400
|
|
||||||
if amount_msat % 1000 != 0:
|
|
||||||
return {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Amount must be a whole-satoshi value",
|
|
||||||
}, 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
app = manager.find_app_by_alias(alias)
|
|
||||||
except _mgr_mod.AlbyHubError:
|
|
||||||
return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503
|
|
||||||
|
|
||||||
if app is None:
|
|
||||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
|
||||||
|
|
||||||
meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata"))
|
|
||||||
description = meta.get("lnurl_description") or f"Pay {alias}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
app_id = int(app["id"])
|
|
||||||
invoice = manager.issue_invoice(app_id, amount_msat, description)
|
|
||||||
except _mgr_mod.AlbyHubError:
|
|
||||||
return {"status": "ERROR", "reason": "Invoice creation failed"}, 502
|
|
||||||
|
|
||||||
# Audit log: Invoice generated via LNURL
|
|
||||||
_audit_mod.audit_log(
|
|
||||||
"lnurl_invoice_created",
|
|
||||||
alias=alias,
|
|
||||||
amount_msat=amount_msat,
|
|
||||||
amount_sat=amount_msat // 1000,
|
|
||||||
client_ip=client_ip,
|
|
||||||
invoice_prefix=invoice[:50] + "..." if len(invoice) > 50 else invoice,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"pr": invoice, "routes": []}, 200
|
|
||||||
|
|
||||||
|
|
||||||
# ── HTTP server ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _make_handler(manager: "AlbyHubManager") -> type:
|
|
||||||
"""Return a handler class bound to the given manager."""
|
|
||||||
|
|
||||||
class LnurlHandler(BaseHTTPRequestHandler):
|
|
||||||
_manager = manager
|
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args: object) -> None:
|
|
||||||
logger.debug(f"LNURL {self.address_string()} {fmt % args}")
|
|
||||||
|
|
||||||
def _send_json(self, status: int, body: dict) -> None:
|
|
||||||
raw = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(raw)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(raw)
|
|
||||||
|
|
||||||
def _get_client_ip(self) -> str:
|
|
||||||
# Check X-Forwarded-For header (set by Caddy)
|
|
||||||
forwarded = self.headers.get("X-Forwarded-For")
|
|
||||||
if forwarded:
|
|
||||||
# Take the first IP in the chain
|
|
||||||
return forwarded.split(",")[0].strip()
|
|
||||||
# Fallback to direct connection IP
|
|
||||||
return self.client_address[0]
|
|
||||||
|
|
||||||
def _check_rate_limit(self) -> bool:
|
|
||||||
client_ip = self._get_client_ip()
|
|
||||||
if not _check_rate_limit(client_ip):
|
|
||||||
self._send_json(429, {
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "Rate limit exceeded. Please slow down."
|
|
||||||
})
|
|
||||||
_audit_mod.audit_log(
|
|
||||||
"rate_limit_exceeded",
|
|
||||||
client_ip=client_ip,
|
|
||||||
path=self.path,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802
|
|
||||||
if not self._check_rate_limit():
|
|
||||||
return
|
|
||||||
|
|
||||||
parsed = urllib.parse.urlparse(self.path)
|
|
||||||
path = parsed.path
|
|
||||||
qs = urllib.parse.parse_qs(parsed.query)
|
|
||||||
client_ip = self._get_client_ip()
|
|
||||||
|
|
||||||
# /.well-known/lnurlp/{alias}
|
|
||||||
m = re.fullmatch(
|
|
||||||
r"/.well-known/lnurlp/([^/]+)", path
|
|
||||||
)
|
|
||||||
if m:
|
|
||||||
alias = urllib.parse.unquote(m.group(1))
|
|
||||||
payload, code = _lnurl_discovery(alias, self._manager, client_ip)
|
|
||||||
self._send_json(code, payload)
|
|
||||||
return
|
|
||||||
|
|
||||||
# /lnurlp/{alias}/callback
|
|
||||||
m = re.fullmatch(r"/lnurlp/([^/]+)/callback", path)
|
|
||||||
if m:
|
|
||||||
alias = urllib.parse.unquote(m.group(1))
|
|
||||||
amount_values = qs.get("amount")
|
|
||||||
if not amount_values:
|
|
||||||
amount_str = None
|
|
||||||
elif len(amount_values) != 1:
|
|
||||||
self._send_json(
|
|
||||||
400,
|
|
||||||
{
|
|
||||||
"status": "ERROR",
|
|
||||||
"reason": "A single amount parameter is required",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
amount_str = amount_values[0]
|
|
||||||
payload, code = _lnurl_callback(alias, amount_str, self._manager, client_ip)
|
|
||||||
self._send_json(code, payload)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._send_json(404, {"status": "ERROR", "reason": "Not found"})
|
|
||||||
|
|
||||||
return LnurlHandler
|
|
||||||
|
|
||||||
|
|
||||||
def run(
|
|
||||||
host: str = LNURL_BIND_HOST,
|
|
||||||
port: int = LNURL_PORT,
|
|
||||||
manager: "AlbyHubManager | None" = None,
|
|
||||||
) -> None:
|
|
||||||
"""Start the blocking LNURL HTTP server."""
|
|
||||||
if manager is None:
|
|
||||||
manager = _mgr_mod.get_manager()
|
|
||||||
handler_class = _make_handler(manager)
|
|
||||||
server = HTTPServer((host, port), handler_class)
|
|
||||||
logger.info("nwc-lnurl service listening on %s:%d", host, port)
|
|
||||||
server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
run()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from . import nwc_hub_manager as _mgr_mod
|
|
||||||
from .server import _nwc_domain, _nwc_validate_alias, _nwc_test_address
|
|
||||||
|
|
||||||
|
|
||||||
def _print(data) -> None:
|
|
||||||
print(json.dumps(data, indent=2, sort_keys=True))
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(prog="nwc-wallet")
|
|
||||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
||||||
|
|
||||||
create = sub.add_parser("create")
|
|
||||||
create.add_argument("name")
|
|
||||||
create.add_argument("alias")
|
|
||||||
preset_group = create.add_mutually_exclusive_group()
|
|
||||||
preset_group.add_argument("--receive-only", action="store_true")
|
|
||||||
preset_group.add_argument("--limit-sats", type=int)
|
|
||||||
|
|
||||||
sub.add_parser("list")
|
|
||||||
|
|
||||||
drain = sub.add_parser("drain")
|
|
||||||
drain.add_argument("wallet")
|
|
||||||
|
|
||||||
delete = sub.add_parser("delete")
|
|
||||||
delete.add_argument("wallet")
|
|
||||||
|
|
||||||
addr = sub.add_parser("address")
|
|
||||||
addr_sub = addr.add_subparsers(dest="address_cmd", required=True)
|
|
||||||
addr_show = addr_sub.add_parser("show")
|
|
||||||
addr_show.add_argument("alias")
|
|
||||||
|
|
||||||
rotate = sub.add_parser("rotate")
|
|
||||||
rotate.add_argument("wallet")
|
|
||||||
|
|
||||||
sub.add_parser("health")
|
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
manager = _mgr_mod.get_manager()
|
|
||||||
domain = _nwc_domain()
|
|
||||||
|
|
||||||
if args.cmd == "list":
|
|
||||||
try:
|
|
||||||
wallets = manager.list_wallets(domain)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
_print({"wallets": wallets})
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.cmd == "health":
|
|
||||||
result = manager.health()
|
|
||||||
_print(result)
|
|
||||||
return 0 if result.get("ok") else 1
|
|
||||||
|
|
||||||
if args.cmd == "address" and args.address_cmd == "show":
|
|
||||||
alias = args.alias.strip().lower()
|
|
||||||
test = _nwc_test_address(alias)
|
|
||||||
_print(test)
|
|
||||||
return 0 if test.get("ok") else 1
|
|
||||||
|
|
||||||
if args.cmd == "drain":
|
|
||||||
try:
|
|
||||||
result = manager.drain_wallet(args.wallet)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
_print(result)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.cmd == "delete":
|
|
||||||
try:
|
|
||||||
result = manager.delete_wallet(args.wallet)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
_print(result)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.cmd == "rotate":
|
|
||||||
try:
|
|
||||||
result = manager.rotate_wallet_secret(args.wallet)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
_print({
|
|
||||||
"wallet_id": result.get("wallet_id", ""),
|
|
||||||
"pairing_uri": result.get("pairing_uri", ""),
|
|
||||||
"message": result.get("message", "New NWC connection secret generated. Save it now — it will not be shown again."),
|
|
||||||
})
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if args.cmd == "create":
|
|
||||||
alias = args.alias.strip().lower()
|
|
||||||
if not _nwc_validate_alias(alias):
|
|
||||||
print("Error: alias_invalid - Alias must be lowercase letters, digits, '_' or '-'.", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
access_preset = "send_receive_limited" if args.limit_sats is not None else "receive_only"
|
|
||||||
try:
|
|
||||||
result = manager.create_wallet(
|
|
||||||
args.name.strip(),
|
|
||||||
alias,
|
|
||||||
access_preset,
|
|
||||||
args.limit_sats if access_preset == "send_receive_limited" else None,
|
|
||||||
domain,
|
|
||||||
)
|
|
||||||
except _mgr_mod.AlbyHubError as exc:
|
|
||||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
# Print the pairing URI once — this is the only time it is shown
|
|
||||||
_print(
|
|
||||||
{
|
|
||||||
"wallet": result["wallet"],
|
|
||||||
"pairing_uri": result.get("pairing_uri", ""),
|
|
||||||
"message": "Keep the NWC connection secret private. It cannot be displayed again.",
|
|
||||||
"result": result.get("result", {}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -39,7 +39,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
|
|
||||||
from .config import load_config, load_versions
|
from .config import load_config, load_versions
|
||||||
from . import systemctl as sysctl
|
from . import systemctl as sysctl
|
||||||
from . import nwc_hub_manager as _nwc_mgr
|
from sovran_nwc import nwc_hub_manager as _nwc_mgr
|
||||||
from . import support_ops as _support_ops
|
from . import support_ops as _support_ops
|
||||||
from .security_helpers import (
|
from .security_helpers import (
|
||||||
_nix_escape,
|
_nix_escape,
|
||||||
|
|||||||
Generated
+53
-1
@@ -70,6 +70,22 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nixpkgs-stable_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1787753485,
|
||||||
|
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-26.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787498568,
|
"lastModified": 1787498568,
|
||||||
@@ -102,6 +118,22 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nixpkgs_4": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1787736819,
|
||||||
|
"narHash": "sha256-cV5xEJJK3BvhU8rEd4mC9UsmDi5qscv/kzGPhBRC5WA=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixvim": {
|
"nixvim": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
@@ -127,7 +159,27 @@
|
|||||||
"btc-clients": "btc-clients",
|
"btc-clients": "btc-clients",
|
||||||
"nixpkgs": "nixpkgs_2",
|
"nixpkgs": "nixpkgs_2",
|
||||||
"nixpkgs-stable": "nixpkgs-stable",
|
"nixpkgs-stable": "nixpkgs-stable",
|
||||||
"nixvim": "nixvim"
|
"nixvim": "nixvim",
|
||||||
|
"sovran-bitcoin": "sovran-bitcoin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sovran-bitcoin": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs_4",
|
||||||
|
"nixpkgs-stable": "nixpkgs-stable_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1788225336,
|
||||||
|
"narHash": "sha256-ixw/ChkLWWrLOzoiSa9n1g2g5KDIVkedC6eYFCba+gA=",
|
||||||
|
"owner": "naturallaw777",
|
||||||
|
"repo": "Sovran_Bitcoin",
|
||||||
|
"rev": "37caecaa3f3666231c8ec82a9a9a0df53fa40910",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "naturallaw777",
|
||||||
|
"repo": "Sovran_Bitcoin",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"systems": {
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
nixvim.url = "github:nix-community/nixvim";
|
nixvim.url = "github:nix-community/nixvim";
|
||||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
|
|
||||||
|
# Bitcoin / Lightning stack — standalone flake, consumed as a module.
|
||||||
|
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, ... }:
|
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, sovran-bitcoin, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
overlay-stable = final: prev: {
|
overlay-stable = final: prev: {
|
||||||
@@ -33,7 +36,8 @@
|
|||||||
modules = [
|
modules = [
|
||||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||||
./iso/common.nix
|
./iso/common.nix
|
||||||
./modules/bitcoin
|
sovran-bitcoin.nixosModules.default
|
||||||
|
./modules/sovran-bitcoin-integration.nix
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -44,7 +48,8 @@
|
|||||||
nixpkgs.overlays = [ overlay-stable ];
|
nixpkgs.overlays = [ overlay-stable ];
|
||||||
})
|
})
|
||||||
./configuration.nix
|
./configuration.nix
|
||||||
./modules/bitcoin
|
sovran-bitcoin.nixosModules.default
|
||||||
|
./modules/sovran-bitcoin-integration.nix
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
config = {
|
config = {
|
||||||
@@ -60,17 +65,9 @@
|
|||||||
pkgs = import nixpkgs {
|
pkgs = import nixpkgs {
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
};
|
};
|
||||||
fetchNodeModules =
|
|
||||||
pkgs.callPackage ./packages/build-support/fetch-node-modules.nix {};
|
|
||||||
mempoolPkgs =
|
|
||||||
pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; };
|
|
||||||
in {
|
in {
|
||||||
bitcoin-btcpay-hardening = import ./tests/bitcoin-btcpay-hardening.nix {
|
# Bitcoin hardening and package checks now live in the Sovran_Bitcoin flake.
|
||||||
inherit nixpkgs overlay-stable;
|
# Run them with: nix build github:naturallaw777/Sovran_Bitcoin#checks.x86_64-linux
|
||||||
};
|
|
||||||
mempool-backend = mempoolPkgs.mempool-backend;
|
|
||||||
mempool-frontend = mempoolPkgs.mempool-frontend;
|
|
||||||
rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; };
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
# 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"
|
|
||||||
]
|
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
{ 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.
|
|
||||||
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
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;
|
|
||||||
|
|
||||||
# nixpkgs 26.11 moved i2pd's protocol configuration from
|
|
||||||
# `services.i2pd.proto` to the RFC42-style `services.i2pd.settings`.
|
|
||||||
i2pSAM = config.services.i2pd.settings.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;
|
|
||||||
settings.sam = {
|
|
||||||
enabled = true;
|
|
||||||
address = "127.0.0.1";
|
|
||||||
port = 7656;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
{ 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 ];
|
|
||||||
})
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 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
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# 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" ];
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
{ 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} = {};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
{ 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.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
|
|
||||||
# The admin macaroon is passed to curl via a fd because argv is
|
|
||||||
# world-readable through /proc/<pid>/cmdline
|
|
||||||
script = nbLib.rootScript "lnd-create-macaroons" ''
|
|
||||||
umask ug=r,o=
|
|
||||||
${lib.concatMapStrings (macaroon: ''
|
|
||||||
echo "Create custom macaroon ${macaroon}"
|
|
||||||
macaroonPath="$RUNTIME_DIRECTORY/${macaroon}.macaroon"
|
|
||||||
adminMacaroonHex=$(${pkgs.xxd}/bin/xxd -ps -u -c 99999 '${networkDir}/admin.macaroon')
|
|
||||||
${curl} \
|
|
||||||
-H @<(printf 'Grpc-Metadata-macaroon: %s\n' "$adminMacaroonHex") \
|
|
||||||
-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}'
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
# LND-only lndconnect wrapper. Restored to the fort-nix/nix-bitcoin contract
|
|
||||||
# after the LND-only rewrite shipped a Zeus QR that Zeus cannot use:
|
|
||||||
# - unknown flags (--cert / --macaroon instead of --tlscertpath / --adminmacaroonpath)
|
|
||||||
# - onion hostname read from /var/lib/tor/onion/free/lnd/hostname (does not exist)
|
|
||||||
# - REST hidden service named "lnd", colliding with the LND P2P onion
|
|
||||||
# - TLS cert embedded in the URI (localhost CN + QR too dense to scan)
|
|
||||||
#
|
|
||||||
# Zeus needs: lndconnect://<lnd-rest-onion>:8080?macaroon=<admin> (no cert over Tor)
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
cfg = config.services.lnd;
|
|
||||||
operatorName = config.nix-bitcoin.operator.name;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
runAsUser = config.nix-bitcoin.runAsUserCmd;
|
|
||||||
|
|
||||||
mkLndconnect = {
|
|
||||||
name,
|
|
||||||
shebang ? "#!${pkgs.stdenv.shell} -e",
|
|
||||||
port,
|
|
||||||
authSecretPath,
|
|
||||||
enableOnion,
|
|
||||||
onionService ? null,
|
|
||||||
certPath ? null
|
|
||||||
}:
|
|
||||||
# lndconnect requires a --configfile argument, although it's unused
|
|
||||||
# https://github.com/LN-Zap/lndconnect/issues/25
|
|
||||||
lib.hiPrio (pkgs.writeScriptBin name ''
|
|
||||||
${shebang}
|
|
||||||
url=$(
|
|
||||||
${getExe pkgs.lndconnect} --url \
|
|
||||||
${optionalString enableOnion "--host=$(cat ${config.nix-bitcoin.onionAddresses.dataDir}/${onionService})"} \
|
|
||||||
--port=${toString port} \
|
|
||||||
${if enableOnion || certPath == null then "--nocert" else "--tlscertpath='${certPath}'"} \
|
|
||||||
--adminmacaroonpath='${authSecretPath}' \
|
|
||||||
--configfile=/dev/null "$@"
|
|
||||||
)
|
|
||||||
|
|
||||||
# If --url is in args
|
|
||||||
if [[ " $* " =~ " --url " ]]; then
|
|
||||||
echo "$url"
|
|
||||||
else
|
|
||||||
# UTF-8 QR is smaller than lndconnect's native output
|
|
||||||
echo -n "$url" | ${getExe pkgs.qrencode} -t UTF8 -o -
|
|
||||||
fi
|
|
||||||
'');
|
|
||||||
in {
|
|
||||||
options.services.lnd.lndconnect = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Add a `lndconnect` binary to the system environment which prints
|
|
||||||
connection info for lnd clients (Zeus).
|
|
||||||
See: https://github.com/LN-Zap/lndconnect
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
```bash
|
|
||||||
# Print QR code
|
|
||||||
lndconnect
|
|
||||||
|
|
||||||
# Print URL
|
|
||||||
lndconnect --url
|
|
||||||
```
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
onion = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Create an onion service for the lnd REST server,
|
|
||||||
which is used by lndconnect / Zeus.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = mkIf (cfg.enable && cfg.lndconnect.enable) (mkMerge [
|
|
||||||
{
|
|
||||||
environment.systemPackages = [(
|
|
||||||
mkLndconnect {
|
|
||||||
name = "lndconnect";
|
|
||||||
# Run as lnd user because the macaroon and cert are not group-readable
|
|
||||||
shebang = "#!/usr/bin/env -S ${runAsUser} ${cfg.user} ${pkgs.bash}/bin/bash";
|
|
||||||
enableOnion = cfg.lndconnect.onion;
|
|
||||||
onionService = "${cfg.user}/lnd-rest";
|
|
||||||
port = cfg.restPort;
|
|
||||||
certPath = cfg.certPath;
|
|
||||||
authSecretPath = "${cfg.networkDir}/admin.macaroon";
|
|
||||||
}
|
|
||||||
)];
|
|
||||||
|
|
||||||
# LAN / clearnet Zeus needs REST on all interfaces. Tor-only stays on
|
|
||||||
# the existing restAddress (loopback) and is reached via lnd-rest.
|
|
||||||
services.lnd.restAddress = mkIf (!cfg.lndconnect.onion) "0.0.0.0";
|
|
||||||
}
|
|
||||||
|
|
||||||
(mkIf cfg.lndconnect.onion {
|
|
||||||
services.tor = {
|
|
||||||
enable = true;
|
|
||||||
# Dedicated name — must not reuse onionServices.lnd (that's P2P :9735).
|
|
||||||
relay.onionServices.lnd-rest = nbLib.mkOnionService {
|
|
||||||
target.addr = nbLib.address cfg.restAddress;
|
|
||||||
target.port = cfg.restPort;
|
|
||||||
port = cfg.restPort;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
nix-bitcoin.onionAddresses.access = {
|
|
||||||
${cfg.user} = [ "lnd-rest" ];
|
|
||||||
${operatorName} = [ "lnd-rest" ];
|
|
||||||
};
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
{ 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} = {};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
{ 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)";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
{ 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})""")
|
|
||||||
'');
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# 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}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# 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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
{ 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
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
{ 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
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
{ 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"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{ 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>
|
|
||||||
'')
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{ 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 = {};
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
# Keep the normal loopback P2P socket available for local clients such as
|
|
||||||
# Bisq. Because `address` defaults to 127.0.0.1 this does not expose a
|
|
||||||
# clearnet or LAN listener. When the existing bitcoind onion service is
|
|
||||||
# enabled, this also creates its Tor-tagged loopback target on port 8334.
|
|
||||||
listen = true;
|
|
||||||
package = pkgs.bitcoind;
|
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
|
||||||
txindex = true;
|
|
||||||
tor.proxy = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
disablewallet = true;
|
|
||||||
extraConfig = ''
|
|
||||||
peerbloomfilters=1
|
|
||||||
server=1
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.onionServices.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
# This is a locally vendored option namespace, not an upstream dependency.
|
|
||||||
# The onion listener remains available to peers that already know it;
|
|
||||||
# advertising its address through Bitcoin peer gossip is opt-in in the Hub.
|
|
||||||
public = config.sovran_systemsOS.features.bitcoin-tor-gossip;
|
|
||||||
};
|
|
||||||
nix-bitcoin.onionServices.electrs.enable = true;
|
|
||||||
nix-bitcoin.onionServices.rtl.enable = true;
|
|
||||||
|
|
||||||
services.electrs = {
|
|
||||||
enable = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data";
|
|
||||||
};
|
|
||||||
|
|
||||||
services.lnd = {
|
|
||||||
enable = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
tor.proxy = true;
|
|
||||||
extraConfig = ''
|
|
||||||
protocol.option-scid-alias=true
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.onionServices.lnd.public = true;
|
|
||||||
|
|
||||||
services.lnd.lndconnect = {
|
|
||||||
enable = true;
|
|
||||||
onion = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.rtl = {
|
|
||||||
enable = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
port = 3050;
|
|
||||||
nightTheme = true;
|
|
||||||
nodes = {
|
|
||||||
lnd = {
|
|
||||||
enable = true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
services.btcpayserver = {
|
|
||||||
enable = config.sovran_systemsOS.web.btcpayserver;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.btcpayserver.lightningBackend = "lnd";
|
|
||||||
|
|
||||||
nix-bitcoin.generateSecrets = true;
|
|
||||||
nix-bitcoin.nodeinfo.enable = true;
|
|
||||||
|
|
||||||
nix-bitcoin.operator = {
|
|
||||||
enable = true;
|
|
||||||
name = "free";
|
|
||||||
};
|
|
||||||
|
|
||||||
# vendored: now no-op (always uses nixpkgs)
|
|
||||||
nix-bitcoin.useVersionLockedPkgs = false;
|
|
||||||
|
|
||||||
systemd.services.bitcoind = {
|
|
||||||
requires = [ "run-media-Second_Drive.mount" ];
|
|
||||||
after = [ "run-media-Second_Drive.mount" ];
|
|
||||||
serviceConfig.PrivateUsers = lib.mkForce false;
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.electrs = {
|
|
||||||
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
|
|
||||||
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
|
|
||||||
wants = [ "bitcoind.service" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.lnd = {
|
|
||||||
wants = [ "bitcoind.service" ];
|
|
||||||
# requires for bitcoind set by nix-bitcoin; mkForce removes it
|
|
||||||
requires = lib.mkForce [ ];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.sovran-btc-permissions = {
|
|
||||||
description = "Fix Bitcoin/Electrs data directory ownership on second drive";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
after = [ "run-media-Second_Drive.mount" ];
|
|
||||||
before = [ "bitcoind.service" "electrs.service" ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node ]; then
|
|
||||||
chown -R bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
|
|
||||||
fi
|
|
||||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data ]; then
|
|
||||||
chown -R electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 3051 ];
|
|
||||||
networking.firewall.allowedUDPPorts = [ 3051 ];
|
|
||||||
|
|
||||||
sovran_systemsOS.domainRequirements = [
|
|
||||||
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
{
|
{
|
||||||
config = lib.mkMerge [
|
config = lib.mkMerge [
|
||||||
|
|
||||||
# Vendored nix-bitcoin is always imported via modules/vendor/nix-bitcoin/modules.nix.
|
# The Sovran_Bitcoin flake module is always imported (see flake.nix).
|
||||||
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
||||||
# without enabling any Bitcoin services.
|
# without enabling any Bitcoin services.
|
||||||
{
|
{
|
||||||
|
|||||||
+12
-25
@@ -135,12 +135,13 @@ let
|
|||||||
"bitcoind.service" = if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0";
|
"bitcoind.service" = if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0";
|
||||||
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
|
"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";
|
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
||||||
# Evaluate locally vendored packages directly so we don't accidentally
|
# Vendored packages come from the Sovran_Bitcoin flake overlay
|
||||||
# pick up older/different versions from upstream nixpkgs.
|
# (pkgs.sovran-bitcoin.*). Evaluate them directly so the Hub shows
|
||||||
"rtl.service" = (pkgs.callPackage ../../packages/rtl { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).version;
|
# the exact versions this system ships.
|
||||||
|
"rtl.service" = pkgs.sovran-bitcoin.rtl.version;
|
||||||
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
||||||
"albyhub.service" = (pkgs.callPackage ../../packages/albyhub {}).version;
|
"albyhub.service" = pkgs.sovran-bitcoin.albyhub.version;
|
||||||
"mempool.service" = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).version;
|
"mempool.service" = pkgs.sovran-bitcoin.mempool-backend.version;
|
||||||
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
||||||
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
||||||
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
||||||
@@ -404,6 +405,9 @@ DESKTOP
|
|||||||
import os, sys
|
import os, sys
|
||||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
base = os.path.join("$out", "lib", "sovran-hub-web")
|
||||||
sys.path.insert(0, base)
|
sys.path.insert(0, base)
|
||||||
|
# Canonical NWC implementation — single source of truth from the
|
||||||
|
# Sovran_Bitcoin flake (imported directly by server.py).
|
||||||
|
sys.path.insert(0, os.path.join("${pkgs.sovran-bitcoin.nwc}", "lib", "sovran-nwc"))
|
||||||
os.environ["SOVRAN_HUB_CONFIG"] = os.path.join(base, "config.json")
|
os.environ["SOVRAN_HUB_CONFIG"] = os.path.join(base, "config.json")
|
||||||
os.environ["SOVRAN_HUB_VERSIONS"] = os.path.join(base, "versions.json")
|
os.environ["SOVRAN_HUB_VERSIONS"] = os.path.join(base, "versions.json")
|
||||||
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
|
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
|
||||||
@@ -417,26 +421,9 @@ uvicorn.run(
|
|||||||
LAUNCHER
|
LAUNCHER
|
||||||
chmod +x $out/bin/sovran-hub-web
|
chmod +x $out/bin/sovran-hub-web
|
||||||
|
|
||||||
cat > $out/bin/nwc-wallet <<LAUNCHER
|
# nwc-wallet / nwc-lnurl binaries are no longer shipped here: the
|
||||||
#!${pkgs.python3}/bin/python3
|
# Sovran_Bitcoin flake provides them (env-wrapped nwc-wallet via
|
||||||
import os, sys
|
# albyhub.nix, nwc-lnurl.service via lnurl.nix) — single source of truth.
|
||||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
|
||||||
sys.path.insert(0, base)
|
|
||||||
from sovran_systemsos_web.nwc_wallet_cli import main
|
|
||||||
sys.exit(main())
|
|
||||||
LAUNCHER
|
|
||||||
chmod +x $out/bin/nwc-wallet
|
|
||||||
|
|
||||||
cat > $out/bin/nwc-lnurl <<LAUNCHER
|
|
||||||
#!${pkgs.python3}/bin/python3
|
|
||||||
import os, sys
|
|
||||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
|
||||||
sys.path.insert(0, base)
|
|
||||||
from sovran_systemsos_web.nwc_lnurl_service import main
|
|
||||||
main()
|
|
||||||
LAUNCHER
|
|
||||||
chmod +x $out/bin/nwc-lnurl
|
|
||||||
|
|
||||||
runHook postInstall
|
runHook postInstall
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
lib.mkIf config.sovran_systemsOS.features.mempool {
|
|
||||||
|
|
||||||
services.mempool = {
|
|
||||||
enable = true;
|
|
||||||
frontend.enable = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.mysql.package = lib.mkForce pkgs.mariadb;
|
|
||||||
|
|
||||||
nix-bitcoin.onionServices.mempool-frontend.enable = true;
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -28,14 +28,11 @@
|
|||||||
./wordpress.nix
|
./wordpress.nix
|
||||||
./nextcloud.nix
|
./nextcloud.nix
|
||||||
./vaultwarden.nix
|
./vaultwarden.nix
|
||||||
./bitcoinecosystem.nix
|
|
||||||
./wallet-autoconnect.nix
|
./wallet-autoconnect.nix
|
||||||
|
|
||||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||||
./haven.nix
|
./haven.nix
|
||||||
./nwc-wallets.nix
|
|
||||||
./element-calling.nix
|
./element-calling.nix
|
||||||
./mempool.nix
|
|
||||||
./rdp.nix
|
./rdp.nix
|
||||||
./sshd.nix
|
./sshd.nix
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
let
|
|
||||||
albyHubPort = 18080;
|
|
||||||
albyHubApiBase = "http://127.0.0.1:${toString albyHubPort}";
|
|
||||||
vendoredAlbyHub = pkgs.callPackage ../packages/albyhub {};
|
|
||||||
patchedAlbyHub = vendoredAlbyHub;
|
|
||||||
|
|
||||||
lndRpcAddress = lib.attrByPath [ "services" "lnd" "rpcAddress" ] "127.0.0.1" config;
|
|
||||||
lndRpcPort = toString (lib.attrByPath [ "services" "lnd" "rpcPort" ] 10009 config);
|
|
||||||
lndCertPath = config.services.lnd.certPath;
|
|
||||||
pythonManagerEnvironment = {
|
|
||||||
NWC_ALBY_HUB_API_BASE = albyHubApiBase;
|
|
||||||
NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
|
|
||||||
NWC_LND_CERT_FILE = lndCertPath;
|
|
||||||
NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
|
||||||
NWC_RELAY =
|
|
||||||
if config.sovran_systemsOS.features.haven
|
|
||||||
then "wss://haven.${config.networking.domain}/nostr"
|
|
||||||
else "wss://relay.getalby.com,wss://relay2.getalby.com";
|
|
||||||
};
|
|
||||||
wrappedNwcWallet = lib.hiPrio (pkgs.writeShellScriptBin "nwc-wallet" ''
|
|
||||||
export NWC_ALBY_HUB_API_BASE='${pythonManagerEnvironment.NWC_ALBY_HUB_API_BASE}'
|
|
||||||
export NWC_LND_ADDRESS='${pythonManagerEnvironment.NWC_LND_ADDRESS}'
|
|
||||||
export NWC_LND_CERT_FILE='${pythonManagerEnvironment.NWC_LND_CERT_FILE}'
|
|
||||||
export NWC_LND_MACAROON_FILE='${pythonManagerEnvironment.NWC_LND_MACAROON_FILE}'
|
|
||||||
export NWC_RELAY='${pythonManagerEnvironment.NWC_RELAY}'
|
|
||||||
exec ${config.services.sovranHub.webPackage}/bin/nwc-wallet "$@"
|
|
||||||
'');
|
|
||||||
|
|
||||||
albyhubWrapper = pkgs.writeShellScript "albyhub-wrapper" ''
|
|
||||||
set -euo pipefail
|
|
||||||
password_file="/var/lib/albyhub/unlock-password"
|
|
||||||
if [ ! -s "$password_file" ]; then
|
|
||||||
umask 077
|
|
||||||
${pkgs.openssl}/bin/openssl rand -hex 32 > "$password_file"
|
|
||||||
fi
|
|
||||||
export AUTO_UNLOCK_PASSWORD="$(cat "$password_file")"
|
|
||||||
exec ${lib.getExe patchedAlbyHub}
|
|
||||||
'';
|
|
||||||
in
|
|
||||||
lib.mkIf config.sovran_systemsOS.features."nwc-wallets" {
|
|
||||||
assertions = [
|
|
||||||
{
|
|
||||||
assertion = config.services.lnd.enable;
|
|
||||||
message = "Lightning Wallet Connections requires services.lnd.enable = true.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = !(lib.attrByPath [ "nix-bitcoin" "netns-isolation" "enable" ] false config);
|
|
||||||
message = "Lightning Wallet Connections requires nix-bitcoin.netns-isolation.enable = false.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = albyHubPort != config.services.lnd.restPort;
|
|
||||||
message = "Alby Hub and LND REST must use different ports.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = albyHubPort != 8181;
|
|
||||||
message = "Alby Hub and the public LNURL service must use different ports.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = !(lib.elem albyHubPort config.networking.firewall.allowedTCPPorts);
|
|
||||||
message = "Alby Hub management port must not be opened on the public TCP firewall.";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
users.groups.albyhub = { };
|
|
||||||
users.users.albyhub = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = "albyhub";
|
|
||||||
home = "/var/lib/albyhub";
|
|
||||||
createHome = false;
|
|
||||||
extraGroups = [ ];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d /var/lib/albyhub 0700 albyhub albyhub -"
|
|
||||||
];
|
|
||||||
|
|
||||||
services.lnd.macaroons.albyhub = {
|
|
||||||
user = "albyhub";
|
|
||||||
permissions = lib.concatStringsSep "," [
|
|
||||||
''{"entity":"info","action":"read"}''
|
|
||||||
''{"entity":"offchain","action":"read"}''
|
|
||||||
''{"entity":"offchain","action":"write"}''
|
|
||||||
''{"entity":"invoices","action":"read"}''
|
|
||||||
''{"entity":"invoices","action":"write"}''
|
|
||||||
''{"entity":"onchain","action":"read"}''
|
|
||||||
''{"entity":"address","action":"read"}''
|
|
||||||
''{"entity":"message","action":"read"}''
|
|
||||||
''{"entity":"message","action":"write"}''
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.albyhub = {
|
|
||||||
description = "Alby Hub — NWC wallet server";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
after = [ "network.target" "lnd.service" ];
|
|
||||||
requires = [ "lnd.service" ];
|
|
||||||
|
|
||||||
environment = {
|
|
||||||
HOME = "/var/lib/albyhub";
|
|
||||||
HOST = "127.0.0.1";
|
|
||||||
LN_BACKEND_TYPE = "LND";
|
|
||||||
ENABLE_ADVANCED_SETUP = "false";
|
|
||||||
LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
|
|
||||||
LND_CERT_FILE = lndCertPath;
|
|
||||||
LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
|
||||||
WORK_DIR = "/var/lib/albyhub";
|
|
||||||
DATABASE_URI = "/var/lib/albyhub/nwc.db";
|
|
||||||
PORT = toString albyHubPort;
|
|
||||||
# Use private Nostr relay if Haven is enabled, otherwise default to Alby's public relays
|
|
||||||
RELAY =
|
|
||||||
if config.sovran_systemsOS.features.haven
|
|
||||||
then "wss://haven.${config.networking.domain}/nostr"
|
|
||||||
else "wss://relay.getalby.com,wss://relay2.getalby.com";
|
|
||||||
AUTO_LINK_ALBY_ACCOUNT = "false";
|
|
||||||
SEND_EVENTS_TO_ALBY = "false";
|
|
||||||
LOG_TO_FILE = "false";
|
|
||||||
HIDE_UPDATE_BANNER = "true";
|
|
||||||
};
|
|
||||||
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "simple";
|
|
||||||
User = "albyhub";
|
|
||||||
Group = "albyhub";
|
|
||||||
WorkingDirectory = "/var/lib/albyhub";
|
|
||||||
ExecStart = albyhubWrapper;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
UMask = "0077";
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
PrivateTmp = true;
|
|
||||||
ProtectHome = true;
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
ReadWritePaths = [ "/var/lib/albyhub" ];
|
|
||||||
ReadOnlyPaths = [ lndCertPath "/run/lnd" ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.nwc-lnurl = {
|
|
||||||
description = "Lightning Wallet Connections public LNURL service";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
after = [ "albyhub.service" "sovran-hub-web.service" ];
|
|
||||||
wants = [ "albyhub.service" ];
|
|
||||||
environment = pythonManagerEnvironment;
|
|
||||||
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "simple";
|
|
||||||
User = "albyhub";
|
|
||||||
Group = "albyhub";
|
|
||||||
ExecStart = "${config.services.sovranHub.webPackage}/bin/nwc-lnurl";
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
UMask = "0027";
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
PrivateTmp = true;
|
|
||||||
ProtectHome = true;
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
ReadOnlyPaths = [
|
|
||||||
"/var/lib/domains/lightning"
|
|
||||||
"/var/lib/albyhub/unlock-password"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.sovran-hub-web.environment = pythonManagerEnvironment;
|
|
||||||
environment.systemPackages = lib.mkBefore [ wrappedNwcWallet ];
|
|
||||||
|
|
||||||
sovran_systemsOS.domainRequirements = [
|
|
||||||
{
|
|
||||||
name = "lightning";
|
|
||||||
label = "Lightning Address Domain";
|
|
||||||
example = "pay.yourdomain.com";
|
|
||||||
needsDDNS = true;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Sovran Bitcoin integration layer — bridges Sovran_SystemsOS options to the
|
||||||
|
# Sovran_Bitcoin flake module.
|
||||||
|
#
|
||||||
|
# This is the ONLY place where OS-specific Bitcoin customizations live.
|
||||||
|
# All bitcoin service modules (bitcoind, electrs, lnd, rtl, btcpayserver,
|
||||||
|
# mempool, albyhub, lnurl) and vendored packages come from the Sovran_Bitcoin
|
||||||
|
# flake input.
|
||||||
|
#
|
||||||
|
# What this file does:
|
||||||
|
# 1. Maps sovran_systemsOS.services.bitcoin → sovran-bitcoin.enable
|
||||||
|
# 2. Maps sovran_systemsOS.features.* → sovran-bitcoin.features.*
|
||||||
|
# 3. Applies OS-specific overrides (Second_Drive paths, operator "free",
|
||||||
|
# forced wallet enable, Hub firewall port, domain requirements)
|
||||||
|
# 4. Wires the Sovran Hub's NWC environment to the Alby Hub service
|
||||||
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.sovran_systemsOS;
|
||||||
|
|
||||||
|
# ── NWC environment for the Sovran Hub web app ─────────────────
|
||||||
|
# The Hub's web package provides nwc-wallet and nwc-lnurl binaries that
|
||||||
|
# need to know where Alby Hub and LND are. Sovran_Bitcoin's modules
|
||||||
|
# handle the base Alby Hub service; this layers on the Hub-specific
|
||||||
|
# tooling environment.
|
||||||
|
lndRpcAddress = config.services.lnd.rpcAddress or "127.0.0.1";
|
||||||
|
lndRpcPort = toString (config.services.lnd.rpcPort or 10009);
|
||||||
|
lndCertPath = config.services.lnd.certPath or "/var/lib/lnd/tls.cert";
|
||||||
|
|
||||||
|
hubNwcEnvironment = {
|
||||||
|
NWC_ALBY_HUB_API_BASE = "http://127.0.0.1:18080";
|
||||||
|
NWC_LND_ADDRESS = "${lndRpcAddress}:${lndRpcPort}";
|
||||||
|
NWC_LND_CERT_FILE = lndCertPath;
|
||||||
|
NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
||||||
|
NWC_RELAY =
|
||||||
|
if cfg.features.haven
|
||||||
|
then "wss://haven.${config.networking.domain}/nostr"
|
||||||
|
else "wss://relay.getalby.com,wss://relay2.getalby.com";
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
# ── 1. Map OS options → Sovran_Bitcoin options ─────────────────
|
||||||
|
sovran-bitcoin = lib.mkIf cfg.services.bitcoin {
|
||||||
|
enable = true;
|
||||||
|
operatorName = "free";
|
||||||
|
bitcoindTorGossip = cfg.features.bitcoin-tor-gossip;
|
||||||
|
|
||||||
|
features = {
|
||||||
|
electrs = true;
|
||||||
|
lnd = true;
|
||||||
|
rtl = true;
|
||||||
|
btcpayserver = cfg.web.btcpayserver;
|
||||||
|
mempool = cfg.features.mempool;
|
||||||
|
nwc = cfg.features."nwc-wallets";
|
||||||
|
lnurl = cfg.features."nwc-wallets";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 2. Second_Drive data paths (OS-specific) ──────────────────
|
||||||
|
services.bitcoind = lib.mkIf cfg.services.bitcoin {
|
||||||
|
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||||
|
# The OS always enables the bitcoind wallet — the Hub and BTCPay need it.
|
||||||
|
disablewallet = lib.mkForce false;
|
||||||
|
};
|
||||||
|
|
||||||
|
services.electrs = lib.mkIf cfg.services.bitcoin {
|
||||||
|
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data";
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 3. Second_Drive mount dependencies ─────────────────────────
|
||||||
|
systemd.services.bitcoind = lib.mkIf cfg.services.bitcoin {
|
||||||
|
requires = [ "run-media-Second_Drive.mount" ];
|
||||||
|
after = [ "run-media-Second_Drive.mount" ];
|
||||||
|
serviceConfig.PrivateUsers = lib.mkForce false;
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.electrs = lib.mkIf cfg.services.bitcoin {
|
||||||
|
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
|
||||||
|
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
|
||||||
|
wants = [ "bitcoind.service" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.lnd = lib.mkIf cfg.services.bitcoin {
|
||||||
|
wants = [ "bitcoind.service" ];
|
||||||
|
# nix-bitcoin sets `requires = [ "bitcoind.service" ]`; the OS removes it
|
||||||
|
# so LND can start even if bitcoind is temporarily down.
|
||||||
|
requires = lib.mkForce [ ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 4. Permission fixup for Second_Drive ───────────────────────
|
||||||
|
systemd.services.sovran-btc-permissions = lib.mkIf cfg.services.bitcoin {
|
||||||
|
description = "Fix Bitcoin/Electrs data directory ownership on second drive";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "run-media-Second_Drive.mount" ];
|
||||||
|
before = [ "bitcoind.service" "electrs.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node ]; then
|
||||||
|
chown -R bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
|
||||||
|
fi
|
||||||
|
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data ]; then
|
||||||
|
chown -R electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
|
||||||
|
fi
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── 5. Firewall — Hub management port ──────────────────────────
|
||||||
|
networking.firewall.allowedTCPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
||||||
|
networking.firewall.allowedUDPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
||||||
|
|
||||||
|
# ── 6. NWC / LNURL — Sovran Hub integration ───────────────────
|
||||||
|
# Sovran_Bitcoin's albyhub.nix and lnurl.nix handle the base services.
|
||||||
|
# This section wires the Hub's web app environment so the Hub can
|
||||||
|
# display NWC status and the nwc-wallet CLI works from the Hub shell.
|
||||||
|
|
||||||
|
services.sovran-lnurl = lib.mkIf cfg.features."nwc-wallets" {
|
||||||
|
# The Hub writes the Lightning Address domain to this file.
|
||||||
|
domainFile = "/var/lib/domains/lightning";
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.sovran-hub-web.environment = lib.mkIf cfg.features."nwc-wallets"
|
||||||
|
hubNwcEnvironment;
|
||||||
|
|
||||||
|
# ── 7. Domain requirements ─────────────────────────────────────
|
||||||
|
sovran_systemsOS.domainRequirements = lib.mkIf cfg.services.bitcoin (
|
||||||
|
[
|
||||||
|
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
|
||||||
|
]
|
||||||
|
++ lib.optionals cfg.features."nwc-wallets" [
|
||||||
|
{ name = "lightning"; label = "Lightning Address Domain"; example = "pay.yourdomain.com"; needsDDNS = true; }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
{ lib
|
|
||||||
, buildGoModule
|
|
||||||
, fetchFromGitHub
|
|
||||||
, stdenv
|
|
||||||
}:
|
|
||||||
|
|
||||||
buildGoModule rec {
|
|
||||||
pname = "albyhub";
|
|
||||||
version = "1.24.0";
|
|
||||||
|
|
||||||
# sovran fork = upstream v1.24.0 + LND-only, no frontend, HOST bind,
|
|
||||||
# always-private route hints (the customization lives in the fork's commits)
|
|
||||||
src = fetchFromGitHub {
|
|
||||||
owner = "naturallaw777";
|
|
||||||
repo = "hub";
|
|
||||||
tag = "sovran-1.24.0";
|
|
||||||
# round 1: copy the "got: sha256-..." from the build error
|
|
||||||
hash = "sha256-9l7xK7iA1p9qQRClgNV+eYg/vYJxbwBiRObeLRpyfDo=";
|
|
||||||
};
|
|
||||||
|
|
||||||
# `go mod vendor` strips the secp256k1-zkp cgo headers (include/), so use
|
|
||||||
# the full module cache. Round 2: copy the "got: sha256-..." from the error
|
|
||||||
proxyVendor = true;
|
|
||||||
vendorHash = "sha256-U0NLVV0gH2IERqaGsJwE2f+iZQ35D+czH8mUKXSM4Wc=";
|
|
||||||
|
|
||||||
subPackages = [ "cmd/http" ];
|
|
||||||
|
|
||||||
# LND client needs cgo (secp256k1-zkp)
|
|
||||||
buildInputs = [ (lib.getLib stdenv.cc.cc) ];
|
|
||||||
|
|
||||||
# pin module downloads to a hermetic proxy and the local toolchain
|
|
||||||
# (otherwise GOPROXY/GOTOOLCHAIN can leak into the builder env and the
|
|
||||||
# cache comes back incomplete — the original failure)
|
|
||||||
overrideModAttrs = (finalAttrs: previousAttrs: {
|
|
||||||
modBuildPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
|
|
||||||
export GIT_SSL_CAINFO=$NIX_SSL_CERT_FILE
|
|
||||||
export GOPROXY=https://proxy.golang.org,direct
|
|
||||||
export GOSUMDB=sum.golang.org
|
|
||||||
export GOTOOLCHAIN=local
|
|
||||||
|
|
||||||
mkdir -p "$GOPATH/pkg/mod/cache/download"
|
|
||||||
go mod download all
|
|
||||||
|
|
||||||
export GOPROXY="file://$GOPATH/pkg/mod/cache/download"
|
|
||||||
|
|
||||||
go list ./cmd/http
|
|
||||||
|
|
||||||
mkdir -p vendor
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
});
|
|
||||||
|
|
||||||
ldflags = [
|
|
||||||
"-X github.com/getAlby/hub/version.Tag=${version}"
|
|
||||||
"-s"
|
|
||||||
"-w"
|
|
||||||
];
|
|
||||||
|
|
||||||
postInstall = ''
|
|
||||||
mv $out/bin/http $out/bin/albyhub
|
|
||||||
'';
|
|
||||||
|
|
||||||
preFixup = ''
|
|
||||||
# Go's external linker bakes the sandbox build dir into the RPATH;
|
|
||||||
# rewrite it to the real store paths before the fixup audit
|
|
||||||
patchelf --set-rpath ${
|
|
||||||
lib.makeLibraryPath [
|
|
||||||
(lib.getLib stdenv.cc.cc)
|
|
||||||
(lib.getLib stdenv.cc.libc)
|
|
||||||
]
|
|
||||||
} $out/bin/albyhub
|
|
||||||
'';
|
|
||||||
|
|
||||||
meta.mainProgram = "albyhub";
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# 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" ]))
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# 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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# 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;
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# 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.10";
|
|
||||||
|
|
||||||
src = fetchurl {
|
|
||||||
url = "https://github.com/Ride-The-Lightning/RTL/archive/refs/tags/v${self.version}.tar.gz";
|
|
||||||
hash = "sha256-r5riYV2FN0OKi0mwj9I1jBeeU1LOv2HVB6CEovPlUuY=";
|
|
||||||
};
|
|
||||||
|
|
||||||
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-NKiWcjqYcHBVIB+vbF3aKXLe2fJRmh/quu8obztP3TA=";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
{ 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"
|
|
||||||
''
|
|
||||||
@@ -27,17 +27,12 @@ def _literal_assignment(source: str, name: str):
|
|||||||
|
|
||||||
|
|
||||||
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
|
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
|
||||||
def test_bitcoind_loopback_listener_is_always_enabled(self):
|
def test_integration_layer_maps_tor_gossip_option(self):
|
||||||
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
"""The integration layer must bridge sovran_systemsOS.features.bitcoin-tor-gossip
|
||||||
self.assertIn("listen = true;", ecosystem)
|
to sovran-bitcoin.bitcoindTorGossip."""
|
||||||
self.assertIn("peerbloomfilters=1", ecosystem)
|
integration = _read("modules", "sovran-bitcoin-integration.nix")
|
||||||
|
self.assertIn("bitcoindTorGossip", integration)
|
||||||
def test_gossip_is_opt_in(self):
|
self.assertIn("bitcoin-tor-gossip", integration)
|
||||||
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
|
||||||
self.assertIn(
|
|
||||||
"public = config.sovran_systemsOS.features.bitcoin-tor-gossip;",
|
|
||||||
ecosystem,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_hub_option_and_evaluated_state_are_declared(self):
|
def test_hub_option_and_evaluated_state_are_declared(self):
|
||||||
roles = _read("modules", "core", "roles.nix")
|
roles = _read("modules", "core", "roles.nix")
|
||||||
|
|||||||
@@ -333,33 +333,6 @@ class TestSshPubkeyValidation(unittest.TestCase):
|
|||||||
_validate_ssh_pubkey("ssh-ed25519")
|
_validate_ssh_pubkey("ssh-ed25519")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# LND macaroon command-line safety
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestLndMacaroonCommandLineSafety(unittest.TestCase):
|
|
||||||
"""The LND admin macaroon must never be exposed in curl's argv."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
path = os.path.join(_REPO_ROOT, "modules", "bitcoin", "lnd.nix")
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
cls.lnd_module = f.read()
|
|
||||||
|
|
||||||
def test_admin_macaroon_not_interpolated_into_header_argument(self):
|
|
||||||
self.assertNotIn(
|
|
||||||
'-H "Grpc-Metadata-macaroon: $(',
|
|
||||||
self.lnd_module,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_admin_macaroon_header_is_passed_via_file_descriptor(self):
|
|
||||||
self.assertIn("adminMacaroonHex=$(", self.lnd_module)
|
|
||||||
self.assertIn(
|
|
||||||
"""-H @<(printf 'Grpc-Metadata-macaroon: %s\\n' "$adminMacaroonHex")""",
|
|
||||||
self.lnd_module,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Auth-exempt paths
|
# Auth-exempt paths
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Regression tests for the Hub Zeus Connect QR.
|
"""Regression tests for the Hub Zeus Connect QR.
|
||||||
|
|
||||||
The LND-only rewrite of modules/bitcoin/lndconnect.nix shipped a wrapper
|
The Zeus Connect setup service (modules/wallet-autoconnect.nix) and the Hub QR
|
||||||
that Zeus cannot use. These tests lock the contract the Hub QR depends on
|
encoding are tested here. The lndconnect wrapper itself is now part of the
|
||||||
without needing lnd / tor / qrencode at test time.
|
Sovran_Bitcoin flake — its contract tests live in that repository.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -17,41 +17,6 @@ def _read(relpath: str) -> str:
|
|||||||
return fh.read()
|
return fh.read()
|
||||||
|
|
||||||
|
|
||||||
class TestLndconnectWrapper(unittest.TestCase):
|
|
||||||
"""The system `lndconnect` wrapper must emit a Zeus-scannable URI."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
cls.src = _read("modules/bitcoin/lndconnect.nix")
|
|
||||||
|
|
||||||
def test_uses_official_lndconnect_flags(self):
|
|
||||||
self.assertIn("--adminmacaroonpath=", self.src)
|
|
||||||
self.assertIn("--configfile=/dev/null", self.src)
|
|
||||||
self.assertIn("--nocert", self.src)
|
|
||||||
self.assertIn("--tlscertpath=", self.src)
|
|
||||||
|
|
||||||
def test_does_not_pass_unknown_short_flags(self):
|
|
||||||
# The broken rewrite called `lndconnect --cert … --macaroon …`.
|
|
||||||
# Those flags do not exist; Zeus then never got a valid URI.
|
|
||||||
self.assertIsNone(re.search(r"--cert=", self.src))
|
|
||||||
self.assertIsNone(re.search(r"--macaroon=", self.src))
|
|
||||||
|
|
||||||
def test_uses_dedicated_lnd_rest_onion(self):
|
|
||||||
self.assertIn("lnd-rest", self.src)
|
|
||||||
self.assertIn('onionServices.lnd-rest', self.src)
|
|
||||||
# Must not collide with the LND P2P onion named `lnd`.
|
|
||||||
self.assertNotIn("onionServices.lnd =", self.src)
|
|
||||||
self.assertNotIn('onionService = "${operatorName}/lnd"', self.src)
|
|
||||||
|
|
||||||
def test_reads_onion_from_onion_addresses_dir(self):
|
|
||||||
self.assertIn("onionAddresses.dataDir", self.src)
|
|
||||||
self.assertNotIn("/var/lib/tor/onion/${onionService}/hostname", self.src)
|
|
||||||
|
|
||||||
def test_omits_tls_cert_over_tor(self):
|
|
||||||
# Onion host + embedded localhost cert = Zeus rejects the QR.
|
|
||||||
self.assertIn('then "--nocert"', self.src)
|
|
||||||
|
|
||||||
|
|
||||||
class TestZeusConnectSetup(unittest.TestCase):
|
class TestZeusConnectSetup(unittest.TestCase):
|
||||||
"""zeus-connect-setup must wait for the REST onion and validate the URI."""
|
"""zeus-connect-setup must wait for the REST onion and validate the URI."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user