Compare commits
19
Commits
v1.1.3
...
staging-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d46d036c0 | ||
|
|
9ae4e34fe0 | ||
|
|
d1e226a687 | ||
|
|
49e41eeea9 | ||
|
|
88be5b99dd | ||
|
|
93492e88fb | ||
|
|
7da37cdb34 | ||
|
|
f34d1533c3 | ||
|
|
220d6dff2c | ||
|
|
81ab3b2280 | ||
|
|
d5d40a1697 | ||
|
|
3756a0058d | ||
|
|
be5d47cb9c | ||
|
|
ddf87a1c1c | ||
|
|
f0e4c33a5f | ||
|
|
a6ef13688e | ||
|
|
ef1c045e0e | ||
|
|
e27bf0ef1f | ||
|
|
362fa0b36c |
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [1.1.4] - 2026-09-02
|
||||
|
||||
### Added
|
||||
- Element-calling: fix Nix string interpolation of LAN_CIDR echo
|
||||
- Docs/hub: update Element Calling port guidance to the new port set
|
||||
- Element-calling: fix Wi-Fi calls and tighten media/TURN ports
|
||||
- Flake: pin LiveKit to 1.13.6 for rtc.advertise_internal_ip support
|
||||
|
||||
### Changed
|
||||
- Updated nixpkgs
|
||||
- Updated sovran-bitcon
|
||||
- Dedupe NWC tooling — use Sovran_Bitcoin's sovran-nwc
|
||||
- Update sovran-bitcoin flake input
|
||||
- Extract bitcoin stack into Sovran_Bitcoin flake input
|
||||
|
||||
### Fixed
|
||||
- Set lnurl domainFile for Hub-managed Lightning Address domain
|
||||
- Correct disablewallet casing
|
||||
- Fixed typo
|
||||
[1.1.4]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.4
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- Element Calling: pin LiveKit to 1.13.6 and advertise the LAN interface
|
||||
(rtc.advertise_internal_ip) so Wi-Fi callers get a local ICE candidate and
|
||||
calls no longer depend on router NAT hairpin
|
||||
- Element Calling: use a single UDP media mux (7882); move the TURN relay to
|
||||
40000-40099 and remove the unreachable 5349 TURN/TLS listener
|
||||
- Hub: update Element Calling router-port guidance to the new port set
|
||||
|
||||
### Fixed
|
||||
- Element Calling: TURN relay could not deliver media to the SFU on routers
|
||||
without NAT loopback (turn.allow_restricted_peer_cidrs now permits the LAN
|
||||
subnet derived from the primary interface)
|
||||
|
||||
## [1.1.3] - 2026-08-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
|
||||
are ready.
|
||||
|
||||
[Visit the Website](https://sovransystems.com) ·
|
||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso) ·
|
||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) ·
|
||||
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso.sha256) ·
|
||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso.sha256) ·
|
||||
[Build from Source](#build-from-source)
|
||||
|
||||
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||
@@ -345,8 +345,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
||||
|
||||
### 1. Download the ISO and checksum
|
||||
|
||||
- [Download Sovran_SystemsOS-1.1.3.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso)
|
||||
- [Download Sovran_SystemsOS-1.1.3.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso.sha256)
|
||||
- [Download Sovran_SystemsOS-1.1.4.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso)
|
||||
- [Download Sovran_SystemsOS-1.1.4.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso.sha256)
|
||||
|
||||
The download may take some time. Do not rename or modify the ISO before
|
||||
verifying it, and keep both files in the same folder.
|
||||
@@ -364,16 +364,16 @@ checksum exactly.
|
||||
Open a terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
sha256sum --check Sovran_SystemsOS-1.1.3.iso.sha256
|
||||
sha256sum --check Sovran_SystemsOS-1.1.4.iso.sha256
|
||||
```
|
||||
|
||||
A successful comparison reports:
|
||||
|
||||
```text
|
||||
Sovran_SystemsOS-1.1.3.iso: OK
|
||||
Sovran_SystemsOS-1.1.4.iso: OK
|
||||
```
|
||||
|
||||
You can also run `sha256sum Sovran_SystemsOS-1.1.3.iso` and compare the output
|
||||
You can also run `sha256sum Sovran_SystemsOS-1.1.4.iso` and compare the output
|
||||
against the checksum file manually.
|
||||
|
||||
</details>
|
||||
@@ -384,11 +384,11 @@ against the checksum file manually.
|
||||
Open Terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
shasum -a 256 Sovran_SystemsOS-1.1.3.iso
|
||||
shasum -a 256 Sovran_SystemsOS-1.1.4.iso
|
||||
```
|
||||
|
||||
Compare the value shown in Terminal with the value inside
|
||||
`Sovran_SystemsOS-1.1.3.iso.sha256`.
|
||||
`Sovran_SystemsOS-1.1.4.iso.sha256`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -398,7 +398,7 @@ Compare the value shown in Terminal with the value inside
|
||||
Open PowerShell in the download folder and run:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\Sovran_SystemsOS-1.1.3.iso -Algorithm SHA256
|
||||
Get-FileHash .\Sovran_SystemsOS-1.1.4.iso -Algorithm SHA256
|
||||
```
|
||||
|
||||
Compare the value under `Hash` with the published checksum.
|
||||
@@ -413,7 +413,7 @@ match exactly.
|
||||
|
||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||
connect the USB drive.
|
||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.3.iso`.
|
||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.4.iso`.
|
||||
3. Choose **Select target**, select the USB drive, and review your selection
|
||||
carefully.
|
||||
4. Choose **Flash** and wait for the writing and verification process to
|
||||
@@ -510,13 +510,15 @@ setup, supported hardware, and Royal Membership.
|
||||
|
||||
## For developers
|
||||
|
||||
Sovran_SystemsOS combines [NixOS](https://nixos.org), an in-repository
|
||||
Bitcoin and Lightning stack, desktop packages from
|
||||
Sovran_SystemsOS combines [NixOS](https://nixos.org), the standalone
|
||||
[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
|
||||
Sovran Hub. The Bitcoin modules under `modules/bitcoin/` were adapted from
|
||||
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) and are now maintained
|
||||
here. Builds no longer import or fetch nix-bitcoin. Legacy `nix-bitcoin.*`
|
||||
option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||
Sovran Hub. The Bitcoin and Lightning modules live in the Sovran_Bitcoin
|
||||
repository and are consumed as a flake input. OS-specific Bitcoin customizations
|
||||
(Second_Drive paths, operator user, Hub integration) are bridged by
|
||||
`modules/sovran-bitcoin-integration.nix`. Legacy `nix-bitcoin.*` option names
|
||||
and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||
|
||||
### 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)
|
||||
for declarative, pinned system configuration
|
||||
- `modules/bitcoin/` for the in-repository Bitcoin and Lightning stack
|
||||
- `packages/` for Sovran-maintained package definitions and patches
|
||||
- [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) for the
|
||||
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
|
||||
Sparrow, Bisq, and Bisq 2 packages
|
||||
- [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 |
|
||||
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
||||
| `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 |
|
||||
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
||||
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
||||
| `iso/` | Installer configuration, installer code, and installer assets |
|
||||
| `packages/` | Sovran-maintained package definitions and patches |
|
||||
| `tests/` | Security and Nix integration checks |
|
||||
| `assets/` | Documentation images |
|
||||
| `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/` |
|
||||
| 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` |
|
||||
| Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` |
|
||||
| Matrix Synapse | `modules/synapse.nix` |
|
||||
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
||||
| Optional Haven Nostr relay | `modules/haven.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` |
|
||||
|
||||
Feature availability and defaults may change as Sovran_SystemsOS develops.
|
||||
@@ -856,7 +856,7 @@ primary location for collaboration. Please read our
|
||||
## Privacy. Sovereignty. Bitcoin.
|
||||
|
||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso) ·
|
||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.4.iso) ·
|
||||
[View the License](LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
+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
|
||||
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
|
||||
adapted from nix-bitcoin, but Sovran builds do not import or fetch nix-bitcoin.
|
||||
The `nix-bitcoin.*` option namespace and `/etc/nix-bitcoin-secrets` path remain
|
||||
only for upgrade compatibility.
|
||||
Bitcoin and Lightning modules are maintained in the standalone
|
||||
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||
and consumed as a flake input. OS-specific customizations (Second_Drive paths,
|
||||
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
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
## nix-bitcoin
|
||||
|
||||
Portions of `modules/bitcoin/` and selected package definitions were adapted
|
||||
from [fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
||||
The Bitcoin and Lightning modules were originally adapted from
|
||||
[fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
||||
[`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
|
||||
|
||||
|
||||
@@ -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 . 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 .security_helpers import (
|
||||
_nix_escape,
|
||||
@@ -291,11 +291,10 @@ FEATURE_REGISTRY = [
|
||||
"port_requirements": [
|
||||
{"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
|
||||
{"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"},
|
||||
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
|
||||
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
|
||||
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
|
||||
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
|
||||
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
|
||||
{"port": "7881", "protocol": "TCP", "description": "WebRTC media (TCP fallback)"},
|
||||
{"port": "7882", "protocol": "UDP", "description": "WebRTC media (UDP)"},
|
||||
{"port": "3478", "protocol": "UDP", "description": "TURN relay + STUN"},
|
||||
{"port": "40000-40099", "protocol": "UDP", "description": "TURN relay (WebRTC media)"},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -395,11 +394,10 @@ FEATURE_SERVICE_MAP = {
|
||||
|
||||
# Port requirements for service tiles (keyed by unit name or icon)
|
||||
_PORTS_ELEMENT_CALLING = [
|
||||
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
|
||||
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
|
||||
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
|
||||
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
|
||||
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
|
||||
{"port": "7881", "protocol": "TCP", "description": "WebRTC media (TCP fallback)"},
|
||||
{"port": "7882", "protocol": "UDP", "description": "WebRTC media (UDP)"},
|
||||
{"port": "3478", "protocol": "UDP", "description": "TURN relay + STUN"},
|
||||
{"port": "40000-40099", "protocol": "UDP", "description": "TURN relay (WebRTC media)"},
|
||||
]
|
||||
|
||||
# Units whose port requirements exist purely so the user can forward them in
|
||||
@@ -910,11 +908,68 @@ def _get_remote_rev(branch=None):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_version(text):
|
||||
"""Return a (major, minor, patch) tuple from a VERSION string, or None."""
|
||||
try:
|
||||
match = re.search(r"(\d+)\.(\d+)\.(\d+)", str(text))
|
||||
if match:
|
||||
return tuple(int(g) for g in match.groups())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_remote_version(branch=None):
|
||||
"""Read VERSION on the tracked branch, e.g. the stable release version."""
|
||||
try:
|
||||
ref = branch or "stable"
|
||||
url = (
|
||||
"https://git.sovransystems.com/api/v1/repos/"
|
||||
"Sovran_Systems/Sovran_SystemsOS/raw/VERSION?ref="
|
||||
+ urllib.parse.quote(ref)
|
||||
)
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return _parse_version(resp.read().decode())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_for_updates() -> bool | None:
|
||||
"""Whether an update is available.
|
||||
|
||||
Primary signal: the flake lock's pinned Sovran_Systems rev differs from
|
||||
the remote branch head. BUT a failed update rewrites ``flake.lock`` (the
|
||||
``nix flake update`` step) without staging a generation (the
|
||||
``nixos-rebuild boot`` step failed), so after a failure the lock and the
|
||||
remote agree while the *running* system is still on the old version. In
|
||||
that case the rev comparison alone reports a false "up to date" and hides
|
||||
the failed update from the dashboard.
|
||||
|
||||
Backstop: compare the running Hub version against the branch VERSION.
|
||||
A newer released version while running an older one means the update did
|
||||
not apply (build failed, reboot skipped, generation rolled back) and must
|
||||
be offered again.
|
||||
"""
|
||||
locked_rev, branch = _get_locked_info()
|
||||
remote_rev = _get_remote_rev(branch)
|
||||
if locked_rev and remote_rev:
|
||||
return locked_rev != remote_rev
|
||||
rev_differs = locked_rev != remote_rev
|
||||
if rev_differs:
|
||||
return True
|
||||
# Revs match — make sure the pinned (failed) rev isn't masking an
|
||||
# older *running* system.
|
||||
running_ver = _parse_version(_get_sovran_version())
|
||||
remote_ver = _get_remote_version(branch)
|
||||
if running_ver and remote_ver and remote_ver > running_ver:
|
||||
return True
|
||||
return False
|
||||
# Couldn't compare revs — fall back to the version backstop.
|
||||
running_ver = _parse_version(_get_sovran_version())
|
||||
remote_ver = _get_remote_version(branch)
|
||||
if running_ver and remote_ver:
|
||||
return remote_ver > running_ver
|
||||
return None # inconclusive — couldn't read lock or reach remote
|
||||
|
||||
|
||||
@@ -1114,7 +1169,7 @@ def _get_firewall_allowed_ports() -> dict[str, set[int]]:
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
for line in proc.stdout.splitlines():
|
||||
# e.g. ACCEPT tcp -- ... dpt:443 or dpts:7882:7894
|
||||
# e.g. ACCEPT tcp -- ... dpt:443 or dpts:40000:40099
|
||||
m = re.search(r'(tcp|udp).*dpts?:(\d+)(?::(\d+))?', line)
|
||||
if m:
|
||||
proto_match = m.group(1)
|
||||
@@ -1172,7 +1227,7 @@ def _resolve_all_addresses_cached(domain: str) -> list[str]:
|
||||
|
||||
|
||||
def _port_range_to_ints(port_str: str) -> list[int]:
|
||||
"""Convert a port string like ``"443"``, ``"30000-40000"`` to a list of ints."""
|
||||
"""Convert a port string like ``"443"``, ``"40000-40099"`` to a list of ints."""
|
||||
port_str = port_str.strip()
|
||||
if re.match(r'^\d+$', port_str):
|
||||
return [int(port_str)]
|
||||
@@ -3887,6 +3942,11 @@ async def api_updates_check():
|
||||
# Avoid a slow remote update check when there is already an operation
|
||||
# the dashboard needs to surface.
|
||||
return {"available": True, "status": status.lower()}
|
||||
if status == "FAILED":
|
||||
# The last update did not complete (build failed). Keep offering the
|
||||
# update so the user can re-run it rather than silently landing on a
|
||||
# false "up to date".
|
||||
return {"available": True, "status": "failed"}
|
||||
|
||||
available = await loop.run_in_executor(None, check_for_updates)
|
||||
# None means inconclusive (check failed) — report as available so the UI doesn't block
|
||||
@@ -3964,8 +4024,15 @@ async def api_updates_run():
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Re-read status: a prior failed update leaves flake.lock advanced even
|
||||
# though no generation was staged, so the rev-based check below can say
|
||||
# "no updates" even though the system is still old. A failed update must
|
||||
# always be re-runnable to recover.
|
||||
persisted_status = await loop.run_in_executor(None, _read_update_status)
|
||||
last_failed = persisted_status == "FAILED"
|
||||
|
||||
available = await loop.run_in_executor(None, check_for_updates)
|
||||
if available is False: # only block when positively confirmed no updates
|
||||
if available is False and not last_failed: # only block when positively confirmed no updates
|
||||
# Clear stale status/log so they don't contaminate future modal opens.
|
||||
_write_update_status("IDLE")
|
||||
try:
|
||||
|
||||
@@ -7,6 +7,7 @@ if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
||||
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
||||
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.addEventListener("click", retryUpdateStatus);
|
||||
if ($btnRetryRun) $btnRetryRun.addEventListener("click", retryUpdateRun);
|
||||
|
||||
// Browser timers and requests may be suspended while an RDP session/tab is in
|
||||
// the background. Reconcile immediately when the user returns instead of
|
||||
|
||||
@@ -67,7 +67,7 @@ function renderPortForwardGuideHtml(ports, opts) {
|
||||
var noteClass = opts.noteClass || "port-req-hint";
|
||||
var ipHtml = opts.internalIp
|
||||
? '<code class="port-req-internal-ip">' + escHtml(opts.internalIp) + '</code>'
|
||||
: 'this computer’s <strong>internal IP</strong> (shown as “Internal IP” at the top of the Hub dashboard)';
|
||||
: 'this computer’s <strong>internal IP</strong>';
|
||||
|
||||
var rows = (ports || []).map(function(p) {
|
||||
return '<tr>' +
|
||||
@@ -78,26 +78,17 @@ function renderPortForwardGuideHtml(ports, opts) {
|
||||
}).join("");
|
||||
|
||||
var forWhat = opts.serviceName
|
||||
? 'For <strong>' + escHtml(opts.serviceName) + '</strong> to be reachable from outside your home network, open'
|
||||
: 'Open';
|
||||
? 'To make <strong>' + escHtml(opts.serviceName) + '</strong> reachable from outside your home, forward these ports to ' + ipHtml + ':'
|
||||
: 'Forward these ports to ' + ipHtml + ':';
|
||||
|
||||
return '<p class="' + introClass + '">' +
|
||||
forWhat + ' the ports below in your router’s <strong>port forwarding</strong> settings ' +
|
||||
'and point them at ' + ipHtml + '.' +
|
||||
return '<p class="' + introClass + '">' + forWhat + '</p>' +
|
||||
'<p class="port-req-steps" style="margin-top:6px;margin-bottom:10px;font-size:0.92em;color:#555;">' +
|
||||
'Set the internal and external port to the <strong>same number</strong>. Match <strong>TCP</strong> or <strong>UDP</strong> exactly. For ranges like <strong>40000-40099</strong>, use your router’s range fields (start 40000, end 40099).' +
|
||||
'</p>' +
|
||||
'<ul class="port-req-steps">' +
|
||||
'<li>Set the <strong>internal (private) port</strong> and the <strong>external (public) port</strong> to the <strong>same number</strong>.</li>' +
|
||||
'<li>Match the <strong>protocol</strong> exactly — a rule set to TCP will not pass UDP traffic. Where the table says <strong>TCP + UDP</strong>, create both rules (or pick “Both”/“TCP/UDP” if your router offers it).</li>' +
|
||||
'<li>For a range such as <strong>30000-40000</strong>, use your router’s port-range fields — start 30000, end 40000 — rather than one rule per port.</li>' +
|
||||
'</ul>' +
|
||||
'<table class="' + tableClass + '">' +
|
||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
'<p class="' + noteClass + '">' +
|
||||
'📱 <strong>How to confirm it worked:</strong> forwarding happens on your router, so it can only be verified from outside your network. ' +
|
||||
'Turn Wi-Fi off on your phone and open the service over mobile data — if it loads, your ports are open.' +
|
||||
'</p>';
|
||||
'</table>';
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
|
||||
@@ -53,6 +53,7 @@ const $modalLog = document.getElementById("modal-log");
|
||||
const $btnReboot = document.getElementById("btn-reboot");
|
||||
const $btnSave = document.getElementById("btn-save-report");
|
||||
const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
|
||||
const $btnRetryRun = document.getElementById("btn-retry-update");
|
||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||
|
||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||
|
||||
@@ -275,7 +275,13 @@ async function checkUpdates() {
|
||||
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
||||
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
||||
if (sidebarUpdateBtn) {
|
||||
if (updateStatus === "reboot_required") {
|
||||
if (updateStatus === "failed") {
|
||||
// Last update errored and did not apply — surface it as a persistent
|
||||
// red banner that re-opens the failed run with a "Retry Update" action.
|
||||
sidebarUpdateBtn.style.borderColor = "#e01b24";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(224, 27, 36, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update failed — click to retry";
|
||||
} else if (updateStatus === "reboot_required") {
|
||||
sidebarUpdateBtn.style.borderColor = "#e5a50a";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(229, 165, 10, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
|
||||
|
||||
@@ -14,7 +14,10 @@ async function openUpdateModal() {
|
||||
{ cache: "no-store" },
|
||||
STATUS_POLL_FETCH_TIMEOUT
|
||||
);
|
||||
if (current.running || current.result === "reboot_required") {
|
||||
if (current.running || current.result === "reboot_required" || current.result === "failed") {
|
||||
// An in-progress update, a staged update awaiting reboot, or a prior
|
||||
// failed update — reattach to the persisted systemd/log state instead of
|
||||
// starting over or wrongly reporting "up to date".
|
||||
showExistingUpdate(current);
|
||||
return;
|
||||
}
|
||||
@@ -41,6 +44,7 @@ async function openUpdateModal() {
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
$modal.classList.add("open");
|
||||
return;
|
||||
@@ -69,6 +73,7 @@ function prepareUpdateModal() {
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||
$modal.classList.add("open");
|
||||
}
|
||||
@@ -154,6 +159,7 @@ function startUpdate() {
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
_updateFinished = true;
|
||||
return;
|
||||
@@ -283,6 +289,16 @@ function retryUpdateStatus() {
|
||||
startUpdatePoll();
|
||||
}
|
||||
|
||||
// Re-run a failed (or never-applied) update from scratch. The backend always
|
||||
// allows this after a FAILED attempt even though flake.lock may already be
|
||||
// advanced (the previous build never staged a bootable generation).
|
||||
function retryUpdateRun() {
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
_doOpenUpdateModal();
|
||||
}
|
||||
|
||||
function resumeUpdateStatusAfterInterruption() {
|
||||
if (!$modal || !$modal.classList.contains("open")) return;
|
||||
if (_updateStatusUnavailable) {
|
||||
@@ -304,9 +320,10 @@ function onUpdateDone(result) {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — restart required";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
||||
if ($modalStatus) $modalStatus.textContent = "✗ Update failed — your system was not changed. Run the update again or save the error report for support.";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "inline-flex";
|
||||
if ($btnSave) $btnSave.style.display = "inline-flex";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-save" id="btn-retry-update-status" style="display:none">Retry Status</button>
|
||||
<button class="btn btn-reboot" id="btn-retry-update" style="display:none">Retry Update</button>
|
||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
||||
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"albyhub.service": "1.24.0",
|
||||
"mempool.service": "3.2.1",
|
||||
"matrix-synapse.service": "1.115.0",
|
||||
"livekit.service": "1.5.2",
|
||||
"livekit.service": "1.13.6",
|
||||
"vaultwarden.service": "1.32.0",
|
||||
"haven-relay.service": "0.1.0"
|
||||
}
|
||||
|
||||
+1
-1
@@ -147,7 +147,7 @@
|
||||
hunspell hunspellDicts.en_US
|
||||
synadm brave-origin dua
|
||||
gparted pv unzip parted screen zenity
|
||||
libargon2 gnome-terminal libreoffice-fresh
|
||||
libargon2 gnome-terminal libreoffice-stable
|
||||
dig firefox wp-cli axel
|
||||
lk-jwt-service livekit-libwebrtc livekit
|
||||
matrix-synapse age onlyoffice-desktopeditors
|
||||
|
||||
Generated
+74
-22
@@ -5,11 +5,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787839684,
|
||||
"narHash": "sha256-xTafqsVs2/zyGsaCel41QT5Lpbs/rG56Kp+YsmLXL8I=",
|
||||
"lastModified": 1788527183,
|
||||
"narHash": "sha256-Jvuh4VWR8Ep+UlX1siyiMWJAn9fPNZKN5IxzVOnGAec=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "btc-clients-nix",
|
||||
"rev": "ef81159876930802ef9861aa5c039cec6886a30f",
|
||||
"rev": "761b147fd7038b210251b16cb2b4e3fe5e9b603b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -26,11 +26,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785627969,
|
||||
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
||||
"lastModified": 1787559586,
|
||||
"narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
||||
"rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -41,11 +41,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1785590095,
|
||||
"narHash": "sha256-CNO2szJbdLjVN/Hi1BML9MSALz1GM2fIdwnzs404QO8=",
|
||||
"lastModified": 1788179970,
|
||||
"narHash": "sha256-r5LmxzIhsu5+oDybatN/HJ8roYOKjb2Apa5xI6v46VU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e568f3b19d54b08f48bfae9b12b3e124d1a28002",
|
||||
"rev": "1db62ab7d2ccf1916bbf7deb61fc9d16f1c4ab49",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -56,11 +56,27 @@
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1787753485,
|
||||
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
|
||||
"lastModified": 1788690626,
|
||||
"narHash": "sha256-+v4I4LawmRD/mVxO7QIAerRrCkElp3YImzWkkUnvOTg=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
|
||||
"rev": "c25784012c9982bca5b3e0de87e90bbdac8927d3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-stable_2": {
|
||||
"locked": {
|
||||
"lastModified": 1788297115,
|
||||
"narHash": "sha256-Z+vUNbfd2FIKkWOTkcT7RYlh3oFCnig/d2eXD1SWf2E=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a3116115851d68b8952a2a4221cc25a84e56b532",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -72,11 +88,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1787498568,
|
||||
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=",
|
||||
"lastModified": 1788614874,
|
||||
"narHash": "sha256-7QYjT2vHLuX9Z1pdxHXDKCbh1CR3D/2rywB9Tx0MPRg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188",
|
||||
"rev": "c043004d1c6985732bcc1cbc5a9c9aecbbb4e0f0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -88,11 +104,11 @@
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1787364730,
|
||||
"narHash": "sha256-NcYt9QJfpJiF1lAyN8BDPB4EeScbPU+EwQqPiBElrpU=",
|
||||
"lastModified": 1787631388,
|
||||
"narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a831408e6378bc02ebf8cc09b52c96ca86f6bab4",
|
||||
"rev": "ac6b2166e7a9375683b8e98f860f273222337b16",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -102,6 +118,22 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1788179007,
|
||||
"narHash": "sha256-hn1oU2rue2SYK8dAr8+WNZWtbsz1S2W5mnHlSEuh3bo=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "34ab99075ac4f7e40cf037eef32cb1c360bb85e9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixvim": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
@@ -109,11 +141,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787691219,
|
||||
"narHash": "sha256-CjeJTOjrluiDuL0W/YBFkSBOQjBYmdKwj7hWAJ4PIh0=",
|
||||
"lastModified": 1788190018,
|
||||
"narHash": "sha256-59BAfH0txPAZrPBF4QJqwvUWppD+ICrcjA1LZAmPnrQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "ebec1ae277a50b16e7d6682edd009ef585d68261",
|
||||
"rev": "41844750e55f17b1385d5b09ca7ade5f11f49506",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -127,7 +159,27 @@
|
||||
"btc-clients": "btc-clients",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"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": 1788796120,
|
||||
"narHash": "sha256-SLxKMbMPf0MN4tVLAURr6p5mNsieXH8dzmMZisqljmM=",
|
||||
"owner": "naturallaw777",
|
||||
"repo": "Sovran_Bitcoin",
|
||||
"rev": "17cc04f5229563cb06f7e1843ccb8994af1df7f9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "naturallaw777",
|
||||
"repo": "Sovran_Bitcoin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
nixvim.url = "github:nix-community/nixvim";
|
||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||
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
|
||||
overlay-stable = final: prev: {
|
||||
@@ -16,6 +19,24 @@
|
||||
system = prev.stdenv.hostPlatform.system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
|
||||
# Pin LiveKit to 1.13.6: element-calling.nix sets
|
||||
# rtc.advertise_internal_ip, which gives LAN callers a host candidate
|
||||
# so calls work on Wi-Fi without the router needing NAT-hairpin. That
|
||||
# flag is only honoured when node_ip is set manually from LiveKit
|
||||
# v1.13.6 (mediatransportutil f234b53); nixpkgs-unstable currently
|
||||
# ships 1.13.5. Remove this override once nixpkgs-unstable reaches
|
||||
# >= 1.13.6.
|
||||
livekit = prev.livekit.overrideAttrs (old: {
|
||||
version = "1.13.6";
|
||||
src = prev.fetchFromGitHub {
|
||||
owner = "livekit";
|
||||
repo = "livekit";
|
||||
rev = "v1.13.6";
|
||||
hash = "sha256-sUAx6ooeEUUqot5xuZv7xiQa3DdRFVULteTwYgFUzCI=";
|
||||
};
|
||||
vendorHash = "sha256-nOGSmoNuQQm/sIVI1HojsiS4GkbhA68uYMQ6X7d4a5Q=";
|
||||
});
|
||||
};
|
||||
in
|
||||
{
|
||||
@@ -33,7 +54,7 @@
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
./iso/common.nix
|
||||
./modules/bitcoin
|
||||
sovran-bitcoin.nixosModules.default
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
};
|
||||
@@ -44,7 +65,8 @@
|
||||
nixpkgs.overlays = [ overlay-stable ];
|
||||
})
|
||||
./configuration.nix
|
||||
./modules/bitcoin
|
||||
sovran-bitcoin.nixosModules.default
|
||||
./modules/sovran-bitcoin-integration.nix
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
config = {
|
||||
@@ -60,17 +82,9 @@
|
||||
pkgs = import nixpkgs {
|
||||
system = "x86_64-linux";
|
||||
};
|
||||
fetchNodeModules =
|
||||
pkgs.callPackage ./packages/build-support/fetch-node-modules.nix {};
|
||||
mempoolPkgs =
|
||||
pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; };
|
||||
in {
|
||||
bitcoin-btcpay-hardening = import ./tests/bitcoin-btcpay-hardening.nix {
|
||||
inherit nixpkgs overlay-stable;
|
||||
};
|
||||
mempool-backend = mempoolPkgs.mempool-backend;
|
||||
mempool-frontend = mempoolPkgs.mempool-frontend;
|
||||
rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; };
|
||||
# Bitcoin hardening and package checks now live in the Sovran_Bitcoin flake.
|
||||
# Run them with: nix build github:naturallaw777/Sovran_Bitcoin#checks.x86_64-linux
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
# 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
|
||||
# without enabling any Bitcoin services.
|
||||
{
|
||||
|
||||
+91
-43
@@ -135,12 +135,13 @@ let
|
||||
"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";
|
||||
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
||||
# Evaluate locally vendored packages directly so we don't accidentally
|
||||
# pick up older/different versions from upstream nixpkgs.
|
||||
"rtl.service" = (pkgs.callPackage ../../packages/rtl { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).version;
|
||||
# Vendored packages come from the Sovran_Bitcoin flake overlay
|
||||
# (pkgs.sovran-bitcoin.*). Evaluate them directly so the Hub shows
|
||||
# the exact versions this system ships.
|
||||
"rtl.service" = pkgs.sovran-bitcoin.rtl.version;
|
||||
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
||||
"albyhub.service" = (pkgs.callPackage ../../packages/albyhub {}).version;
|
||||
"mempool.service" = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).version;
|
||||
"albyhub.service" = pkgs.sovran-bitcoin.albyhub.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";
|
||||
"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";
|
||||
@@ -149,6 +150,61 @@ let
|
||||
"haven-relay.service" = if pkgs ? haven-relay then pkgs.haven-relay.version else (if pkgs ? haven then pkgs.haven.version else "0.1.0");
|
||||
});
|
||||
|
||||
# Shared shell prelude used by both the update and rebuild wrapper scripts.
|
||||
# A flake/package fetch that is interrupted (network blip, reboot
|
||||
# mid-download, disk filled, hiccup on the remote) can leave a truncated
|
||||
# tarball or partial git clone in Nix's download caches. Nix then reuses the
|
||||
# corrupt archive on every retry and dies with "cannot read file from
|
||||
# tarball: Truncated tar archive detected" — a failure that is NOT fixed by
|
||||
# simply re-running, but IS fixed by clearing the fetch caches. run_step runs
|
||||
# a command and, on the first failure that matches a download/cache
|
||||
# signature, clears the caches and retries once. Real config errors never
|
||||
# match, so they still fail loudly. Each sourcing script must define $LOG.
|
||||
nix-self-heal-prelude = ''
|
||||
transient_failure() {
|
||||
grep -Eqi 'truncated tar|unexpected end of (file|archive)|unexpected eof|corrupt(ed)? (archive|nar|download|file)|could not (fetch|download)|download.*(failed|interrupted)|timed out|timeout|connection (reset|refused|timed out)|network is unreachable|temporary failure in name resolution|checksum mismatch|hash mismatch|nar hash|unable to download|store path.*is not valid|cannot read file from tarball|into the git cache' "$LOG"
|
||||
}
|
||||
|
||||
clear_fetch_caches() {
|
||||
echo "[SELF-HEAL] Clearing stale Nix download caches and verifying the Nix store…"
|
||||
# Re-fetchable caches only; /nix/store generations and the running system
|
||||
# are never touched here.
|
||||
rm -rf /root/.cache/nix/tarballs /root/.cache/nix/vcs-cache /root/.cache/nix/git* /root/.cache/nix/flakes 2>/dev/null || true
|
||||
# Fast closure-level repair only. A full --check-contents scan hashes
|
||||
# every store path and can take tens of minutes on a big node; the cache
|
||||
# clear above is the actual fix for truncated/corrupt downloads.
|
||||
nix-store --verify --repair >/dev/null 2>&1 || true
|
||||
echo "[SELF-HEAL] Caches cleared; retrying…"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# run_step LABEL CMD [ARGS...] — run a build step; on a transient
|
||||
# fetch/cache failure, heal once and retry. Returns the command exit code
|
||||
# but leaves error messaging to the caller.
|
||||
run_step() {
|
||||
label="$1"; shift
|
||||
rc=1
|
||||
for try in 1 2; do
|
||||
if [ "$try" -eq 2 ]; then
|
||||
echo "── $label — retry after cache repair ──"
|
||||
fi
|
||||
"$@"
|
||||
rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$try" -eq 1 ] && transient_failure; then
|
||||
echo ""
|
||||
echo "[SELF-HEAL] $label failed on a download/cache error (see above)."
|
||||
clear_fetch_caches
|
||||
continue
|
||||
fi
|
||||
return "$rc"
|
||||
done
|
||||
return "$rc"
|
||||
}
|
||||
'';
|
||||
|
||||
# ── Update wrapper script ──────────────────────────────────────
|
||||
update-script = pkgs.writeShellScript "sovran-hub-update.sh" ''
|
||||
set -uo pipefail
|
||||
@@ -170,12 +226,14 @@ let
|
||||
|
||||
RC=0
|
||||
|
||||
${nix-self-heal-prelude}
|
||||
|
||||
echo "── Step 1/3: nix flake update ────────────────────"
|
||||
if ! nix flake update --flake /etc/nixos --print-build-logs \
|
||||
if ! run_step "nix flake update" nix flake update --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1; then
|
||||
--option fallback true; then
|
||||
echo "[ERROR] nix flake update failed"
|
||||
RC=1
|
||||
fi
|
||||
@@ -185,21 +243,21 @@ let
|
||||
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
||||
# Stream output straight into $LOG (see rebuild-script) so the Hub UI
|
||||
# shows live progress instead of an empty log during long builds.
|
||||
nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true
|
||||
BOOT_RC=$?
|
||||
if [ "$BOOT_RC" -ne 0 ]; then
|
||||
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true; then
|
||||
if ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
|
||||
# The marker is informational only. The Hub derives pending-reboot
|
||||
# state from the NixOS system profile itself, so failing to record
|
||||
# the marker must not fail an otherwise successful update.
|
||||
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
||||
rm -f "$GENERATION"
|
||||
fi
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot failed"
|
||||
RC=1
|
||||
elif ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
|
||||
# The marker is informational only. The Hub derives pending-reboot
|
||||
# state from the NixOS system profile itself, so failing to record
|
||||
# the marker must not fail an otherwise successful update.
|
||||
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
||||
rm -f "$GENERATION"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
@@ -244,12 +302,15 @@ let
|
||||
echo " Sovran_SystemsOS Rebuild — $(date)"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
${nix-self-heal-prelude}
|
||||
|
||||
echo "── Rebuilding system configuration ──────────────"
|
||||
# Stream output straight into $LOG (tee'd by the exec redirect above) so
|
||||
# the Hub UI shows live progress. Capturing the output in a variable
|
||||
# kept the log empty for the entire build+activation, which made long
|
||||
# rebuilds can otherwise look like a hang.
|
||||
nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
run_step "nixos-rebuild switch" nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
@@ -265,11 +326,11 @@ let
|
||||
echo ""
|
||||
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1; then
|
||||
--option fallback true; then
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot also failed"
|
||||
@@ -277,6 +338,7 @@ let
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild switch failed"
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✗ Rebuild failed — see errors above"
|
||||
@@ -404,6 +466,9 @@ DESKTOP
|
||||
import os, sys
|
||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
||||
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_VERSIONS"] = os.path.join(base, "versions.json")
|
||||
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
|
||||
@@ -417,26 +482,9 @@ uvicorn.run(
|
||||
LAUNCHER
|
||||
chmod +x $out/bin/sovran-hub-web
|
||||
|
||||
cat > $out/bin/nwc-wallet <<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_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
|
||||
|
||||
# nwc-wallet / nwc-lnurl binaries are no longer shipped here: the
|
||||
# Sovran_Bitcoin flake provides them (env-wrapped nwc-wallet via
|
||||
# albyhub.nix, nwc-lnurl.service via lnurl.nix) — single source of truth.
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
|
||||
@@ -185,6 +185,14 @@ EOF
|
||||
fi
|
||||
echo "Detected primary network interface: $IFACE"
|
||||
|
||||
# Derive the LAN subnet this box sits on so the embedded TURN relay
|
||||
# is allowed to hand media to LiveKit's LAN host candidate (see the
|
||||
# allow_restricted_peer_cidrs block below). Computed from the primary
|
||||
# interface's own address, so it always matches the subnet the LAN
|
||||
# clients (phones on Wi-Fi) actually live on.
|
||||
LAN_CIDR=$(ip -4 -o addr show dev "$IFACE" | awk '{print $4}' | grep -vE '^(127\.|169\.254\.)' | head -n1 | python3 -c 'import sys, ipaddress; s = sys.stdin.read().strip(); print(str(ipaddress.ip_network(s, strict=False)) if s else "")' 2>/dev/null)
|
||||
if [ -n "$LAN_CIDR" ]; then echo "Derived LAN CIDR for TURN relay: $LAN_CIDR"; else echo "Derived LAN CIDR for TURN relay: <none>"; fi
|
||||
|
||||
# Generate the full LiveKit config the daemon will load. turn.domain and
|
||||
# rtc.interfaces.includes are only known at runtime, so they are
|
||||
# substituted here. The cert/key paths point at the LoadCredential-staged
|
||||
@@ -229,10 +237,9 @@ port: 7880
|
||||
rtc:
|
||||
use_external_ip: false
|
||||
node_ip: $PUBLIC_IP
|
||||
advertise_internal_ip: true
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
port_range_start: 30000
|
||||
port_range_end: 40000
|
||||
interfaces:
|
||||
includes:
|
||||
- $IFACE
|
||||
@@ -244,10 +251,9 @@ port: 7880
|
||||
rtc:
|
||||
use_external_ip: true
|
||||
skip_external_ip_validation: true
|
||||
advertise_internal_ip: true
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
port_range_start: 30000
|
||||
port_range_end: 40000
|
||||
interfaces:
|
||||
includes:
|
||||
- $IFACE
|
||||
@@ -264,16 +270,37 @@ EOF
|
||||
# vhost (/livekit/jwt/sfu_webhook → 8073).
|
||||
LK_KEY=$(cut -d: -f1 < ${livekitKeyFile} | tr -d '[:space:]')
|
||||
|
||||
# TURN/TLS is intentionally not configured (no tls_port): LiveKit
|
||||
# advertises turns:<domain>:443 to clients regardless of tls_port, so
|
||||
# a 5349 TURN/TLS listener would be unreachable and only adds attack
|
||||
# surface. The staged cert/key stay for a future TURN/TLS-on-443
|
||||
# (Caddy layer4 SNI) setup.
|
||||
cat >> /run/livekit/livekit.yaml <<EOF
|
||||
room:
|
||||
auto_create: false
|
||||
turn:
|
||||
enabled: true
|
||||
domain: $MATRIX
|
||||
tls_port: 5349
|
||||
udp_port: 3478
|
||||
relay_range_start: 40000
|
||||
relay_range_end: 40099
|
||||
cert_file: /run/credentials/livekit.service/turn-cert
|
||||
key_file: /run/credentials/livekit.service/turn-key
|
||||
EOF
|
||||
|
||||
# By default the embedded TURN relay refuses to send media to
|
||||
# private/loopback peers. That would force its final hop to the
|
||||
# public/WAN IP (hairpin NAT) — exactly what breaks calls on routers
|
||||
# without NAT loopback. Allow the LAN subnet so the relay can deliver
|
||||
# directly to LiveKit's LAN host candidate instead.
|
||||
if [ -n "$LAN_CIDR" ]; then
|
||||
cat >> /run/livekit/livekit.yaml <<EOF
|
||||
allow_restricted_peer_cidrs:
|
||||
- $LAN_CIDR
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat >> /run/livekit/livekit.yaml <<EOF
|
||||
webhook:
|
||||
api_key: $LK_KEY
|
||||
urls:
|
||||
@@ -315,10 +342,14 @@ EOF
|
||||
"turn-key:/var/lib/livekit/turn.key"
|
||||
];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
|
||||
# 5349/TCP (TURN/TLS) is deliberately absent — see livekit-turn-setup. RTC
|
||||
# media uses the single UDP mux (7882); the 30000-40000 range is gone so
|
||||
# media is no longer spread across 10000 ports. The TURN relay allocation
|
||||
# range (40000-40099) is kept separate from the media mux.
|
||||
networking.firewall.allowedTCPPorts = [ 7881 ];
|
||||
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
|
||||
networking.firewall.allowedUDPPortRanges = [
|
||||
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range
|
||||
{ from = 40000; to = 40099; } # LiveKit embedded TURN relay allocation range
|
||||
];
|
||||
|
||||
####### JWT SERVICE RUNTIME CONFIG #######
|
||||
|
||||
@@ -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
|
||||
./nextcloud.nix
|
||||
./vaultwarden.nix
|
||||
./bitcoinecosystem.nix
|
||||
./wallet-autoconnect.nix
|
||||
|
||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||
./haven.nix
|
||||
./nwc-wallets.nix
|
||||
./element-calling.nix
|
||||
./mempool.nix
|
||||
./rdp.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):
|
||||
def test_bitcoind_loopback_listener_is_always_enabled(self):
|
||||
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
||||
self.assertIn("listen = true;", ecosystem)
|
||||
self.assertIn("peerbloomfilters=1", ecosystem)
|
||||
|
||||
def test_gossip_is_opt_in(self):
|
||||
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
||||
self.assertIn(
|
||||
"public = config.sovran_systemsOS.features.bitcoin-tor-gossip;",
|
||||
ecosystem,
|
||||
)
|
||||
def test_integration_layer_maps_tor_gossip_option(self):
|
||||
"""The integration layer must bridge sovran_systemsOS.features.bitcoin-tor-gossip
|
||||
to sovran-bitcoin.bitcoindTorGossip."""
|
||||
integration = _read("modules", "sovran-bitcoin-integration.nix")
|
||||
self.assertIn("bitcoindTorGossip", integration)
|
||||
self.assertIn("bitcoin-tor-gossip", integration)
|
||||
|
||||
def test_hub_option_and_evaluated_state_are_declared(self):
|
||||
roles = _read("modules", "core", "roles.nix")
|
||||
|
||||
@@ -333,33 +333,6 @@ class TestSshPubkeyValidation(unittest.TestCase):
|
||||
_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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Regression tests for the Hub Zeus Connect QR.
|
||||
|
||||
The LND-only rewrite of modules/bitcoin/lndconnect.nix shipped a wrapper
|
||||
that Zeus cannot use. These tests lock the contract the Hub QR depends on
|
||||
without needing lnd / tor / qrencode at test time.
|
||||
The Zeus Connect setup service (modules/wallet-autoconnect.nix) and the Hub QR
|
||||
encoding are tested here. The lndconnect wrapper itself is now part of the
|
||||
Sovran_Bitcoin flake — its contract tests live in that repository.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -17,41 +17,6 @@ def _read(relpath: str) -> str:
|
||||
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):
|
||||
"""zeus-connect-setup must wait for the REST onion and validate the URI."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user