22 Commits
Author SHA1 Message Date
naturallaw777 be5d47cb9c updated sovran-bitcon 2026-08-31 20:31:48 -05:00
naturallaw777 ddf87a1c1c refactor(nwc): dedupe NWC tooling — use Sovran_Bitcoin's sovran-nwc
The Hub vendored a second copy of the NWC stack
(app/sovran_systemsos_web/nwc_hub_manager.py, nwc_audit.py,
nwc_lnurl_service.py, nwc_wallet_cli.py) and built its own nwc-wallet /
nwc-lnurl binaries from it. That copy drifted from the pinned Alby Hub
API contract (appId vs toAppId) and duplicated code that Sovran_Bitcoin
already ships and fixes.

Changes:
- Delete the four vendored modules; server.py now imports the canonical
  implementation directly (from sovran_nwc import nwc_hub_manager) from
  the sovran-nwc package (pkgs.sovran-bitcoin.nwc). API fixes in
  Sovran_Bitcoin now propagate to the Hub web app automatically.
- sovran-hub-web launcher: add <sovran-nwc>/lib/sovran-nwc to
  sys.path so the import resolves.
- Stop shipping nwc-wallet / nwc-lnurl binaries from sovran-hub-web:
  the flake already provides them (env-wrapped nwc-wallet with
  NWC_* vars via albyhub.nix, and nwc-lnurl.service via lnurl.nix).

Requires a Sovran_Bitcoin rev containing the toAppId fix (and the
LNURL module audit-log fix); bump the flake input afterwards:
  nix flake update sovran-bitcoin

Test:
  - nixos-rebuild switch
  - Hub Wallet Connections tab still lists/creates wallets
  - nwc-wallet list works from the operator shell
  - journalctl -u nwc-lnurl shows no import/contract errors
2026-08-31 20:28:10 -05:00
naturallaw777 f0e4c33a5f fix: set lnurl domainFile for Hub-managed Lightning Address domain 2026-08-31 10:58:04 -05:00
naturallaw777 a6ef13688e chore: update sovran-bitcoin flake input 2026-08-31 10:50:59 -05:00
naturallaw777 ef1c045e0e fix: correct disablewallet casing 2026-08-31 10:46:19 -05:00
naturallaw777 e27bf0ef1f fixed typo 2026-08-31 10:27:11 -05:00
Sovran Patch 362fa0b36c refactor: extract bitcoin stack into Sovran_Bitcoin flake input
Decouple the Bitcoin/Lightning modules and packages into the standalone
Sovran_Bitcoin flake, consumed as a NixOS module input.

Deleted (now in Sovran_Bitcoin):
  - modules/bitcoin/          (19 files — vendored nix-bitcoin modules)
  - modules/bitcoinecosystem.nix
  - modules/nwc-wallets.nix
  - modules/mempool.nix
  - packages/{albyhub,mempool,rtl,build-support}/
  - tests/bitcoin-btcpay-hardening.nix

Created:
  - modules/sovran-bitcoin-integration.nix — the OS-specific bridge that
    maps sovran_systemsOS.* options to sovran-bitcoin.* and applies
    Second_Drive paths, operator 'free', forced wallet, firewall 3051,
    and Sovran Hub NWC environment wiring.

Modified:
  - flake.nix — added sovran-bitcoin flake input, updated module imports
  - modules/modules.nix — removed deleted imports
  - modules/core/sovran-hub.nix — version metadata now reads from
    pkgs.sovran-bitcoin.* overlay instead of local packages/
  - tests/test_bitcoin_tor_gossip.py — updated to check integration layer

The sovran_systemsOS.* option namespace is preserved. The Hub, roles,
and custom.nix continue to work unchanged.
2026-08-31 10:17:14 -05:00
naturallaw777 043981a34b chore(release): prepare v1.1.3 2026-08-27 09:24:31 -05:00
naturallaw777 5529fccab2 Sparrow update 2026-08-27 09:12:03 -05:00
naturallaw777 5f84154dca nixpkgs update with bisq, bisq2, and sparrow update 2026-08-27 09:10:45 -05:00
Arena Agent 60b55f715e fix(hub): evaluate local vendored package versions directly
Fix the version metadata generation in sovran-hub.nix for Alby Hub, RTL, and Mempool. Previously, the build would incorrectly fall back to older upstream nixpkgs versions because the package names exist upstream, despite the OS deploying custom vendored forks locally. This replaces the fragile checks with direct evaluations of the local packages.

Also updates the development fallback versions.json to reflect the current vendored Alby Hub version (1.24.0).
2026-08-27 09:05:20 -05:00
naturallaw777 01037cd55c albyhub: re-harvest hashes for fixed sovran tag 2026-08-27 02:09:35 -05:00
naturallaw777 7980833fc2 albyhub: re-harvest hashes for fixed sovran tag 2026-08-27 02:08:13 -05:00
naturallaw777 837fffc8f9 albyhub: re-harvest hashes for fixed sovran tag 2026-08-27 02:05:35 -05:00
naturallaw777 cb4585ecf8 albyhub default.nix hash update 2026-08-27 01:13:48 -05:00
naturallaw777 1de1bf95ad albyhub default.nix hash update 2026-08-27 01:01:56 -05:00
naturallaw777 e9d6edca56 albyhub default.nix hash update 2026-08-27 01:00:17 -05:00
naturallaw777 a7bfc9aa0d albyhub default.nix fix 2026-08-27 00:58:17 -05:00
naturallaw777 ed2e4059c1 albyhub: build from sovran fork tag (LND-only, no frontend) 2026-08-27 00:50:35 -05:00
naturallaw777 f057e43ca0 fix(albyhub): use proxyVendor to preserve secp256k1-zkp cgo headers 2026-08-25 19:28:18 -05:00
naturallaw777 d600198049 feat(albyhub): vendor v1.24.0 LND-only, no-frontend build
Replace the nixpkgs albyhub overrideAttrs patch-chain with a fully
vendored package at packages/albyhub for v1.24.0.

- modules/core/sovran-hub.nix: bump fallback version 1.8.0 -> 1.24.0
- modules/nwc-wallets.nix: build via pkgs.callPackage ../packages/albyhub
- packages/albyhub:
  - drop 0002-isolated-invoice-app-id.patch (fixed upstream)
  - add 0004-lnd-only.patch: strip LDK/Bark/Cashu/CLN/Phoenix backends
    from service/start.go, leaving only the LND case
  - add 0005-no-frontend.patch: remove //go:embed dist and the
    frontend handler registration
  - add default.nix: buildGoModule for v1.24.0 with no nodejs/yarn/
    bark-ffi-go/ldk-node deps (only stdenv.cc.cc), subPackages cmd/http

Keeps 0001-private-route-hints and 0003-loopback-bind-host. Does not
touch flake.nix, VERSION, or CHANGELOG.
2026-08-25 19:11:25 -05:00
naturallaw777 14db4282b7 ux: simplify domain and router setup guidance 2026-08-22 09:10:05 -05:00
58 changed files with 371 additions and 5757 deletions
+18
View File
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [1.1.3] - 2026-08-27
### Added
- Sparrow update
- Nixpkgs update with bisq and bisq2
- Albyhub: re-harvest hashes for fixed sovran tag
- Albyhub default.nix hash update
- Albyhub default.nix fix
- Albyhub: build from sovran fork tag (LND-only, no frontend)
- Vendor v1.24.0 LND-only, no-frontend build
- Ux: simplify domain and router setup guidance
### Fixed
- Evaluate local vendored package versions directly
- Use proxyVendor to preserve secp256k1-zkp cgo headers
[1.1.3]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.3
## [1.1.2] - 2026-08-21
### Added
+25 -25
View File
@@ -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.2.iso) ·
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.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.2.iso.sha256) ·
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.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.2.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso)
- [Download Sovran_SystemsOS-1.1.2.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso.sha256)
- [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)
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.2.iso.sha256
sha256sum --check Sovran_SystemsOS-1.1.3.iso.sha256
```
A successful comparison reports:
```text
Sovran_SystemsOS-1.1.2.iso: OK
Sovran_SystemsOS-1.1.3.iso: OK
```
You can also run `sha256sum Sovran_SystemsOS-1.1.2.iso` and compare the output
You can also run `sha256sum Sovran_SystemsOS-1.1.3.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.2.iso
shasum -a 256 Sovran_SystemsOS-1.1.3.iso
```
Compare the value shown in Terminal with the value inside
`Sovran_SystemsOS-1.1.2.iso.sha256`.
`Sovran_SystemsOS-1.1.3.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.2.iso -Algorithm SHA256
Get-FileHash .\Sovran_SystemsOS-1.1.3.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.2.iso`.
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.3.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.2.iso) ·
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso) ·
[View the License](LICENSE)
</div>
+7 -5
View File
@@ -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
+6 -3
View File
@@ -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 -1
View File
@@ -1 +1 @@
1.1.2
1.1.3
-75
View File
@@ -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)
-881
View File
@@ -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()
-131
View File
@@ -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())
+8 -8
View File
@@ -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,
@@ -246,8 +246,8 @@ CATEGORY_ORDER = [
FEATURE_REGISTRY = [
{
"id": "rdp",
"name": "Remote Desktop (RDP)",
"description": "Access your desktop remotely via RDP client",
"name": "Remote Desktop",
"description": "Access your desktop from a device on your home network — no router setup needed",
"category": "infrastructure",
"needs_domain": False,
"domain_name": None,
@@ -362,8 +362,8 @@ FEATURE_REGISTRY = [
},
{
"id": "btcpay-web",
"name": "BTCPay Server Web Access",
"description": "Expose BTCPay Server to the internet via your domain. When disabled, BTCPay Server still runs locally but is not accessible from the web.",
"name": "Put BTCPay Server Online",
"description": "Let people donate Bitcoin, pay you, or visit your online store. BTCPay Server stays private when this is turned off.",
"category": "bitcoin",
"needs_domain": True,
"domain_name": "btcpayserver",
@@ -545,9 +545,9 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
"Addresses on your Sovran_SystemsOS node."
),
"gnome-remote-desktop.service": (
"Access your server's full desktop environment from anywhere using any RDP client. "
"Manage your system visually without being physically present. "
"Sovran_SystemsOS sets up secure remote access with generated credentials — connect and go."
"Access your desktop from a device on your home network — no router setup needed. "
"Sovran_SystemsOS creates the address, username, and password for you: "
"just open an RDP app and connect."
),
"sshd.service": (
"Secure Shell (SSH) remote access. When enabled, authorized users can connect "
@@ -1,22 +1,14 @@
/* Sovran_SystemsOS Hub — Shared domain-prerequisite instructions.
/* Sovran_SystemsOS Hub — Shared domain and router instructions.
SINGLE SOURCE OF TRUTH for the "you need a Njal.la domain + router port
forwarding" guidance that must read identically everywhere it appears:
This is the single source of truth for the simple setup guidance shown in:
• First-boot onboarding wizard (Server + Desktop role)
— onboarding.js step 3
Feature-enable domain modal (Node role, and any role)
— features.js openDomainSetupModal()
(Lightning Wallet Connections / NWC, BTCPay Server, Haven, …)
• Domain reconfigure / troubleshooting modal
— features.js openDomainReconfigureModal()
• First-boot onboarding for Server + Desktop
• Feature setup in the Hub
Domain reconfiguration and troubleshooting
Keep these three surfaces word-for-word consistent: always edit this
file, never fork the wording inline. Plain classic script (no modules) —
both templates load it with a plain <script> tag. */
Keep the wording consistent by editing it here. */
"use strict";
/* Escape helper local to this file so it is self-contained on both pages. */
function dpEsc(str) {
return String(str)
.replace(/&/g, "&amp;")
@@ -26,105 +18,71 @@ function dpEsc(str) {
.replace(/'/g, "&#39;");
}
/* ── "What you'll need" intro ──────────────────────────────────────
opts.serviceName — e.g. "Lightning Wallet Connections", or null for
the multi-service (onboarding) wording.
opts.hostExample — e.g. "lightning" → lightning.yourdomain.com */
/* Explain only what the user needs for the step in front of them. */
function renderDomainNeedsHtml(opts) {
opts = opts || {};
var serviceName = opts.serviceName || null;
var hostExample = dpEsc(opts.hostExample || "myservice");
var purpose = opts.purpose || "";
var html = "";
if (serviceName) {
html += "<p>To enable <strong>" + dpEsc(serviceName) + "</strong>, you'll need two things first:</p>";
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
+ dpEsc(serviceName) + " gets its own hostname: a subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many.</li>";
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
+ "to this computer once. All domain-based services share these two ports; "
+ "they're required for HTTPS and SSL certificates.</li>";
html += "</ol>";
if (purpose === "btcpay") {
html += "<p><strong>Put your BTCPay Server online</strong></p>";
html += "<p>Let people donate Bitcoin, pay you, or visit your online store.</p>";
html += "<p>BTCPay Server needs a domain so people can reach it. "
+ "Sovran_SystemsOS walks you through getting your domain from "
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
+ " and connecting it. Just follow the steps below.</p>";
} else if (serviceName) {
html += "<p>To make <strong>" + dpEsc(serviceName) + "</strong> available outside your home, it needs a domain.</p>";
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
+ " and connecting it. Just follow the steps below.</p>";
} else {
html += "<p><strong>Each service below needs two things set up:</strong></p>";
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
+ "Each service gets its own hostname: its own subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many, "
+ "so a single domain can serve every service.</li>";
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
+ "to this computer once. All domain-based services share these two ports; "
+ "they're required for HTTPS and SSL certificates.</li>";
html += "</ol>";
html += "<p>Your domain is the address people use to reach your services on the internet.</p>";
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
+ " and connecting your services. Just follow the steps below.</p>";
}
return html;
}
/* ── "How to set it up at Njal.la" steps ───────────────────────────
opts.hostExample — host part used in the examples (default "call").
opts.pasteHint — where the curl command goes:
"below" (single service) or "next to its service below"
(onboarding, many services). */
/* The shortest Njal.la steps that still tell the user exactly what to do. */
function renderNjallaStepsHtml(opts) {
var hostExample = dpEsc((opts && opts.hostExample) || "call");
var pasteHint = (opts && opts.pasteHint) || "below";
var html = "";
html += "<p style=\"margin-top:12px;\"><strong>How to set it up at Njal.la:</strong></p>";
html += '<p style="margin-top:12px;"><strong>Get and connect your domain:</strong></p>';
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
html += "<li>Create an account at <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">https://njal.la</a> and buy a domain.</li>";
html += "<li>Add a <strong>Dynamic</strong> record for the hostname:"
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
+ "<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>"
+ "(For &quot;" + hostExample + ".yourdomain.com&quot; you&apos;d type just: <code>" + hostExample + "</code>.)<br>"
+ "&#9888; Do NOT type the full domain here — Njal.la adds it automatically.</li>"
+ "<li>Dedicating a whole separate domain to this service? Leave the Name field blank or use <code>@</code>.</li>"
+ "</ul>"
+ "</li>";
html += "<li>A Dynamic record has <strong>NO IP field</strong> — you don&apos;t enter an IP anywhere. "
+ "It auto-fills once Sovran_SystemsOS runs the update command (on save, and again after every reboot).</li>";
html += "<li>Njal.la gives you a curl command, e.g.:<br>"
+ '<code style="font-size:0.8em;">curl &quot;https://njal.la/update/?h=' + hostExample + '.yourdomain.com&amp;k=abc123&amp;auto&quot;</code><br>'
+ "Copy it and paste it " + pasteHint + ".</li>";
html += '<li>Open <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>, create an account, and get a domain.</li>';
html += "<li>Open your domain and add a <strong>Dynamic</strong> record for this service. "
+ "In the <strong>Name</strong> box, enter only the first part of the address. "
+ "For <code>" + hostExample + ".yourdomain.com</code>, enter <code>" + hostExample + "</code>. "
+ "If you are using the whole domain, leave the box blank or enter <code>@</code>.</li>";
html += "<li>Copy the update command Njal.la gives you and paste it " + pasteHint + ".</li>";
html += "</ol>";
return html;
}
/* ── "One router task" port-forwarding box ─────────────────────────
opts.internalIp — LAN IP string, or empty/null for generic wording.
opts.plural — true for multi-service (onboarding) wording.
opts.includeSsh — also mention port 22 for SSH (onboarding).
opts.extraNote — extra sentence appended at the end (optional). */
/* One router task shared by all normal domain-based services. */
function renderRouterPortsHtml(opts) {
opts = opts || {};
var ipPart = opts.internalIp
? " to this computer&rsquo;s internal IP <strong>" + dpEsc(opts.internalIp) + "</strong>"
: " to this computer&rsquo;s internal IP";
var serviceWord = opts.plural ? "services" : "service";
? " to this computer at <strong>" + dpEsc(opts.internalIp) + "</strong>"
: " to this computer";
var html = "";
html += "🔌 <strong>One router task:</strong> in your router&rsquo;s <strong>port forwarding</strong> settings, "
+ "forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>"
+ ipPart + ". Use the <strong>same number for the internal and external port</strong>. "
+ "This only needs to be done once — all domain services share these ports — "
+ "but HTTPS and SSL certificates won&rsquo;t work without them, so your " + serviceWord + " "
+ "can&rsquo;t be reached from outside your home network. "
+ "You&rsquo;ll need normal access to your router&rsquo;s settings with working port forwarding — "
+ "if your ISP blocks it (e.g. CGNAT), domain-based services can&rsquo;t be reached from the internet. ";
if (opts.includeSsh) {
html += "Add port <strong>22 (TCP)</strong> as well if you want remote SSH access. ";
}
html += "🔌 <strong>One router task:</strong> in your router&rsquo;s <strong>Port Forwarding</strong> settings, "
+ "forward <strong>80 (TCP)</strong> and <strong>443 (TCP)</strong>"
+ ipPart + ". If your router asks for outside and inside port numbers, use the same number for both. "
+ "You only need to do this once. All your services share these two ports.";
if (opts.extraNote) {
html += dpEsc(opts.extraNote);
html += " " + dpEsc(opts.extraNote);
}
return html;
}
/* ── Async helper: fill a router-box container with the internal IP ──
Renders generic text immediately, then upgrades to the concrete LAN IP
if /api/network provides one. Best-effort — never blocks the UI. */
/* Show generic wording immediately, then add the computer's address when found. */
function renderRouterPortsBox(elId, opts) {
var el = document.getElementById(elId);
if (!el) return;
@@ -142,5 +100,5 @@ function renderRouterPortsBox(elId, opts) {
target.innerHTML = renderRouterPortsHtml(next);
}
})
.catch(function() { /* generic wording already shown — fine */ });
.catch(function() { /* Generic wording is already shown. */ });
}
+13 -13
View File
@@ -60,6 +60,7 @@ function openDomainSetupModal(feat, onSaved) {
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
var isBtcpay = feat.id === "btcpay-web";
var npubField = "";
if (feat.id === "haven") {
@@ -77,30 +78,30 @@ function openDomainSetupModal(feat, onSaved) {
var nwcWarning = isWalletConnections
? '<div class="domain-nwc-warning">' +
'<strong>Lightning Wallet Connections requires its own unique hostname.</strong> ' +
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
'</div>'
: '';
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : (isBtcpay ? "btcpay.yourdomain.com" : "myservice.example.com");
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : (isBtcpay ? "btcpay.yourdomain.com" : "call.yourdomain.com");
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
// identical wording to the Server + Desktop onboarding wizard and every other
// domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
var hostExample = isWalletConnections ? "lightning" : "call";
var hostExample = isWalletConnections ? "lightning" : (isBtcpay ? "btcpay" : "call");
var purpose = isBtcpay ? "btcpay" : "";
$domainSetupBody.innerHTML =
'<div class="domain-setup-intro">' +
nwcWarning +
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample }) +
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample, purpose: purpose }) +
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' +
'<p style="margin-top:10px;">Enter the address for this service and paste the update command from Njal.la.</p>' +
'</div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=' + domainPlaceholder + '&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint"> Paste the full curl command from your Njal.la dashboard\'s Dynamic record</p></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service address (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl &quot;https://njal.la/update/?h=' + domainPlaceholder + '&amp;k=abc123&amp;auto&quot;" /><p class="domain-field-hint">Paste the update command from Njal.la</p></div>' +
npubField +
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save &amp; Enable</button></div>';
@@ -169,9 +170,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
var nwcWarning = isWalletConnections
? '<div class="domain-nwc-warning">' +
'<strong>Lightning Wallet Connections requires its own unique hostname.</strong> ' +
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
'</div>'
: '';
+7 -12
View File
@@ -364,17 +364,12 @@ async function loadStep3() {
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
+ '</div>';
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
html += '<p class="onboarding-hint">Enter the address for each service and paste its update command from Njal.la.</p>';
// Router note (the same wording is shown again, per-service, whenever a
// domain-based feature is enabled, and lives on each service tile afterwards)
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
+ renderRouterPortsHtml({
internalIp: internalIp,
plural: true,
includeSsh: true,
extraNote: "Element Call needs a few extra ports (some UDP), and youll be shown exactly which when you enable it.",
})
+ renderRouterPortsHtml({ internalIp: internalIp })
+ '</div>';
relevantDomains.forEach(function(d) {
@@ -382,20 +377,20 @@ async function loadStep3() {
html += '<div class="onboarding-domain-group">';
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />';
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la DDNS Curl Command</label>';
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la Update Command</label>';
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl &quot;https://njal.la/update/?h=...&amp;k=...&amp;auto&quot;" />';
html += '<p class="onboarding-hint" style="margin-top:4px;"> Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p>';
html += '<p class="onboarding-hint" style="margin-top:4px;">Paste the update command from Njal.la</p>';
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-domain="' + escHtml(d.name) + '" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
html += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>';
html += '</div>';
});
}
// SSL email section
// Contact email section
var emailVal = domainValues["sslemail"] || "";
html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>';
html += '<p class="onboarding-hint onboarding-hint--inline">Let\'s Encrypt uses this for certificate expiry notifications.</p>';
html += '<label class="onboarding-domain-label">📧 Email Address</label>';
html += '<p class="onboarding-hint onboarding-hint--inline">Used to send important notices about your services.</p>';
html += '<input class="onboarding-domain-input domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" value="' + escHtml(emailVal) + '" />';
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-email="true" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
html += '<span class="onboarding-domain-save-status" id="domain-save-status-email" style="font-size:0.82rem;min-height:1.2em;"></span>';
+4 -12
View File
@@ -197,22 +197,14 @@
encrypted messaging, password management, cloud storage, website hosting, and more.
</p>
<div class="upgrade-info-box">
<p class="upgrade-info-title">⚠ What you should know:</p>
<p class="upgrade-info-title">Simple, guided setup:</p>
<ul class="upgrade-info-list">
<li>You will need to purchase domains for your services — <a href="https://njal.la" target="_blank" rel="noopener noreferrer">Njal.la</a> is the only supported domain provider</li>
<li>Some services require ports to be opened on your router</li>
</ul>
</div>
<div class="upgrade-info-box">
<p class="upgrade-info-title">️ Good to know:</p>
<ul class="upgrade-info-list">
<li>Your services will be accessible via your home internet connection. Your approximate geographic area may be visible through DNS records — this is normal for all self-hosted services</li>
<li>Your Bitcoin node remains fully private over Tor</li>
<li>Sovran_SystemsOS walks you through getting your domain from <a href="https://njal.la" target="_blank" rel="noopener noreferrer">Njal.la</a> and connecting your services</li>
<li>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</li>
</ul>
</div>
<p class="support-desc">
<strong>Don't worry</strong> — the Hub will walk you through every step after the upgrade.
Domain setup, port forwarding, and configuration are all guided.
The Hub guides you through every step.
</p>
<p class="support-desc upgrade-rebuild-note">
The system will rebuild after upgrading. This may take several minutes.
@@ -123,10 +123,10 @@
<div class="onboarding-panel" id="step-3" style="display:none">
<div class="onboarding-step-header">
<span class="onboarding-step-icon">🌐</span>
<h2 class="onboarding-step-title">Domain Configuration</h2>
<h2 class="onboarding-step-title">Connect Your Services</h2>
<p class="onboarding-step-desc">
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS, and your router needs ports <strong>80</strong> and <strong>443</strong> (TCP) forwarded to this computer.
Everything you need — Njal.la account, Dynamic records, and the one router task — is laid out step by step below.
Sovran_SystemsOS walks you through getting your domain from <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> and connecting your services.
To make your services available outside your home, youll complete one router task: forward ports <strong>80</strong> and <strong>443</strong> to this computer.
</p>
</div>
<div class="onboarding-card" id="step-3-body">
+1 -1
View File
@@ -7,7 +7,7 @@
"lnd.service": "0.18.0",
"rtl.service": "0.15.10",
"btcpayserver.service": "2.4.2",
"albyhub.service": "1.8.0",
"albyhub.service": "1.24.0",
"mempool.service": "3.2.1",
"matrix-synapse.service": "1.115.0",
"livekit.service": "1.5.2",
Generated
+68 -16
View File
@@ -5,11 +5,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1787246616,
"narHash": "sha256-TTbXIBwoaPbzk2o+rGEayqOvrXqKo9JdYJAMDwqjCQ4=",
"lastModified": 1787839684,
"narHash": "sha256-xTafqsVs2/zyGsaCel41QT5Lpbs/rG56Kp+YsmLXL8I=",
"owner": "emmanuelrosa",
"repo": "btc-clients-nix",
"rev": "8b10c40cb13bac5d100ae1d1fb42eccc0d9c3223",
"rev": "ef81159876930802ef9861aa5c039cec6886a30f",
"type": "github"
},
"original": {
@@ -56,11 +56,27 @@
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1787101114,
"narHash": "sha256-gwrPcFf/rDjHPaVflbDZ040ZDmBTRj/7+s8ZmE2SaIM=",
"lastModified": 1787753485,
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b18a4b905f8d028dc4476412e6d6891728695379",
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable_2": {
"locked": {
"lastModified": 1787753485,
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "062346a6d85bc4b49dfaa61c986e9c5be21217d1",
"type": "github"
},
"original": {
@@ -72,11 +88,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1787135253,
"narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=",
"lastModified": 1787498568,
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46",
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188",
"type": "github"
},
"original": {
@@ -88,11 +104,11 @@
},
"nixpkgs_3": {
"locked": {
"lastModified": 1787111413,
"narHash": "sha256-sFosWtq21eHGJRnTc/hvf4M1obRgLEUMNm/IzllkHMA=",
"lastModified": 1787364730,
"narHash": "sha256-NcYt9QJfpJiF1lAyN8BDPB4EeScbPU+EwQqPiBElrpU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "afe3d8ac4395617bdcdac9f188ac8717a062e014",
"rev": "a831408e6378bc02ebf8cc09b52c96ca86f6bab4",
"type": "github"
},
"original": {
@@ -102,6 +118,22 @@
"type": "github"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1787736819,
"narHash": "sha256-cV5xEJJK3BvhU8rEd4mC9UsmDi5qscv/kzGPhBRC5WA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixvim": {
"inputs": {
"flake-parts": "flake-parts",
@@ -109,11 +141,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1787151631,
"narHash": "sha256-EblMdrDFBFNNlUPm5zUQdh0j6gDx+OFQPv7LFE4B5AA=",
"lastModified": 1787691219,
"narHash": "sha256-CjeJTOjrluiDuL0W/YBFkSBOQjBYmdKwj7hWAJ4PIh0=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "d0d62a2b5027da689b4e8d5ee43f1cf83f2e975d",
"rev": "ebec1ae277a50b16e7d6682edd009ef585d68261",
"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": 1788225336,
"narHash": "sha256-ixw/ChkLWWrLOzoiSa9n1g2g5KDIVkedC6eYFCba+gA=",
"owner": "naturallaw777",
"repo": "Sovran_Bitcoin",
"rev": "37caecaa3f3666231c8ec82a9a9a0df53fa40910",
"type": "github"
},
"original": {
"owner": "naturallaw777",
"repo": "Sovran_Bitcoin",
"type": "github"
}
},
"systems": {
+10 -13
View File
@@ -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: {
@@ -33,7 +36,8 @@
modules = [
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
./iso/common.nix
./modules/bitcoin
sovran-bitcoin.nixosModules.default
./modules/sovran-bitcoin-integration.nix
nixvim.nixosModules.nixvim
];
};
@@ -44,7 +48,8 @@
nixpkgs.overlays = [ overlay-stable ];
})
./configuration.nix
./modules/bitcoin
sovran-bitcoin.nixosModules.default
./modules/sovran-bitcoin-integration.nix
nixvim.nixosModules.nixvim
];
config = {
@@ -60,17 +65,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 -35
View File
@@ -423,40 +423,6 @@ class InstallerWindow(Adw.ApplicationWindow):
sep.set_margin_end(40)
outer.append(sep)
notice_frame = Gtk.Frame()
notice_frame.add_css_class("card")
notice_frame.set_margin_start(40)
notice_frame.set_margin_end(40)
notice_frame.set_margin_top(20)
notice_frame.set_margin_bottom(4)
notice_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
notice_box.set_margin_top(12)
notice_box.set_margin_bottom(12)
notice_box.set_margin_start(16)
notice_box.set_margin_end(16)
notice_icon = symbolic_icon("dialog-information-symbolic")
notice_icon.set_valign(Gtk.Align.START)
notice_box.append(notice_icon)
notice_lbl = Gtk.Label()
notice_lbl.set_use_markup(True)
notice_lbl.set_wrap(True)
notice_lbl.set_xalign(0)
notice_lbl.set_halign(Gtk.Align.FILL)
notice_lbl.set_markup(
"<span weight='bold'>Heads up — Server + Desktop prerequisites</span>\n"
"• A domain or subdomain from <span weight='bold'>https://njal.la</span>\n"
"• The ability to open / forward ports on your router\n\n"
"Don't worry — after install, the onboarding wizard walks you through every step.\n"
"<span size='small'>Desktop Only and Node Only do not require a domain or port forwarding.</span>"
)
notice_box.append(notice_lbl)
notice_frame.set_child(notice_box)
outer.append(notice_frame)
if self.virtualization:
vm_frame = Gtk.Frame()
vm_frame.add_css_class("card")
@@ -505,7 +471,7 @@ class InstallerWindow(Adw.ApplicationWindow):
# Role cards
roles = [
("Server + Desktop",
"Full sovereign experience: beautiful desktop, your own cloud, secure messaging, Bitcoin node, and more.",
"Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer.",
"Server+Desktop"),
("Desktop Only",
"A beautiful, easy-to-use desktop without the background server applications.",
@@ -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"
]
-507
View File
@@ -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
'';
};
}
-334
View File
@@ -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 ];
})
];
}
-15
View File
@@ -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
];
}
-16
View File
@@ -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" ];
}
-106
View File
@@ -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} = {};
};
}
-132
View File
@@ -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
-316
View File
@@ -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}'
'';
};
}
-115
View File
@@ -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" ];
};
})
]);
}
-348
View File
@@ -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} = {};
};
}
-60
View File
@@ -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)";
};
};
};
}
-154
View File
@@ -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})""")
'');
};
};
}
-106
View File
@@ -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}
'';
};
};
}
-119
View File
@@ -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;
};
};
}
];
}
-58
View File
@@ -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
'';
};
};
}
-216
View File
@@ -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
'';
};
}
-235
View File
@@ -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"
'';
};
};
}
-48
View File
@@ -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>
'')
];
};
}
-12
View File
@@ -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 = {};
}
-128
View File
@@ -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"; }
];
}
+1 -1
View File
@@ -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.
{
+12 -29
View File
@@ -135,16 +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";
# Keep the fallbacks aligned with the vendored packages used by the
# service modules (RTL 0.15.10 and Mempool 3.2.1). The nixpkgs attrs are
# optional because these packages are built locally in this repository.
"rtl.service" = if pkgs ? clightning-rtl then pkgs.clightning-rtl.version else (if pkgs ? rtl then pkgs.rtl.version else "0.15.10");
# BTCPay Server is intentionally sourced from pkgs.stable by the service
# module. Read the configured package here rather than pkgs.btcpayserver
# (unstable), otherwise the Hub can advertise a version that is not running.
# 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" = if pkgs ? albyhub then pkgs.albyhub.version else "1.8.0";
"mempool.service" = if pkgs ? mempool then pkgs.mempool.version else "3.2.1";
"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";
@@ -408,6 +405,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")
@@ -421,26 +421,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
'';
-14
View File
@@ -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;
}
-3
View File
@@ -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
];
-182
View File
@@ -1,182 +0,0 @@
{ config, pkgs, lib, ... }:
let
albyHubPort = 18080;
albyHubApiBase = "http://127.0.0.1:${toString albyHubPort}";
patchedAlbyHub = pkgs.albyhub.overrideAttrs (old: {
patches = (old.patches or []) ++ [
../packages/albyhub/0001-private-route-hints.patch
../packages/albyhub/0002-isolated-invoice-app-id.patch
../packages/albyhub/0003-loopback-bind-host.patch
];
});
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;
}
];
}
+134
View File
@@ -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,13 +0,0 @@
diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go
index bad532f..ba35b7b 100644
--- a/lnclient/lnd/lnd.go
+++ b/lnclient/lnd/lnd.go
@@ -708,7 +708,7 @@ func (svc *LNDService) MakeInvoice(ctx context.Context, amountMsat int64, descri
DescriptionHash: descriptionHashBytes,
Expiry: expiry,
RouteHints: hints,
- Private: !hasPublicChannels, // use private channel hints in the invoice
+ Private: true, // always include private channel hints in the invoice
}
resp, err := svc.client.AddInvoice(ctx, addInvoiceRequest)
@@ -1,71 +0,0 @@
diff --git a/api/models.go b/api/models.go
index 28bd117..dceb79a 100644
--- a/api/models.go
+++ b/api/models.go
@@ -46,7 +46,7 @@ type API interface {
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error)
ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error)
SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, fromAppId *uint) (*SendPaymentResponse, error)
- CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error)
+ CreateInvoice(ctx context.Context, amountMsat uint64, description string, appId *uint) (*MakeInvoiceResponse, error)
LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error)
SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error
RequestMempoolApi(ctx context.Context, endpoint string) (interface{}, error)
@@ -586,6 +586,7 @@ type MakeInvoiceRequest struct {
AmountSat *uint64 `json:"amountSat"`
AmountMsat *uint64 `json:"amountMsat"`
Description string `json:"description"`
+ AppId *uint `json:"appId"`
}
type ResetRouterRequest struct {
diff --git a/api/transactions.go b/api/transactions.go
index aaf9380..3f9850a 100644
--- a/api/transactions.go
+++ b/api/transactions.go
@@ -12,12 +12,12 @@ import (
"github.com/sirupsen/logrus"
)
-func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error) {
+func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string, appId *uint) (*MakeInvoiceResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
- transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, nil, nil, nil)
+ transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, appId, nil, nil)
if err != nil {
return nil, err
}
diff --git a/http/http_service.go b/http/http_service.go
index bf650b9..cf61250 100644
--- a/http/http_service.go
+++ b/http/http_service.go
@@ -687,7 +687,12 @@ func (httpSvc *HttpService) makeInvoiceHandler(c echo.Context) error {
amountMsat = *resolvedAmountMsat
}
- invoice, err := httpSvc.api.CreateInvoice(c.Request().Context(), amountMsat, makeInvoiceRequest.Description)
+ invoice, err := httpSvc.api.CreateInvoice(
+ c.Request().Context(),
+ amountMsat,
+ makeInvoiceRequest.Description,
+ makeInvoiceRequest.AppId,
+ )
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go
index d25259b..c9fe4e8 100644
--- a/wails/wails_handlers.go
+++ b/wails/wails_handlers.go
@@ -600,7 +600,7 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
if resolvedAmountMsat != nil {
amountMsat = *resolvedAmountMsat
}
- invoice, err := app.api.CreateInvoice(ctx, amountMsat, makeInvoiceRequest.Description)
+ invoice, err := app.api.CreateInvoice(ctx, amountMsat, makeInvoiceRequest.Description, nil)
if err != nil {
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
@@ -1,37 +0,0 @@
diff --git a/cmd/http/main.go b/cmd/http/main.go
index ed31f96..4d31212 100644
--- a/cmd/http/main.go
+++ b/cmd/http/main.go
@@ -2,8 +2,8 @@ package main
import (
"context"
- "fmt"
nethttp "net/http"
+ "net"
"os"
"os/signal"
"syscall"
@@ -51,8 +51,9 @@ func main() {
httpSvc := http.NewHttpService(svc, svc.GetEventPublisher())
httpSvc.RegisterSharedRoutes(e)
//start Echo server
+ bindAddress := net.JoinHostPort(svc.GetConfig().GetEnv().Host, svc.GetConfig().GetEnv().Port)
go func() {
- if err := e.Start(fmt.Sprintf(":%v", svc.GetConfig().GetEnv().Port)); err != nil && err != nethttp.ErrServerClosed {
+ if err := e.Start(bindAddress); err != nil && err != nethttp.ErrServerClosed {
logger.Logger.WithError(err).Error("echo server failed to start")
cancel()
}
diff --git a/config/models.go b/config/models.go
index 0fb8870..dc11afd 100644
--- a/config/models.go
+++ b/config/models.go
@@ -24,6 +24,7 @@ type AppConfig struct {
LNDAddress string `envconfig:"LND_ADDRESS"`
LNDCertFile string `envconfig:"LND_CERT_FILE"`
LNDMacaroonFile string `envconfig:"LND_MACAROON_FILE"`
+ Host string `envconfig:"HOST" default:"127.0.0.1"`
Workdir string `envconfig:"WORK_DIR"`
Port string `envconfig:"PORT" default:"8080"`
DatabaseUri string `envconfig:"DATABASE_URI" default:"nwc.db"`
@@ -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
-203
View File
@@ -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;
};
}
-28
View File
@@ -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;
}
-66
View File
@@ -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
-146
View File
@@ -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"
''
+6 -11
View File
@@ -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")
-27
View File
@@ -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
# ---------------------------------------------------------------------------
+3 -38
View File
@@ -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."""