Compare commits
38
Commits
v1.1.1
..
f0e4c33a5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0e4c33a5f | ||
|
|
a6ef13688e | ||
|
|
ef1c045e0e | ||
|
|
e27bf0ef1f | ||
|
|
362fa0b36c | ||
|
|
043981a34b | ||
|
|
5529fccab2 | ||
|
|
5f84154dca | ||
|
|
60b55f715e | ||
|
|
01037cd55c | ||
|
|
7980833fc2 | ||
|
|
837fffc8f9 | ||
|
|
cb4585ecf8 | ||
|
|
1de1bf95ad | ||
|
|
e9d6edca56 | ||
|
|
a7bfc9aa0d | ||
|
|
ed2e4059c1 | ||
|
|
f057e43ca0 | ||
|
|
d600198049 | ||
|
|
14db4282b7 | ||
|
|
d31887cae6 | ||
|
|
669181c75b | ||
|
|
47e9bb99b0 | ||
|
|
ac6c498615 | ||
|
|
224ea99ce4 | ||
|
|
c54dbfe2a5 | ||
|
|
a1fa40cacf | ||
|
|
48dacbeef3 | ||
|
|
b6e6adbe31 | ||
|
|
64624002bb | ||
|
|
1ccce429a5 | ||
|
|
f832efb0d1 | ||
|
|
67ae53ad3f | ||
|
|
db5b9f6b60 | ||
|
|
2ea1427766 | ||
|
|
b35b327a07 | ||
|
|
a9ff168fd6 | ||
|
|
b436dbed95 |
@@ -7,6 +7,54 @@ 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
|
||||||
|
- Nixpkgs update with Bisq update
|
||||||
|
- Unify public-IP detection into one privacy-first script
|
||||||
|
- Fix Element X discovery and harden federated calling
|
||||||
|
- Nixpkgs update
|
||||||
|
- Add tor-browser to the package list
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Use pkgs.lndinit, drop local packages/lndinit
|
||||||
|
- Update RTL to 0.15.10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Move system.activationScripts under the config attribute
|
||||||
|
- Reuse Hub external IP for LiveKit, drop egress service calls
|
||||||
|
- Derive Restart required from boot default vs running system
|
||||||
|
- Reconcile completed updates after polling stalls
|
||||||
|
- Migrate i2pd SAM settings for nixpkgs 26.11
|
||||||
|
- Order turn-setup after network-online to fix boot-time red dot
|
||||||
|
- Restore a Zeus-scannable LND REST connect QR
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Prevent LND admin macaroon exposure in curl argv
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Update desktop and Sovran Hub screenshots
|
||||||
|
[1.1.2]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.2
|
||||||
|
|
||||||
|
|
||||||
## [1.1.1] - 2026-08-15
|
## [1.1.1] - 2026-08-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -43,6 +91,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
manual re-pin.
|
manual re-pin.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Zeus Connect QR in the Hub was invalid: the LND-only `lndconnect`
|
||||||
|
wrapper used flags Zeus/`lndconnect` do not understand (`--cert`,
|
||||||
|
`--macaroon`), read a non-existent onion path (`free/lnd.onion`),
|
||||||
|
and collided with LND's P2P hidden service. The wrapper now matches
|
||||||
|
the nix-bitcoin contract — dedicated `lnd-rest` onion on port 8080,
|
||||||
|
`--nocert` over Tor, admin macaroon in the URI — so scanning the Hub
|
||||||
|
QR in Zeus works again.
|
||||||
- The Hub launcher now uses a persistent per-user browser profile
|
- The Hub launcher now uses a persistent per-user browser profile
|
||||||
(`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp`
|
(`$XDG_STATE_HOME/sovran-hub-browser`) instead of a throwaway `/tmp`
|
||||||
profile that was deleted on exit. The throwaway profile wiped the
|
profile that was deleted on exit. The throwaway profile wiped the
|
||||||
@@ -68,6 +123,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
polling failures instead of hanging indefinitely.
|
polling failures instead of hanging indefinitely.
|
||||||
- Rebuild/update scripts stream `nixos-rebuild` output into the live log
|
- Rebuild/update scripts stream `nixos-rebuild` output into the live log
|
||||||
(it was buffered until completion, making long rebuilds look frozen).
|
(it was buffered until completion, making long rebuilds look frozen).
|
||||||
|
- The update modal now reconciles persisted update state after a page/RDP
|
||||||
|
reconnect, bounds every status request with a timeout, prevents overlapping
|
||||||
|
async polls, and reports an explicit "status unavailable" state with a
|
||||||
|
Retry Status action instead of spinning forever. Verbose Nix logs are
|
||||||
|
rendered incrementally and bounded so they cannot stall the browser UI.
|
||||||
|
- Successful staged updates now record the exact NixOS generation. The Hub
|
||||||
|
keeps showing "Restart required" until that generation is active, then
|
||||||
|
clears the marker after reboot (with log-based recovery for older updates).
|
||||||
|
- Pending-reboot state is now derived from NixOS itself — the boot default
|
||||||
|
(`/nix/var/nix/profiles/system`) versus the running `/run/current-system`
|
||||||
|
— instead of from Hub-written marker files. Updates performed from a
|
||||||
|
terminal or support session (which never touch the Hub's status files)
|
||||||
|
previously left a stale `REBOOT_REQUIRED` marker the Hub could never
|
||||||
|
clear, pinning the "Restart required" badge forever even after many
|
||||||
|
reboots. The marker self-heals to `IDLE` on the next status read whenever
|
||||||
|
boot default and running system agree, and recording the informational
|
||||||
|
`.generation` marker can no longer fail an otherwise successful update.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ Lightning infrastructure, private cloud, and communications platform when you
|
|||||||
are ready.
|
are ready.
|
||||||
|
|
||||||
[Visit the Website](https://sovransystems.com) ·
|
[Visit the Website](https://sovransystems.com) ·
|
||||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.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) ·
|
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso.sha256) ·
|
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso.sha256) ·
|
||||||
[Build from Source](#build-from-source)
|
[Build from Source](#build-from-source)
|
||||||
|
|
||||||
<img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||||
|
|
||||||
*Bitcoin sovereignty from the first boot.*
|
*Bitcoin sovereignty from the first boot.*
|
||||||
|
|
||||||
@@ -281,6 +281,10 @@ From one place, the Hub helps you:
|
|||||||
- Reach your Bitcoin tools, private cloud, and communications
|
- Reach your Bitcoin tools, private cloud, and communications
|
||||||
- Perform supported system operations without everyday terminal commands
|
- Perform supported system operations without everyday terminal commands
|
||||||
|
|
||||||
|
<img src="assets/sovran-hub-screenshot.webp" alt="The Sovran Hub dashboard" width="800" />
|
||||||
|
|
||||||
|
*The Sovran Hub: manage your private infrastructure from one place.*
|
||||||
|
|
||||||
### Example home setup
|
### Example home setup
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -341,8 +345,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
|||||||
|
|
||||||
### 1. Download the ISO and checksum
|
### 1. Download the ISO and checksum
|
||||||
|
|
||||||
- [Download Sovran_SystemsOS-1.1.1.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso)
|
- [Download Sovran_SystemsOS-1.1.3.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso)
|
||||||
- [Download Sovran_SystemsOS-1.1.1.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso.sha256)
|
- [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
|
The download may take some time. Do not rename or modify the ISO before
|
||||||
verifying it, and keep both files in the same folder.
|
verifying it, and keep both files in the same folder.
|
||||||
@@ -360,16 +364,16 @@ checksum exactly.
|
|||||||
Open a terminal in the download folder and run:
|
Open a terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sha256sum --check Sovran_SystemsOS-1.1.1.iso.sha256
|
sha256sum --check Sovran_SystemsOS-1.1.3.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
A successful comparison reports:
|
A successful comparison reports:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Sovran_SystemsOS-1.1.1.iso: OK
|
Sovran_SystemsOS-1.1.3.iso: OK
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also run `sha256sum Sovran_SystemsOS-1.1.1.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.
|
against the checksum file manually.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -380,11 +384,11 @@ against the checksum file manually.
|
|||||||
Open Terminal in the download folder and run:
|
Open Terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
shasum -a 256 Sovran_SystemsOS-1.1.1.iso
|
shasum -a 256 Sovran_SystemsOS-1.1.3.iso
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value shown in Terminal with the value inside
|
Compare the value shown in Terminal with the value inside
|
||||||
`Sovran_SystemsOS-1.1.1.iso.sha256`.
|
`Sovran_SystemsOS-1.1.3.iso.sha256`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -394,7 +398,7 @@ Compare the value shown in Terminal with the value inside
|
|||||||
Open PowerShell in the download folder and run:
|
Open PowerShell in the download folder and run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Get-FileHash .\Sovran_SystemsOS-1.1.1.iso -Algorithm SHA256
|
Get-FileHash .\Sovran_SystemsOS-1.1.3.iso -Algorithm SHA256
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value under `Hash` with the published checksum.
|
Compare the value under `Hash` with the published checksum.
|
||||||
@@ -409,7 +413,7 @@ match exactly.
|
|||||||
|
|
||||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||||
connect the USB drive.
|
connect the USB drive.
|
||||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.1.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
|
3. Choose **Select target**, select the USB drive, and review your selection
|
||||||
carefully.
|
carefully.
|
||||||
4. Choose **Flash** and wait for the writing and verification process to
|
4. Choose **Flash** and wait for the writing and verification process to
|
||||||
@@ -506,13 +510,15 @@ setup, supported hardware, and Royal Membership.
|
|||||||
|
|
||||||
## For developers
|
## For developers
|
||||||
|
|
||||||
Sovran_SystemsOS combines [NixOS](https://nixos.org), an in-repository
|
Sovran_SystemsOS combines [NixOS](https://nixos.org), the standalone
|
||||||
Bitcoin and Lightning stack, desktop packages from
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) flake for
|
||||||
|
Bitcoin and Lightning, desktop packages from
|
||||||
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
||||||
Sovran Hub. The Bitcoin modules under `modules/bitcoin/` were adapted from
|
Sovran Hub. The Bitcoin and Lightning modules live in the Sovran_Bitcoin
|
||||||
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) and are now maintained
|
repository and are consumed as a flake input. OS-specific Bitcoin customizations
|
||||||
here. Builds no longer import or fetch nix-bitcoin. Legacy `nix-bitcoin.*`
|
(Second_Drive paths, operator user, Hub integration) are bridged by
|
||||||
option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
`modules/sovran-bitcoin-integration.nix`. Legacy `nix-bitcoin.*` option names
|
||||||
|
and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||||
|
|
||||||
### Development workflow
|
### Development workflow
|
||||||
|
|
||||||
@@ -532,8 +538,9 @@ option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
|||||||
|
|
||||||
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
||||||
for declarative, pinned system configuration
|
for declarative, pinned system configuration
|
||||||
- `modules/bitcoin/` for the in-repository Bitcoin and Lightning stack
|
- [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) for the
|
||||||
- `packages/` for Sovran-maintained package definitions and patches
|
Bitcoin and Lightning stack (consumed as a flake input)
|
||||||
|
- `modules/sovran-bitcoin-integration.nix` for OS-specific Bitcoin overrides
|
||||||
- [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the
|
- [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the
|
||||||
Sparrow, Bisq, and Bisq 2 packages
|
Sparrow, Bisq, and Bisq 2 packages
|
||||||
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
|
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
|
||||||
@@ -624,12 +631,11 @@ sudo nixos-rebuild switch --rollback
|
|||||||
| `flake.lock` | Pins dependencies for reproducible builds |
|
| `flake.lock` | Pins dependencies for reproducible builds |
|
||||||
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
||||||
| `modules/` | Core modules, self-hosted services, and optional features |
|
| `modules/` | Core modules, self-hosted services, and optional features |
|
||||||
| `modules/bitcoin/` | In-repository Bitcoin and Lightning service modules |
|
| `modules/sovran-bitcoin-integration.nix` | OS-specific Bitcoin overrides (Second_Drive, operator, Hub wiring) |
|
||||||
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
||||||
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
||||||
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
||||||
| `iso/` | Installer configuration, installer code, and installer assets |
|
| `iso/` | Installer configuration, installer code, and installer assets |
|
||||||
| `packages/` | Sovran-maintained package definitions and patches |
|
|
||||||
| `tests/` | Security and Nix integration checks |
|
| `tests/` | Security and Nix integration checks |
|
||||||
| `assets/` | Documentation images |
|
| `assets/` | Documentation images |
|
||||||
| `custom.template.nix` | Template for local features and service overrides |
|
| `custom.template.nix` | Template for local features and service overrides |
|
||||||
@@ -685,14 +691,12 @@ rebuilds the machine into the selected declarative state.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Core platform: roles, Hub, desktop integration, Caddy, domains, support, remote deployment | `modules/core/` |
|
| Core platform: roles, Hub, desktop integration, Caddy, domains, support, remote deployment | `modules/core/` |
|
||||||
| Shared credentials | `modules/credentials.nix` |
|
| Shared credentials | `modules/credentials.nix` |
|
||||||
| Bitcoin and Lightning stack | `modules/bitcoinecosystem.nix` |
|
| Bitcoin and Lightning stack | [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) flake input + `modules/sovran-bitcoin-integration.nix` |
|
||||||
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
||||||
| Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` |
|
|
||||||
| Matrix Synapse | `modules/synapse.nix` |
|
| Matrix Synapse | `modules/synapse.nix` |
|
||||||
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
||||||
| Optional Haven Nostr relay | `modules/haven.nix` |
|
| Optional Haven Nostr relay | `modules/haven.nix` |
|
||||||
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
|
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
|
||||||
| Optional Mempool explorer | `modules/mempool.nix` |
|
|
||||||
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
|
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
|
||||||
|
|
||||||
Feature availability and defaults may change as Sovran_SystemsOS develops.
|
Feature availability and defaults may change as Sovran_SystemsOS develops.
|
||||||
@@ -852,7 +856,7 @@ primary location for collaboration. Please read our
|
|||||||
## Privacy. Sovereignty. Bitcoin.
|
## Privacy. Sovereignty. Bitcoin.
|
||||||
|
|
||||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.1.iso) ·
|
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.3.iso) ·
|
||||||
[View the License](LICENSE)
|
[View the License](LICENSE)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+7
-5
@@ -35,12 +35,14 @@ external networks and are outside a “fully offline” model.
|
|||||||
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
||||||
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
||||||
|
|
||||||
### In-repository Bitcoin stack
|
### Bitcoin stack
|
||||||
|
|
||||||
Bitcoin and Lightning modules are maintained under `modules/bitcoin/`. They were
|
Bitcoin and Lightning modules are maintained in the standalone
|
||||||
adapted from nix-bitcoin, but Sovran builds do not import or fetch nix-bitcoin.
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||||
The `nix-bitcoin.*` option namespace and `/etc/nix-bitcoin-secrets` path remain
|
and consumed as a flake input. OS-specific customizations (Second_Drive paths,
|
||||||
only for upgrade compatibility.
|
operator user, Hub integration) are bridged by
|
||||||
|
`modules/sovran-bitcoin-integration.nix`. The `nix-bitcoin.*` option namespace
|
||||||
|
and `/etc/nix-bitcoin-secrets` path remain only for upgrade compatibility.
|
||||||
|
|
||||||
### Supply chain and integrity
|
### Supply chain and integrity
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
## nix-bitcoin
|
## nix-bitcoin
|
||||||
|
|
||||||
Portions of `modules/bitcoin/` and selected package definitions were adapted
|
The Bitcoin and Lightning modules were originally adapted from
|
||||||
from [fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
[fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
||||||
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||||
The current Sovran build does not import or fetch nix-bitcoin.
|
They now live in the standalone
|
||||||
|
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||||
|
and are consumed by Sovran_SystemsOS as a flake input. The current Sovran build
|
||||||
|
does not import or fetch nix-bitcoin.
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -55,6 +56,7 @@ from .security_helpers import (
|
|||||||
load_session_store,
|
load_session_store,
|
||||||
save_session_store,
|
save_session_store,
|
||||||
)
|
)
|
||||||
|
from .update_state import effective_update_status
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ GITEA_API_BASE = "https://git.sovransystems.com/api/v1/repos/Sovran_Systems/Sovr
|
|||||||
|
|
||||||
UPDATE_LOG = "/var/log/sovran-hub-update.log"
|
UPDATE_LOG = "/var/log/sovran-hub-update.log"
|
||||||
UPDATE_STATUS = "/var/log/sovran-hub-update.status"
|
UPDATE_STATUS = "/var/log/sovran-hub-update.status"
|
||||||
|
UPDATE_GENERATION = "/var/log/sovran-hub-update.generation"
|
||||||
UPDATE_UNIT = "sovran-hub-update.service"
|
UPDATE_UNIT = "sovran-hub-update.service"
|
||||||
|
|
||||||
REBUILD_LOG = "/var/log/sovran-hub-rebuild.log"
|
REBUILD_LOG = "/var/log/sovran-hub-rebuild.log"
|
||||||
@@ -132,6 +135,7 @@ _SERVICE_DOMAIN_KEYS = frozenset([
|
|||||||
])
|
])
|
||||||
|
|
||||||
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
|
INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip"
|
||||||
|
EXTERNAL_IP_FILE = "/var/lib/secrets/external-ip"
|
||||||
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
|
ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url"
|
||||||
|
|
||||||
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
|
ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete"
|
||||||
@@ -242,8 +246,8 @@ CATEGORY_ORDER = [
|
|||||||
FEATURE_REGISTRY = [
|
FEATURE_REGISTRY = [
|
||||||
{
|
{
|
||||||
"id": "rdp",
|
"id": "rdp",
|
||||||
"name": "Remote Desktop (RDP)",
|
"name": "Remote Desktop",
|
||||||
"description": "Access your desktop remotely via RDP client",
|
"description": "Access your desktop from a device on your home network — no router setup needed",
|
||||||
"category": "infrastructure",
|
"category": "infrastructure",
|
||||||
"needs_domain": False,
|
"needs_domain": False,
|
||||||
"domain_name": None,
|
"domain_name": None,
|
||||||
@@ -358,8 +362,8 @@ FEATURE_REGISTRY = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "btcpay-web",
|
"id": "btcpay-web",
|
||||||
"name": "BTCPay Server Web Access",
|
"name": "Put BTCPay Server Online",
|
||||||
"description": "Expose BTCPay Server to the internet via your domain. When disabled, BTCPay Server still runs locally but is not accessible from the web.",
|
"description": "Let people donate Bitcoin, pay you, or visit your online store. BTCPay Server stays private when this is turned off.",
|
||||||
"category": "bitcoin",
|
"category": "bitcoin",
|
||||||
"needs_domain": True,
|
"needs_domain": True,
|
||||||
"domain_name": "btcpayserver",
|
"domain_name": "btcpayserver",
|
||||||
@@ -487,9 +491,9 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
"Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch."
|
"Sovran_SystemsOS makes running a production-grade payment gateway as simple as flipping a switch."
|
||||||
),
|
),
|
||||||
"zeus-connect-setup.service": (
|
"zeus-connect-setup.service": (
|
||||||
"Connect the Zeus mobile wallet to your Lightning node via LND REST. Send and receive "
|
"Connect the Zeus mobile wallet to your Lightning node via LND REST over Tor. Send and receive "
|
||||||
"Lightning payments from your phone using a direct node connection. "
|
"Lightning payments from your phone using a direct node connection. "
|
||||||
"Scan the QR code to add your node to Zeus — this gives full node admin access."
|
"Scan the QR code to add your node to Zeus, then enable Use Tor — this gives full node admin access."
|
||||||
),
|
),
|
||||||
"mempool.service": (
|
"mempool.service": (
|
||||||
"Your own blockchain explorer and mempool visualizer. Monitor transactions, "
|
"Your own blockchain explorer and mempool visualizer. Monitor transactions, "
|
||||||
@@ -541,9 +545,9 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
|
|||||||
"Addresses on your Sovran_SystemsOS node."
|
"Addresses on your Sovran_SystemsOS node."
|
||||||
),
|
),
|
||||||
"gnome-remote-desktop.service": (
|
"gnome-remote-desktop.service": (
|
||||||
"Access your server's full desktop environment from anywhere using any RDP client. "
|
"Access your desktop from a device on your home network — no router setup needed. "
|
||||||
"Manage your system visually without being physically present. "
|
"Sovran_SystemsOS creates the address, username, and password for you: "
|
||||||
"Sovran_SystemsOS sets up secure remote access with generated credentials — connect and go."
|
"just open an RDP app and connect."
|
||||||
),
|
),
|
||||||
"sshd.service": (
|
"sshd.service": (
|
||||||
"Secure Shell (SSH) remote access. When enabled, authorized users can connect "
|
"Secure Shell (SSH) remote access. When enabled, authorized users can connect "
|
||||||
@@ -950,21 +954,43 @@ def _save_internal_ip(ip: str):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _get_external_ip() -> str:
|
def _save_external_ip(ip: str):
|
||||||
MAX_IP_LENGTH = 46
|
"""Write the external IP to a file so other services (e.g. LiveKit) can
|
||||||
for url in [
|
reference it without running their own detection."""
|
||||||
"https://api.ipify.org",
|
if ip and ip != "unavailable":
|
||||||
"https://ifconfig.me/ip",
|
|
||||||
"https://icanhazip.com",
|
|
||||||
]:
|
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, method="GET")
|
os.makedirs(os.path.dirname(EXTERNAL_IP_FILE), exist_ok=True)
|
||||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
with open(EXTERNAL_IP_FILE, "w") as f:
|
||||||
ip = resp.read().decode().strip()
|
f.write(ip)
|
||||||
if ip and len(ip) < MAX_IP_LENGTH:
|
except OSError:
|
||||||
return ip
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _get_external_ip() -> str:
|
||||||
|
"""Public IP via the shared detector (/var/lib/sovran/public-ip.py).
|
||||||
|
|
||||||
|
The detector owns discovery (STUN -> DNS -> opt-in HTTPS echo), caches the
|
||||||
|
result in /var/lib/secrets/external-ip, and contacts at most one third
|
||||||
|
party per refresh interval. This function only reads the cache and asks
|
||||||
|
the detector to refresh when it is missing or stale — it performs no
|
||||||
|
per-call external queries of its own.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
|
||||||
|
capture_output=True, text=True, timeout=20,
|
||||||
|
)
|
||||||
|
if r.returncode == 0 and r.stdout.strip():
|
||||||
|
return r.stdout.strip().splitlines()[0]
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
pass
|
||||||
|
try:
|
||||||
|
with open(EXTERNAL_IP_FILE) as f:
|
||||||
|
ip = f.read().strip()
|
||||||
|
if ip:
|
||||||
|
return ip
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
return "unavailable"
|
return "unavailable"
|
||||||
|
|
||||||
|
|
||||||
@@ -1532,10 +1558,16 @@ def _evaluate_domain_checklist(
|
|||||||
|
|
||||||
def _generate_qr_png_bytes(data: str, scale: int = 6, margin: int = 2) -> bytes | None:
|
def _generate_qr_png_bytes(data: str, scale: int = 6, margin: int = 2) -> bytes | None:
|
||||||
"""Generate a QR code PNG and return the raw bytes.
|
"""Generate a QR code PNG and return the raw bytes.
|
||||||
Uses qrencode CLI (available on the system via credentials.nix)."""
|
Uses qrencode CLI (available on the system via credentials.nix).
|
||||||
|
|
||||||
|
High error-correction (H) is preferred for short payloads. Long
|
||||||
|
lndconnect URIs can exceed version-40 capacity at H, so fall back to
|
||||||
|
quartile then low ECC — otherwise the Hub shows an empty Zeus QR.
|
||||||
|
"""
|
||||||
|
for ecc in ("H", "Q", "L"):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["qrencode", "-o", "-", "-t", "PNG", "-s", str(scale), "-m", str(margin), "-l", "H", data],
|
["qrencode", "-o", "-", "-t", "PNG", "-s", str(scale), "-m", str(margin), "-l", ecc, data],
|
||||||
capture_output=True, timeout=10,
|
capture_output=True, timeout=10,
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout:
|
if result.returncode == 0 and result.stdout:
|
||||||
@@ -1548,9 +1580,10 @@ def _generate_qr_png_bytes(data: str, scale: int = 6, margin: int = 2) -> bytes
|
|||||||
def _generate_qr_svg(data: str, scale: int = 10, margin: int = 4) -> str | None:
|
def _generate_qr_svg(data: str, scale: int = 10, margin: int = 4) -> str | None:
|
||||||
"""Generate a QR code SVG document (resolution-independent, ideal if the
|
"""Generate a QR code SVG document (resolution-independent, ideal if the
|
||||||
user wants to embed the QR in a website or print it at any size)."""
|
user wants to embed the QR in a website or print it at any size)."""
|
||||||
|
for ecc in ("H", "Q", "L"):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["qrencode", "-o", "-", "-t", "SVG", "-s", str(scale), "-m", str(margin), "-l", "H", data],
|
["qrencode", "-o", "-", "-t", "SVG", "-s", str(scale), "-m", str(margin), "-l", ecc, data],
|
||||||
capture_output=True, timeout=10,
|
capture_output=True, timeout=10,
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout:
|
if result.returncode == 0 and result.stdout:
|
||||||
@@ -1627,15 +1660,6 @@ def _nwc_lnurl_bech32(alias: str, domain: str) -> str:
|
|||||||
|
|
||||||
# ── Update helpers (file-based, no systemctl) ────────────────────
|
# ── Update helpers (file-based, no systemctl) ────────────────────
|
||||||
|
|
||||||
def _read_update_status() -> str:
|
|
||||||
"""Read the status file. Returns RUNNING, SUCCESS, REBOOT_REQUIRED, FAILED, or IDLE."""
|
|
||||||
try:
|
|
||||||
with open(UPDATE_STATUS, "r") as f:
|
|
||||||
return f.read().strip()
|
|
||||||
except FileNotFoundError:
|
|
||||||
return "IDLE"
|
|
||||||
|
|
||||||
|
|
||||||
def _write_update_status(status: str):
|
def _write_update_status(status: str):
|
||||||
"""Write to the status file."""
|
"""Write to the status file."""
|
||||||
try:
|
try:
|
||||||
@@ -1645,6 +1669,36 @@ def _write_update_status(status: str):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _read_update_status() -> str:
|
||||||
|
"""Read and reconcile the persistent update status.
|
||||||
|
|
||||||
|
``REBOOT_REQUIRED`` survives Hub/browser restarts before the reboot, but
|
||||||
|
it is a CLAIM about live NixOS state, not the source of truth: the boot
|
||||||
|
default (``/nix/var/nix/profiles/system``) versus the running
|
||||||
|
``/run/current-system``. Re-validating on every read keeps the Hub
|
||||||
|
correct when the system was updated from a terminal or support session
|
||||||
|
(which never writes Hub markers), and lets an old marker that predates
|
||||||
|
the reconciliation feature self-heal instead of demanding reboots
|
||||||
|
forever. The stale marker file is removed once cleared.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(UPDATE_STATUS, "r") as f:
|
||||||
|
status = f.read().strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "IDLE"
|
||||||
|
|
||||||
|
effective = effective_update_status(status)
|
||||||
|
if effective != status:
|
||||||
|
_write_update_status(effective)
|
||||||
|
try:
|
||||||
|
os.remove(UPDATE_GENERATION)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return effective
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
def _read_log(offset: int = 0) -> tuple[str, int]:
|
def _read_log(offset: int = 0) -> tuple[str, int]:
|
||||||
"""Read the update log file from the given byte offset.
|
"""Read the update log file from the given byte offset.
|
||||||
Returns (new_text, new_offset)."""
|
Returns (new_text, new_offset)."""
|
||||||
@@ -1680,6 +1734,9 @@ def _resolve_credential(cred: dict) -> dict | None:
|
|||||||
qr_data = _generate_qr_base64(result["value"])
|
qr_data = _generate_qr_base64(result["value"])
|
||||||
if qr_data:
|
if qr_data:
|
||||||
result["qrcode"] = qr_data
|
result["qrcode"] = qr_data
|
||||||
|
else:
|
||||||
|
# Don't hide the URI if we could not render a scannable QR.
|
||||||
|
qronly = False
|
||||||
if qronly:
|
if qronly:
|
||||||
result["qronly"] = True
|
result["qronly"] = True
|
||||||
return result
|
return result
|
||||||
@@ -1714,6 +1771,9 @@ def _resolve_credential(cred: dict) -> dict | None:
|
|||||||
qr_data = _generate_qr_base64(value)
|
qr_data = _generate_qr_base64(value)
|
||||||
if qr_data:
|
if qr_data:
|
||||||
result["qrcode"] = qr_data
|
result["qrcode"] = qr_data
|
||||||
|
else:
|
||||||
|
# Don't hide the URI if we could not render a scannable QR.
|
||||||
|
qronly = False
|
||||||
|
|
||||||
if qronly:
|
if qronly:
|
||||||
result["qronly"] = True
|
result["qronly"] = True
|
||||||
@@ -3682,6 +3742,9 @@ async def api_network():
|
|||||||
# Keep the internal-ip file in sync for credential lookups
|
# Keep the internal-ip file in sync for credential lookups
|
||||||
_save_internal_ip(internal)
|
_save_internal_ip(internal)
|
||||||
_cached_external_ip = external
|
_cached_external_ip = external
|
||||||
|
# Persist the external IP so other services (e.g. LiveKit) can reuse the
|
||||||
|
# Hub's detection instead of running their own.
|
||||||
|
_save_external_ip(external)
|
||||||
return {"internal_ip": internal, "external_ip": external}
|
return {"internal_ip": internal, "external_ip": external}
|
||||||
|
|
||||||
|
|
||||||
@@ -3819,9 +3882,15 @@ async def api_ports_health():
|
|||||||
@app.get("/api/updates/check")
|
@app.get("/api/updates/check")
|
||||||
async def api_updates_check():
|
async def api_updates_check():
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
status = await loop.run_in_executor(None, _read_update_status)
|
||||||
|
if status in {"RUNNING", "REBOOT_REQUIRED"}:
|
||||||
|
# Avoid a slow remote update check when there is already an operation
|
||||||
|
# the dashboard needs to surface.
|
||||||
|
return {"available": True, "status": status.lower()}
|
||||||
|
|
||||||
available = await loop.run_in_executor(None, check_for_updates)
|
available = await loop.run_in_executor(None, check_for_updates)
|
||||||
# None means inconclusive (check failed) — report as available so the UI doesn't block
|
# None means inconclusive (check failed) — report as available so the UI doesn't block
|
||||||
return {"available": available is not False}
|
return {"available": available is not False, "status": status.lower()}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/ping")
|
@app.get("/api/ping")
|
||||||
@@ -6168,10 +6237,19 @@ async def _startup_migrate_deprecated_features():
|
|||||||
|
|
||||||
async def _background_domain_reachability_checker():
|
async def _background_domain_reachability_checker():
|
||||||
"""Periodically curl configured domains and cache reachability results."""
|
"""Periodically curl configured domains and cache reachability results."""
|
||||||
|
global _cached_external_ip
|
||||||
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
|
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
|
||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
# Keep the persisted external IP fresh (dynamic WAN IPs), so
|
||||||
|
# services like LiveKit can read /var/lib/secrets/external-ip.
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
external = await loop.run_in_executor(None, _get_external_ip)
|
||||||
|
if external != "unavailable":
|
||||||
|
_cached_external_ip = external
|
||||||
|
_save_external_ip(external)
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
services = cfg.get("services", [])
|
services = cfg.get("services", [])
|
||||||
|
|
||||||
@@ -6181,7 +6259,6 @@ async def _background_domain_reachability_checker():
|
|||||||
if unit is not None
|
if unit is not None
|
||||||
}
|
}
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
overrides, *_ = await loop.run_in_executor(None, _read_hub_overrides)
|
overrides, *_ = await loop.run_in_executor(None, _read_hub_overrides)
|
||||||
|
|
||||||
domains_to_check: list[str] = []
|
domains_to_check: list[str] = []
|
||||||
|
|||||||
@@ -5,10 +5,16 @@
|
|||||||
const POLL_INTERVAL_SERVICES = 5000;
|
const POLL_INTERVAL_SERVICES = 5000;
|
||||||
const POLL_INTERVAL_UPDATES = 1800000;
|
const POLL_INTERVAL_UPDATES = 1800000;
|
||||||
const UPDATE_POLL_INTERVAL = 2000;
|
const UPDATE_POLL_INTERVAL = 2000;
|
||||||
// Max consecutive failed rebuild/update status polls before the page gives up
|
// A pending fetch never rejects by itself. Bound every status request so a
|
||||||
// waiting and reloads to re-sync (2s interval → ~2 minutes of failures).
|
// wedged browser connection cannot leave the modal spinning forever.
|
||||||
// A brief Hub restart during activation only causes a handful of failures.
|
const STATUS_POLL_FETCH_TIMEOUT = 15000;
|
||||||
const STATUS_POLL_MAX_FAILURES = 60;
|
// Eight timed-out requests plus the poll interval is a little over two minutes.
|
||||||
|
// A brief Hub restart or a heavily loaded Nix build remains well inside this.
|
||||||
|
const STATUS_POLL_MAX_FAILURES = 8;
|
||||||
|
// Keep verbose Nix output from making textContent updates quadratic and
|
||||||
|
// freezing the Hub renderer (especially noticeable over RDP).
|
||||||
|
const UPDATE_VISIBLE_LOG_MAX_CHARS = 250000;
|
||||||
|
const UPDATE_VISIBLE_LOG_TRIM_CHARS = 200000;
|
||||||
const REBOOT_CHECK_INTERVAL = 5000;
|
const REBOOT_CHECK_INTERVAL = 5000;
|
||||||
const REBOOT_FETCH_TIMEOUT = 12000;
|
const REBOOT_FETCH_TIMEOUT = 12000;
|
||||||
const REBOOT_REQUEST_TIMEOUT = 4000;
|
const REBOOT_REQUEST_TIMEOUT = 4000;
|
||||||
|
|||||||
@@ -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
|
This is the single source of truth for the simple setup guidance shown in:
|
||||||
forwarding" guidance that must read identically everywhere it appears:
|
|
||||||
|
|
||||||
• First-boot onboarding wizard (Server + Desktop role)
|
• First-boot onboarding for Server + Desktop
|
||||||
— onboarding.js step 3
|
• Feature setup in the Hub
|
||||||
• Feature-enable domain modal (Node role, and any role)
|
• Domain reconfiguration and troubleshooting
|
||||||
— features.js openDomainSetupModal()
|
|
||||||
(Lightning Wallet Connections / NWC, BTCPay Server, Haven, …)
|
|
||||||
• Domain reconfigure / troubleshooting modal
|
|
||||||
— features.js openDomainReconfigureModal()
|
|
||||||
|
|
||||||
Keep these three surfaces word-for-word consistent: always edit this
|
Keep the wording consistent by editing it here. */
|
||||||
file, never fork the wording inline. Plain classic script (no modules) —
|
|
||||||
both templates load it with a plain <script> tag. */
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
/* Escape helper local to this file so it is self-contained on both pages. */
|
|
||||||
function dpEsc(str) {
|
function dpEsc(str) {
|
||||||
return String(str)
|
return String(str)
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -26,105 +18,71 @@ function dpEsc(str) {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── "What you'll need" intro ──────────────────────────────────────
|
/* Explain only what the user needs for the step in front of them. */
|
||||||
opts.serviceName — e.g. "Lightning Wallet Connections", or null for
|
|
||||||
the multi-service (onboarding) wording.
|
|
||||||
opts.hostExample — e.g. "lightning" → lightning.yourdomain.com */
|
|
||||||
function renderDomainNeedsHtml(opts) {
|
function renderDomainNeedsHtml(opts) {
|
||||||
|
opts = opts || {};
|
||||||
var serviceName = opts.serviceName || null;
|
var serviceName = opts.serviceName || null;
|
||||||
var hostExample = dpEsc(opts.hostExample || "myservice");
|
var purpose = opts.purpose || "";
|
||||||
var html = "";
|
var html = "";
|
||||||
|
|
||||||
if (serviceName) {
|
if (purpose === "btcpay") {
|
||||||
html += "<p>To enable <strong>" + dpEsc(serviceName) + "</strong>, you'll need two things first:</p>";
|
html += "<p><strong>Put your BTCPay Server online</strong></p>";
|
||||||
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
html += "<p>Let people donate Bitcoin, pay you, or visit your online store.</p>";
|
||||||
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> "
|
html += "<p>BTCPay Server needs a domain so people can reach it. "
|
||||||
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
|
+ "Sovran_SystemsOS walks you through getting your domain from "
|
||||||
+ dpEsc(serviceName) + " gets its own hostname: a subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
|
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||||
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many.</li>";
|
+ " and connecting it. Just follow the steps below.</p>";
|
||||||
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
|
} else if (serviceName) {
|
||||||
+ "to this computer once. All domain-based services share these two ports; "
|
html += "<p>To make <strong>" + dpEsc(serviceName) + "</strong> available outside your home, it needs a domain.</p>";
|
||||||
+ "they're required for HTTPS and SSL certificates.</li>";
|
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
|
||||||
html += "</ol>";
|
+ '<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 {
|
} else {
|
||||||
html += "<p><strong>Each service below needs two things set up:</strong></p>";
|
html += "<p>Your domain is the address people use to reach your services on the internet.</p>";
|
||||||
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
|
||||||
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> "
|
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||||
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
|
+ " and connecting your services. Just follow the steps below.</p>";
|
||||||
+ "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>";
|
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── "How to set it up at Njal.la" steps ───────────────────────────
|
/* The shortest Njal.la steps that still tell the user exactly what to do. */
|
||||||
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). */
|
|
||||||
function renderNjallaStepsHtml(opts) {
|
function renderNjallaStepsHtml(opts) {
|
||||||
var hostExample = dpEsc((opts && opts.hostExample) || "call");
|
var hostExample = dpEsc((opts && opts.hostExample) || "call");
|
||||||
var pasteHint = (opts && opts.pasteHint) || "below";
|
var pasteHint = (opts && opts.pasteHint) || "below";
|
||||||
var html = "";
|
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 += '<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>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>Add a <strong>Dynamic</strong> record for the hostname:"
|
html += "<li>Open your domain and add a <strong>Dynamic</strong> record for this service. "
|
||||||
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
|
+ "In the <strong>Name</strong> box, enter only the first part of the address. "
|
||||||
+ "<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>"
|
+ "For <code>" + hostExample + ".yourdomain.com</code>, enter <code>" + hostExample + "</code>. "
|
||||||
+ "(For "" + hostExample + ".yourdomain.com" you'd type just: <code>" + hostExample + "</code>.)<br>"
|
+ "If you are using the whole domain, leave the box blank or enter <code>@</code>.</li>";
|
||||||
+ "⚠ Do NOT type the full domain here — Njal.la adds it automatically.</li>"
|
html += "<li>Copy the update command Njal.la gives you and paste it " + pasteHint + ".</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'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 "https://njal.la/update/?h=' + hostExample + '.yourdomain.com&k=abc123&auto"</code><br>'
|
|
||||||
+ "Copy it and paste it " + pasteHint + ".</li>";
|
|
||||||
html += "</ol>";
|
html += "</ol>";
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── "One router task" port-forwarding box ─────────────────────────
|
/* One router task shared by all normal domain-based services. */
|
||||||
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). */
|
|
||||||
function renderRouterPortsHtml(opts) {
|
function renderRouterPortsHtml(opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var ipPart = opts.internalIp
|
var ipPart = opts.internalIp
|
||||||
? " to this computer’s internal IP <strong>" + dpEsc(opts.internalIp) + "</strong>"
|
? " to this computer at <strong>" + dpEsc(opts.internalIp) + "</strong>"
|
||||||
: " to this computer’s internal IP";
|
: " to this computer";
|
||||||
var serviceWord = opts.plural ? "services" : "service";
|
|
||||||
|
|
||||||
var html = "";
|
var html = "";
|
||||||
html += "🔌 <strong>One router task:</strong> in your router’s <strong>port forwarding</strong> settings, "
|
html += "🔌 <strong>One router task:</strong> in your router’s <strong>Port Forwarding</strong> settings, "
|
||||||
+ "forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>"
|
+ "forward <strong>80 (TCP)</strong> and <strong>443 (TCP)</strong>"
|
||||||
+ ipPart + ". Use the <strong>same number for the internal and external port</strong>. "
|
+ ipPart + ". If your router asks for outside and inside port numbers, use the same number for both. "
|
||||||
+ "This only needs to be done once — all domain services share these ports — "
|
+ "You only need to do this once. All your services share these two ports.";
|
||||||
+ "but HTTPS and SSL certificates won’t work without them, so your " + serviceWord + " "
|
|
||||||
+ "can’t be reached from outside your home network. "
|
|
||||||
+ "You’ll need normal access to your router’s settings with working port forwarding — "
|
|
||||||
+ "if your ISP blocks it (e.g. CGNAT), domain-based services can’t be reached from the internet. ";
|
|
||||||
if (opts.includeSsh) {
|
|
||||||
html += "Add port <strong>22 (TCP)</strong> as well if you want remote SSH access. ";
|
|
||||||
}
|
|
||||||
if (opts.extraNote) {
|
if (opts.extraNote) {
|
||||||
html += dpEsc(opts.extraNote);
|
html += " " + dpEsc(opts.extraNote);
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Async helper: fill a router-box container with the internal IP ──
|
/* Show generic wording immediately, then add the computer's address when found. */
|
||||||
Renders generic text immediately, then upgrades to the concrete LAN IP
|
|
||||||
if /api/network provides one. Best-effort — never blocks the UI. */
|
|
||||||
function renderRouterPortsBox(elId, opts) {
|
function renderRouterPortsBox(elId, opts) {
|
||||||
var el = document.getElementById(elId);
|
var el = document.getElementById(elId);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -142,5 +100,5 @@ function renderRouterPortsBox(elId, opts) {
|
|||||||
target.innerHTML = renderRouterPortsHtml(next);
|
target.innerHTML = renderRouterPortsHtml(next);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(function() { /* generic wording already shown — fine */ });
|
.catch(function() { /* Generic wording is already shown. */ });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,16 @@
|
|||||||
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
||||||
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
||||||
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.addEventListener("click", retryUpdateStatus);
|
||||||
|
|
||||||
|
// Browser timers and requests may be suspended while an RDP session/tab is in
|
||||||
|
// the background. Reconcile immediately when the user returns instead of
|
||||||
|
// waiting for the next interval.
|
||||||
|
window.addEventListener("focus", resumeUpdateStatusAfterInterruption);
|
||||||
|
window.addEventListener("online", resumeUpdateStatusAfterInterruption);
|
||||||
|
document.addEventListener("visibilitychange", function() {
|
||||||
|
if (document.visibilityState === "visible") resumeUpdateStatusAfterInterruption();
|
||||||
|
});
|
||||||
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal);
|
||||||
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal);
|
||||||
|
|
||||||
@@ -248,6 +258,11 @@ async function init() {
|
|||||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||||
loadAutolaunchToggle();
|
loadAutolaunchToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the page was reloaded or the RDP/browser session resumed during an
|
||||||
|
// update, reopen the modal from the persisted backend state. This also
|
||||||
|
// surfaces a completed update that is waiting for its activation reboot.
|
||||||
|
await restoreUpdateModalIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
@@ -60,6 +60,7 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
||||||
|
|
||||||
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
||||||
|
var isBtcpay = feat.id === "btcpay-web";
|
||||||
|
|
||||||
var npubField = "";
|
var npubField = "";
|
||||||
if (feat.id === "haven") {
|
if (feat.id === "haven") {
|
||||||
@@ -77,30 +78,30 @@ function openDomainSetupModal(feat, onSaved) {
|
|||||||
|
|
||||||
var nwcWarning = isWalletConnections
|
var nwcWarning = isWalletConnections
|
||||||
? '<div class="domain-nwc-warning">' +
|
? '<div class="domain-nwc-warning">' +
|
||||||
'<strong>⚠ Lightning Wallet Connections requires its own unique hostname.</strong> ' +
|
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
|
||||||
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
|
||||||
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
|
||||||
'</div>'
|
'</div>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
|
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : (isBtcpay ? "btcpay.yourdomain.com" : "myservice.example.com");
|
||||||
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.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) —
|
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
|
||||||
// identical wording to the Server + Desktop onboarding wizard and every other
|
// identical wording to the Server + Desktop onboarding wizard and every other
|
||||||
// domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
|
// 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 =
|
$domainSetupBody.innerHTML =
|
||||||
'<div class="domain-setup-intro">' +
|
'<div class="domain-setup-intro">' +
|
||||||
nwcWarning +
|
nwcWarning +
|
||||||
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample }) +
|
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample, purpose: purpose }) +
|
||||||
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
|
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
|
||||||
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
|
'<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>' +
|
||||||
'<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-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 Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=' + domainPlaceholder + '&k=abc123&auto"" /><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-ddns-input">Njal.la Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=' + domainPlaceholder + '&k=abc123&auto"" /><p class="domain-field-hint">Paste the update command from Njal.la</p></div>' +
|
||||||
npubField +
|
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 & Enable</button></div>';
|
'<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 & Enable</button></div>';
|
||||||
|
|
||||||
@@ -169,9 +170,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
|||||||
|
|
||||||
var nwcWarning = isWalletConnections
|
var nwcWarning = isWalletConnections
|
||||||
? '<div class="domain-nwc-warning">' +
|
? '<div class="domain-nwc-warning">' +
|
||||||
'<strong>⚠ Lightning Wallet Connections requires its own unique hostname.</strong> ' +
|
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
|
||||||
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
|
||||||
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
|
||||||
'</div>'
|
'</div>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
|||||||
@@ -144,3 +144,22 @@ async function apiFetch(path, options) {
|
|||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function apiFetchWithTimeout(path, options, timeoutMs) {
|
||||||
|
var controller = new AbortController();
|
||||||
|
var fetchOptions = Object.assign({}, options || {});
|
||||||
|
fetchOptions.signal = controller.signal;
|
||||||
|
var timer = setTimeout(function() { controller.abort(); }, timeoutMs);
|
||||||
|
try {
|
||||||
|
return await apiFetch(path, fetchOptions);
|
||||||
|
} catch (err) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
var timeoutError = new Error("Request timed out");
|
||||||
|
timeoutError.name = "TimeoutError";
|
||||||
|
throw timeoutError;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function openRebuildModal() {
|
|||||||
_rebuildLogOffset = 0;
|
_rebuildLogOffset = 0;
|
||||||
_rebuildServerDown = false;
|
_rebuildServerDown = false;
|
||||||
_rebuildFinished = false;
|
_rebuildFinished = false;
|
||||||
|
_rebuildPollInFlight = false;
|
||||||
_rebuildPollFailures = 0;
|
_rebuildPollFailures = 0;
|
||||||
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
|
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
|
||||||
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
|
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
|
||||||
@@ -34,6 +35,7 @@ function appendRebuildLog(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startRebuildPoll() {
|
function startRebuildPoll() {
|
||||||
|
if (_rebuildPollTimer) clearInterval(_rebuildPollTimer);
|
||||||
pollRebuildStatus();
|
pollRebuildStatus();
|
||||||
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
|
_rebuildPollTimer = setInterval(pollRebuildStatus, UPDATE_POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
@@ -43,9 +45,14 @@ function stopRebuildPoll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollRebuildStatus() {
|
async function pollRebuildStatus() {
|
||||||
if (_rebuildFinished) return;
|
if (_rebuildFinished || _rebuildPollInFlight) return;
|
||||||
|
_rebuildPollInFlight = true;
|
||||||
try {
|
try {
|
||||||
var data = await apiFetch("/api/rebuild/status?offset=" + _rebuildLogOffset);
|
var data = await apiFetchWithTimeout(
|
||||||
|
"/api/rebuild/status?offset=" + _rebuildLogOffset,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
_rebuildPollFailures = 0;
|
_rebuildPollFailures = 0;
|
||||||
if (_rebuildServerDown) { _rebuildServerDown = false; }
|
if (_rebuildServerDown) { _rebuildServerDown = false; }
|
||||||
if (data.log) appendRebuildLog(data.log);
|
if (data.log) appendRebuildLog(data.log);
|
||||||
@@ -61,10 +68,8 @@ async function pollRebuildStatus() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
_rebuildPollFailures += 1;
|
_rebuildPollFailures += 1;
|
||||||
// The Hub restarts itself during activation, which briefly drops this poll.
|
// The Hub restarts itself during activation, which briefly drops this poll.
|
||||||
// If polling stays broken long past a normal restart, the page's session
|
// If polling stays broken long past a normal restart, reload to
|
||||||
// almost certainly no longer matches the server (e.g. the Hub restarted
|
// re-authenticate and show the resulting feature state.
|
||||||
// and sessions were not recovered). Reload to re-authenticate and show
|
|
||||||
// the real feature state instead of spinning forever.
|
|
||||||
if (_rebuildPollFailures >= STATUS_POLL_MAX_FAILURES) {
|
if (_rebuildPollFailures >= STATUS_POLL_MAX_FAILURES) {
|
||||||
_rebuildFinished = true;
|
_rebuildFinished = true;
|
||||||
stopRebuildPoll();
|
stopRebuildPoll();
|
||||||
@@ -72,6 +77,8 @@ async function pollRebuildStatus() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
||||||
|
} finally {
|
||||||
|
_rebuildPollInFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ function _getZeusConnectGuideHtml() {
|
|||||||
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
|
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
|
||||||
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
|
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
|
||||||
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
|
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
|
||||||
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the LND REST QR and fills in the connection details. Review them, then tap <strong>Save Wallet Config</strong>.</div></div>' +
|
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the LND REST QR and fills in the connection details. Turn <strong>Use Tor</strong> on (the host is a .onion address), then tap <strong>Save Wallet Config</strong>.</div></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the NWC pairing QR shown in Lightning Wallet Connections — that gives your wallet sandboxed, limited access for everyday spending. LND REST connects Zeus directly to your node for full admin control.</div>' +
|
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the NWC pairing QR shown in Lightning Wallet Connections — that gives your wallet sandboxed, limited access for everyday spending. LND REST connects Zeus directly to your node for full admin control. The QR uses your dedicated LND REST Tor address (no TLS cert) so Zeus can scan and connect over Tor.</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ let _servicesCache = [];
|
|||||||
let _categoryLabels = {};
|
let _categoryLabels = {};
|
||||||
let _updateLog = "";
|
let _updateLog = "";
|
||||||
let _updatePollTimer = null;
|
let _updatePollTimer = null;
|
||||||
|
let _updatePollInFlight = false;
|
||||||
let _updateLogOffset = 0;
|
let _updateLogOffset = 0;
|
||||||
|
let _updateVisibleLogChars = 0;
|
||||||
let _serverWasDown = false;
|
let _serverWasDown = false;
|
||||||
let _updateFinished = false;
|
let _updateFinished = false;
|
||||||
|
let _updateStatusUnavailable = false;
|
||||||
let _updatePollFailures = 0; // consecutive failed update-status polls
|
let _updatePollFailures = 0; // consecutive failed update-status polls
|
||||||
let _supportTimerInt = null;
|
let _supportTimerInt = null;
|
||||||
let _supportEnabledAt = null;
|
let _supportEnabledAt = null;
|
||||||
@@ -24,6 +27,7 @@ let _featuresData = null;
|
|||||||
let _rebuildLog = "";
|
let _rebuildLog = "";
|
||||||
let _rebuildLogOffset = 0;
|
let _rebuildLogOffset = 0;
|
||||||
let _rebuildPollTimer = null;
|
let _rebuildPollTimer = null;
|
||||||
|
let _rebuildPollInFlight = false;
|
||||||
let _rebuildFinished = false;
|
let _rebuildFinished = false;
|
||||||
let _rebuildServerDown = false;
|
let _rebuildServerDown = false;
|
||||||
let _rebuildPollFailures = 0; // consecutive failed rebuild-status polls
|
let _rebuildPollFailures = 0; // consecutive failed rebuild-status polls
|
||||||
@@ -48,6 +52,7 @@ const $modalStatus = document.getElementById("modal-status");
|
|||||||
const $modalLog = document.getElementById("modal-log");
|
const $modalLog = document.getElementById("modal-log");
|
||||||
const $btnReboot = document.getElementById("btn-reboot");
|
const $btnReboot = document.getElementById("btn-reboot");
|
||||||
const $btnSave = document.getElementById("btn-save-report");
|
const $btnSave = document.getElementById("btn-save-report");
|
||||||
|
const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
|
||||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||||
|
|
||||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||||
|
|||||||
@@ -271,10 +271,19 @@ async function checkUpdates() {
|
|||||||
try {
|
try {
|
||||||
var data = await apiFetch("/api/updates/check");
|
var data = await apiFetch("/api/updates/check");
|
||||||
var hasUpdates = !!data.available;
|
var hasUpdates = !!data.available;
|
||||||
|
var updateStatus = data.status || "idle";
|
||||||
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
||||||
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
||||||
if (sidebarUpdateBtn) {
|
if (sidebarUpdateBtn) {
|
||||||
if (hasUpdates) {
|
if (updateStatus === "reboot_required") {
|
||||||
|
sidebarUpdateBtn.style.borderColor = "#e5a50a";
|
||||||
|
sidebarUpdateBtn.style.backgroundColor = "rgba(229, 165, 10, 0.10)";
|
||||||
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
|
||||||
|
} else if (updateStatus === "running") {
|
||||||
|
sidebarUpdateBtn.style.borderColor = "#3584e4";
|
||||||
|
sidebarUpdateBtn.style.backgroundColor = "rgba(53, 132, 228, 0.10)";
|
||||||
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
|
||||||
|
} else if (hasUpdates) {
|
||||||
sidebarUpdateBtn.style.borderColor = "#2ec27e";
|
sidebarUpdateBtn.style.borderColor = "#2ec27e";
|
||||||
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)";
|
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)";
|
||||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
||||||
|
|||||||
@@ -2,20 +2,45 @@
|
|||||||
|
|
||||||
// ── Update modal ──────────────────────────────────────────────────
|
// ── Update modal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function openUpdateModal() {
|
async function openUpdateModal() {
|
||||||
if (!$modal) return;
|
if (!$modal) return;
|
||||||
apiFetch("/api/updates/check")
|
|
||||||
|
// Reattach before checking for new updates. This makes a browser reload,
|
||||||
|
// RDP reconnect, or suspended tab recover the authoritative systemd-backed
|
||||||
|
// state instead of starting over or claiming the system is merely up to date.
|
||||||
|
try {
|
||||||
|
var current = await apiFetchWithTimeout(
|
||||||
|
"/api/updates/status?offset=0",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
|
if (current.running || current.result === "reboot_required") {
|
||||||
|
showExistingUpdate(current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// The normal start path below has its own visible error handling.
|
||||||
|
}
|
||||||
|
|
||||||
|
apiFetchWithTimeout(
|
||||||
|
"/api/updates/check",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
)
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.available) {
|
if (!data.available) {
|
||||||
stopUpdatePoll();
|
stopUpdatePoll();
|
||||||
_updateLog = "";
|
_updateLog = "";
|
||||||
_updateLogOffset = 0;
|
_updateLogOffset = 0;
|
||||||
|
_updateVisibleLogChars = 0;
|
||||||
_updateFinished = true;
|
_updateFinished = true;
|
||||||
|
_updateStatusUnavailable = false;
|
||||||
if ($modalLog) $modalLog.textContent = "";
|
if ($modalLog) $modalLog.textContent = "";
|
||||||
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
||||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
if ($btnReboot) $btnReboot.style.display = "none";
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
if ($btnSave) $btnSave.style.display = "none";
|
if ($btnSave) $btnSave.style.display = "none";
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
$modal.classList.add("open");
|
$modal.classList.add("open");
|
||||||
return;
|
return;
|
||||||
@@ -27,23 +52,69 @@ function openUpdateModal() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _doOpenUpdateModal() {
|
function prepareUpdateModal() {
|
||||||
if (!$modal) return;
|
if (!$modal) return;
|
||||||
|
stopUpdatePoll();
|
||||||
_updateLog = "";
|
_updateLog = "";
|
||||||
_updateLogOffset = 0;
|
_updateLogOffset = 0;
|
||||||
|
_updateVisibleLogChars = 0;
|
||||||
|
_updatePollInFlight = false;
|
||||||
_serverWasDown = false;
|
_serverWasDown = false;
|
||||||
_updateFinished = false;
|
_updateFinished = false;
|
||||||
|
_updateStatusUnavailable = false;
|
||||||
_updatePollFailures = 0;
|
_updatePollFailures = 0;
|
||||||
if ($modalLog) $modalLog.textContent = "";
|
if ($modalLog) $modalLog.textContent = "";
|
||||||
if ($modalStatus) $modalStatus.textContent = "Starting update…";
|
if ($modalStatus) $modalStatus.textContent = "Starting update…";
|
||||||
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||||
if ($btnReboot) $btnReboot.style.display = "none";
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
if ($btnSave) $btnSave.style.display = "none";
|
if ($btnSave) $btnSave.style.display = "none";
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||||
$modal.classList.add("open");
|
$modal.classList.add("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _doOpenUpdateModal() {
|
||||||
|
prepareUpdateModal();
|
||||||
startUpdate();
|
startUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showExistingUpdate(data) {
|
||||||
|
prepareUpdateModal();
|
||||||
|
if (data.log) appendLog(data.log);
|
||||||
|
_updateLogOffset = Number(data.offset) || 0;
|
||||||
|
|
||||||
|
if (data.running) {
|
||||||
|
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||||
|
startUpdatePoll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateFinished = true;
|
||||||
|
if (data.result === "reboot_required") {
|
||||||
|
onUpdateDone("reboot_required");
|
||||||
|
} else if (data.result === "success") {
|
||||||
|
onUpdateDone(true);
|
||||||
|
} else {
|
||||||
|
onUpdateDone(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreUpdateModalIfNeeded() {
|
||||||
|
if (!$modal || $modal.classList.contains("open")) return;
|
||||||
|
try {
|
||||||
|
var data = await apiFetchWithTimeout(
|
||||||
|
"/api/updates/status?offset=0",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
|
if (data.running || data.result === "reboot_required") {
|
||||||
|
showExistingUpdate(data);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Dashboard startup must remain usable when status cannot be reached.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeUpdateModal() {
|
function closeUpdateModal() {
|
||||||
if (!$modal) return;
|
if (!$modal) return;
|
||||||
$modal.classList.remove("open");
|
$modal.classList.remove("open");
|
||||||
@@ -53,21 +124,36 @@ function closeUpdateModal() {
|
|||||||
function appendLog(text) {
|
function appendLog(text) {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
_updateLog += text;
|
_updateLog += text;
|
||||||
if ($modalLog) { $modalLog.textContent += text; $modalLog.scrollTop = $modalLog.scrollHeight; }
|
if ($modalLog) {
|
||||||
|
// Appending a text node avoids reparsing/replacing the complete log on
|
||||||
|
// every two-second poll. Trim only occasionally once the visible log is
|
||||||
|
// large; the complete _updateLog remains available for error reports.
|
||||||
|
if (_updateVisibleLogChars + text.length > UPDATE_VISIBLE_LOG_MAX_CHARS) {
|
||||||
|
var tail = _updateLog.slice(-UPDATE_VISIBLE_LOG_TRIM_CHARS);
|
||||||
|
var notice = "[Earlier update output hidden from this view; it remains in the saved report.]\n\n";
|
||||||
|
$modalLog.textContent = notice + tail;
|
||||||
|
_updateVisibleLogChars = notice.length + tail.length;
|
||||||
|
} else {
|
||||||
|
$modalLog.appendChild(document.createTextNode(text));
|
||||||
|
_updateVisibleLogChars += text.length;
|
||||||
|
}
|
||||||
|
$modalLog.scrollTop = $modalLog.scrollHeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startUpdate() {
|
function startUpdate() {
|
||||||
fetch("/api/updates/run", { method: "POST" })
|
apiFetchWithTimeout(
|
||||||
.then(function(response) {
|
"/api/updates/run",
|
||||||
if (!response.ok) return response.text().then(function(t) { throw new Error(t); });
|
{ method: "POST" },
|
||||||
return response.json();
|
STATUS_POLL_FETCH_TIMEOUT * 2
|
||||||
})
|
)
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.status === "no_updates") {
|
if (data.status === "no_updates") {
|
||||||
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
||||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
if ($btnReboot) $btnReboot.style.display = "none";
|
if ($btnReboot) $btnReboot.style.display = "none";
|
||||||
if ($btnSave) $btnSave.style.display = "none";
|
if ($btnSave) $btnSave.style.display = "none";
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
_updateFinished = true;
|
_updateFinished = true;
|
||||||
return;
|
return;
|
||||||
@@ -83,6 +169,7 @@ function startUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startUpdatePoll() {
|
function startUpdatePoll() {
|
||||||
|
if (_updatePollTimer) clearInterval(_updatePollTimer);
|
||||||
pollUpdateStatus();
|
pollUpdateStatus();
|
||||||
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
|
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
|
||||||
}
|
}
|
||||||
@@ -92,33 +179,45 @@ function stopUpdatePoll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pollUpdateStatus() {
|
async function pollUpdateStatus() {
|
||||||
if (_updateFinished) return;
|
// setInterval does not wait for an async callback. The guard prevents a slow
|
||||||
|
// request from creating overlapping, out-of-order status polls.
|
||||||
|
if (_updateFinished || _updatePollInFlight) return;
|
||||||
|
_updatePollInFlight = true;
|
||||||
try {
|
try {
|
||||||
var data = await apiFetch("/api/updates/status?offset=" + _updateLogOffset);
|
var data = await apiFetchWithTimeout(
|
||||||
|
"/api/updates/status?offset=" + _updateLogOffset,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
_updatePollFailures = 0;
|
_updatePollFailures = 0;
|
||||||
if (_serverWasDown) {
|
if (_serverWasDown) {
|
||||||
_serverWasDown = false;
|
_serverWasDown = false;
|
||||||
if (!data.running) {
|
if (!data.running) {
|
||||||
// The update finished while the server was restarting. Reset to
|
// The update finished while the server or browser connection was away.
|
||||||
// offset 0 and re-fetch so the complete log is shown from the top.
|
// Re-fetch from offset 0 so the final result and complete tail agree.
|
||||||
_updateLog = "";
|
_updateLog = "";
|
||||||
_updateLogOffset = 0;
|
_updateLogOffset = 0;
|
||||||
|
_updateVisibleLogChars = 0;
|
||||||
if ($modalLog) $modalLog.textContent = "";
|
if ($modalLog) $modalLog.textContent = "";
|
||||||
try {
|
try {
|
||||||
var fullData = await apiFetch("/api/updates/status?offset=0");
|
var fullData = await apiFetchWithTimeout(
|
||||||
|
"/api/updates/status?offset=0",
|
||||||
|
{ cache: "no-store" },
|
||||||
|
STATUS_POLL_FETCH_TIMEOUT
|
||||||
|
);
|
||||||
if (fullData.log) appendLog(fullData.log);
|
if (fullData.log) appendLog(fullData.log);
|
||||||
_updateLogOffset = fullData.offset;
|
_updateLogOffset = fullData.offset;
|
||||||
} catch (e) {
|
data = fullData;
|
||||||
// If the re-fetch fails, fall through with whatever we have.
|
} catch (_) {
|
||||||
if (data.log) appendLog(data.log);
|
if (data.log) appendLog(data.log);
|
||||||
_updateLogOffset = data.offset;
|
_updateLogOffset = data.offset;
|
||||||
}
|
}
|
||||||
if (data.result === "reboot_required") {
|
if (data.result === "reboot_required") {
|
||||||
appendLog("[Server restarted — update completed, reboot required.]\n");
|
appendLog("[Reconnected — update completed, reboot required.]\n");
|
||||||
} else if (data.result === "success") {
|
} else if (data.result === "success") {
|
||||||
appendLog("[Server restarted — update completed successfully.]\n");
|
appendLog("[Reconnected — update completed successfully.]\n");
|
||||||
} else {
|
} else {
|
||||||
appendLog("[Server restarted — update encountered an error.]\n");
|
appendLog("[Reconnected — update encountered an error.]\n");
|
||||||
}
|
}
|
||||||
_updateFinished = true;
|
_updateFinished = true;
|
||||||
stopUpdatePoll();
|
stopUpdatePoll();
|
||||||
@@ -129,7 +228,7 @@ async function pollUpdateStatus() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
appendLog("[Server reconnected]\n");
|
appendLog("[Update status reconnected]\n");
|
||||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||||
}
|
}
|
||||||
if (data.log) appendLog(data.log);
|
if (data.log) appendLog(data.log);
|
||||||
@@ -146,21 +245,57 @@ async function pollUpdateStatus() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
_updatePollFailures += 1;
|
_updatePollFailures += 1;
|
||||||
// Same guard as the rebuild modal: if polling stays broken long past a
|
|
||||||
// normal Hub restart, reload to re-authenticate and show the real state
|
|
||||||
// instead of spinning forever.
|
|
||||||
if (_updatePollFailures >= STATUS_POLL_MAX_FAILURES) {
|
if (_updatePollFailures >= STATUS_POLL_MAX_FAILURES) {
|
||||||
_updateFinished = true;
|
showUpdateStatusUnavailable();
|
||||||
stopUpdatePoll();
|
|
||||||
window.location.reload();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_serverWasDown) { _serverWasDown = true; appendLog("\n[Server restarting — waiting for it to come back…]\n"); if ($modalStatus) $modalStatus.textContent = "Server restarting…"; }
|
if (!_serverWasDown) {
|
||||||
|
_serverWasDown = true;
|
||||||
|
appendLog("\n[Update status connection interrupted — retrying…]\n");
|
||||||
|
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_updatePollInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUpdateStatusUnavailable() {
|
||||||
|
_updateFinished = true;
|
||||||
|
_updateStatusUnavailable = true;
|
||||||
|
stopUpdatePoll();
|
||||||
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
|
if ($modalStatus) $modalStatus.textContent = "Update status unavailable — update may still be running";
|
||||||
|
appendLog("\n[The Hub could not confirm update status. The background update was not stopped. Select Retry Status after reconnecting.]\n");
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "inline-flex";
|
||||||
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryUpdateStatus() {
|
||||||
|
if (!$modal) return;
|
||||||
|
_updateFinished = false;
|
||||||
|
_updateStatusUnavailable = false;
|
||||||
|
_updatePollFailures = 0;
|
||||||
|
_serverWasDown = true;
|
||||||
|
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||||
|
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…";
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
|
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||||
|
startUpdatePoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeUpdateStatusAfterInterruption() {
|
||||||
|
if (!$modal || !$modal.classList.contains("open")) return;
|
||||||
|
if (_updateStatusUnavailable) {
|
||||||
|
retryUpdateStatus();
|
||||||
|
} else if (!_updateFinished) {
|
||||||
|
pollUpdateStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUpdateDone(result) {
|
function onUpdateDone(result) {
|
||||||
|
_updateStatusUnavailable = false;
|
||||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||||
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||||
if (result === true) {
|
if (result === true) {
|
||||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||||
@@ -187,6 +322,7 @@ function saveErrorReport() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Reboot ────────────────────────────────────────────────────────
|
// ── Reboot ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var _rebootStartTime = 0;
|
var _rebootStartTime = 0;
|
||||||
|
|||||||
@@ -364,17 +364,12 @@ async function loadStep3() {
|
|||||||
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
|
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
|
||||||
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
|
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
|
||||||
+ '</div>';
|
+ '</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
|
// Router note (the same wording is shown again, per-service, whenever a
|
||||||
// domain-based feature is enabled, and lives on each service tile afterwards)
|
// domain-based feature is enabled, and lives on each service tile afterwards)
|
||||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||||
+ renderRouterPortsHtml({
|
+ renderRouterPortsHtml({ internalIp: internalIp })
|
||||||
internalIp: internalIp,
|
|
||||||
plural: true,
|
|
||||||
includeSsh: true,
|
|
||||||
extraNote: "Element Call needs a few extra ports (some UDP), and you’ll be shown exactly which when you enable it.",
|
|
||||||
})
|
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
|
|
||||||
relevantDomains.forEach(function(d) {
|
relevantDomains.forEach(function(d) {
|
||||||
@@ -382,20 +377,20 @@ async function loadStep3() {
|
|||||||
html += '<div class="onboarding-domain-group">';
|
html += '<div class="onboarding-domain-group">';
|
||||||
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
|
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 += '<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 "https://njal.la/update/?h=...&k=...&auto"" />';
|
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl "https://njal.la/update/?h=...&k=...&auto"" />';
|
||||||
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 += '<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 += '<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>';
|
html += '</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSL email section
|
// Contact email section
|
||||||
var emailVal = domainValues["sslemail"] || "";
|
var emailVal = domainValues["sslemail"] || "";
|
||||||
html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
|
html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
|
||||||
html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>';
|
html += '<label class="onboarding-domain-label">📧 Email Address</label>';
|
||||||
html += '<p class="onboarding-hint onboarding-hint--inline">Let\'s Encrypt uses this for certificate expiry notifications.</p>';
|
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 += '<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 += '<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>';
|
html += '<span class="onboarding-domain-save-status" id="domain-save-status-email" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
||||||
|
<button class="btn btn-save" id="btn-retry-update-status" style="display:none">Retry Status</button>
|
||||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
||||||
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,22 +197,14 @@
|
|||||||
encrypted messaging, password management, cloud storage, website hosting, and more.
|
encrypted messaging, password management, cloud storage, website hosting, and more.
|
||||||
</p>
|
</p>
|
||||||
<div class="upgrade-info-box">
|
<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">
|
<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>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>Some services require ports to be opened on your router</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>
|
|
||||||
<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>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p class="support-desc">
|
<p class="support-desc">
|
||||||
<strong>Don't worry</strong> — the Hub will walk you through every step after the upgrade.
|
The Hub guides you through every step.
|
||||||
Domain setup, port forwarding, and configuration are all guided.
|
|
||||||
</p>
|
</p>
|
||||||
<p class="support-desc upgrade-rebuild-note">
|
<p class="support-desc upgrade-rebuild-note">
|
||||||
The system will rebuild after upgrading. This may take several minutes.
|
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-panel" id="step-3" style="display:none">
|
||||||
<div class="onboarding-step-header">
|
<div class="onboarding-step-header">
|
||||||
<span class="onboarding-step-icon">🌐</span>
|
<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">
|
<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.
|
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.
|
||||||
Everything you need — Njal.la account, Dynamic records, and the one router task — is laid out step by step below.
|
To make your services available outside your home, you’ll complete one router task: forward ports <strong>80</strong> and <strong>443</strong> to this computer.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-card" id="step-3-body">
|
<div class="onboarding-card" id="step-3-body">
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Update-state helpers for the Sovran Hub.
|
||||||
|
|
||||||
|
The full-system updater stages a NixOS generation with ``nixos-rebuild boot``.
|
||||||
|
That generation is not active until the machine reboots — and the same is true
|
||||||
|
for updates started from a terminal or an SSH support session, which never go
|
||||||
|
near the Hub's status files.
|
||||||
|
|
||||||
|
The ONLY reliable indicator that a reboot is pending is NixOS itself: the
|
||||||
|
system profile (``/nix/var/nix/profiles/system``), which ``nixos-rebuild``
|
||||||
|
points at the newest generation on every ``boot`` AND every ``switch``, versus
|
||||||
|
``/run/current-system``, the generation actually running since the last boot.
|
||||||
|
When the two differ, a staged generation has not been booted yet.
|
||||||
|
|
||||||
|
Earlier revisions reconstructed this from a marker file and log tails written
|
||||||
|
by the Hub's own updater. Any system updated by other means — or whose
|
||||||
|
``REBOOT_REQUIRED`` status was written by an updater older than the marker
|
||||||
|
feature — left the Hub showing "Restart required" forever: the recorded
|
||||||
|
generation could never equal the (since advanced) running one, so the marker
|
||||||
|
could never be cleared.
|
||||||
|
|
||||||
|
This module has no FastAPI or systemd dependencies so the policy can be tested
|
||||||
|
without importing the Hub server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# The NixOS system profile. ``nixos-rebuild boot`` and ``nixos-rebuild
|
||||||
|
# switch`` both add a generation here; ``boot`` additionally makes it the
|
||||||
|
# bootloader default. The path is a symlink chain (``system`` ->
|
||||||
|
# ``system-N-link`` -> ``/nix/store/...-nixos-system-...``).
|
||||||
|
BOOT_PROFILE_PATH = "/nix/var/nix/profiles/system"
|
||||||
|
CURRENT_SYSTEM_PATH = "/run/current-system"
|
||||||
|
|
||||||
|
|
||||||
|
def reboot_is_pending(
|
||||||
|
boot_profile_path: str = BOOT_PROFILE_PATH,
|
||||||
|
current_system_path: str = CURRENT_SYSTEM_PATH,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether a staged NixOS generation has not been booted yet.
|
||||||
|
|
||||||
|
This is deliberately independent of how the update was started — Hub
|
||||||
|
"Update System", terminal ``nixos-rebuild boot``, or a support session all
|
||||||
|
move the system profile the same way:
|
||||||
|
|
||||||
|
* after ``nixos-rebuild boot``: profile -> new, current -> old → pending
|
||||||
|
* after rebooting: both -> new → cleared
|
||||||
|
* after ``nixos-rebuild switch``: both move together → no reboot
|
||||||
|
ever needed (switch activates immediately)
|
||||||
|
* after a rollback: both point at the rollback target → cleared
|
||||||
|
|
||||||
|
Unreadable or missing paths are treated as "not pending": the Hub must
|
||||||
|
never demand a reboot it cannot substantiate.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
boot_default = os.path.realpath(boot_profile_path)
|
||||||
|
current = os.path.realpath(current_system_path)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
if not os.path.exists(boot_default) or not os.path.exists(current):
|
||||||
|
return False
|
||||||
|
return boot_default != current
|
||||||
|
|
||||||
|
|
||||||
|
def effective_update_status(
|
||||||
|
status: str,
|
||||||
|
boot_profile_path: str = BOOT_PROFILE_PATH,
|
||||||
|
current_system_path: str = CURRENT_SYSTEM_PATH,
|
||||||
|
) -> str:
|
||||||
|
"""Map a persisted Hub status to the one that reflects live NixOS state.
|
||||||
|
|
||||||
|
Only ``REBOOT_REQUIRED`` is re-validated: it means "the update staged a
|
||||||
|
generation the machine has not booted into", a claim that must stay true
|
||||||
|
no matter which tool performed the last update. When the boot default IS
|
||||||
|
the running system the claim is stale — the staged generation booted, was
|
||||||
|
superseded by a newer update, or the marker was written by an updater that
|
||||||
|
could never clear it — so the effective status is ``IDLE``.
|
||||||
|
|
||||||
|
All other statuses (``RUNNING``, ``FAILED``, ``SUCCESS``, ``IDLE``) pass
|
||||||
|
through unchanged; RUNNING staleness is handled separately against the
|
||||||
|
systemd unit itself.
|
||||||
|
"""
|
||||||
|
if status == "REBOOT_REQUIRED" and not reboot_is_pending(
|
||||||
|
boot_profile_path, current_system_path
|
||||||
|
):
|
||||||
|
return "IDLE"
|
||||||
|
return status
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
"bitcoind.service": "27.1.0",
|
"bitcoind.service": "27.1.0",
|
||||||
"electrs.service": "0.10.6",
|
"electrs.service": "0.10.6",
|
||||||
"lnd.service": "0.18.0",
|
"lnd.service": "0.18.0",
|
||||||
"rtl.service": "0.15.8",
|
"rtl.service": "0.15.10",
|
||||||
"btcpayserver.service": "2.4.2",
|
"btcpayserver.service": "2.4.2",
|
||||||
"albyhub.service": "1.8.0",
|
"albyhub.service": "1.24.0",
|
||||||
"mempool.service": "3.2.1",
|
"mempool.service": "3.2.1",
|
||||||
"matrix-synapse.service": "1.115.0",
|
"matrix-synapse.service": "1.115.0",
|
||||||
"livekit.service": "1.5.2",
|
"livekit.service": "1.5.2",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 442 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
@@ -151,6 +151,7 @@
|
|||||||
dig firefox wp-cli axel
|
dig firefox wp-cli axel
|
||||||
lk-jwt-service livekit-libwebrtc livekit
|
lk-jwt-service livekit-libwebrtc livekit
|
||||||
matrix-synapse age onlyoffice-desktopeditors
|
matrix-synapse age onlyoffice-desktopeditors
|
||||||
|
tor-browser
|
||||||
];
|
];
|
||||||
|
|
||||||
# ── Shell ──────────────────────────────────────────────────
|
# ── Shell ──────────────────────────────────────────────────
|
||||||
|
|||||||
Generated
+72
-19
@@ -5,11 +5,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785778881,
|
"lastModified": 1787839684,
|
||||||
"narHash": "sha256-yHJJTm3o7ZhiVp68G+hh+dz1GKV+ACOT6mQj+eYvJLQ=",
|
"narHash": "sha256-xTafqsVs2/zyGsaCel41QT5Lpbs/rG56Kp+YsmLXL8I=",
|
||||||
"owner": "emmanuelrosa",
|
"owner": "emmanuelrosa",
|
||||||
"repo": "btc-clients-nix",
|
"repo": "btc-clients-nix",
|
||||||
"rev": "fc1aca94d839f82e7501fe0d01279d5c1fba6e63",
|
"rev": "ef81159876930802ef9861aa5c039cec6886a30f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -26,11 +26,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1782949081,
|
"lastModified": 1785627969,
|
||||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -56,11 +56,27 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786201459,
|
"lastModified": 1787753485,
|
||||||
"narHash": "sha256-CiOTEjmwAmG2AWnaIno9YaCJJmpca2FXPhMAsnrolCg=",
|
"narHash": "sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "8b8c811c7c2541c30382c5de7ed26be055569c60",
|
"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"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -72,11 +88,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1786106723,
|
"lastModified": 1787498568,
|
||||||
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
|
"narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
|
"rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -88,11 +104,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_3": {
|
"nixpkgs_3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784555310,
|
"lastModified": 1787364730,
|
||||||
"narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=",
|
"narHash": "sha256-NcYt9QJfpJiF1lAyN8BDPB4EeScbPU+EwQqPiBElrpU=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493",
|
"rev": "a831408e6378bc02ebf8cc09b52c96ca86f6bab4",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -102,6 +118,22 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nixpkgs_4": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1787736819,
|
||||||
|
"narHash": "sha256-cV5xEJJK3BvhU8rEd4mC9UsmDi5qscv/kzGPhBRC5WA=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "9fbb54b33e91ee4ca368e35a78e0613c720600b3",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixvim": {
|
"nixvim": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
@@ -109,11 +141,11 @@
|
|||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785763201,
|
"lastModified": 1787691219,
|
||||||
"narHash": "sha256-wA373y/B9orM3HatLu9oS+Ke5lmdZyBl/bdjd2gLMq4=",
|
"narHash": "sha256-CjeJTOjrluiDuL0W/YBFkSBOQjBYmdKwj7hWAJ4PIh0=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "c7be49306b23a952c0151cf4bbeacd944ed82f2a",
|
"rev": "ebec1ae277a50b16e7d6682edd009ef585d68261",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -127,10 +159,31 @@
|
|||||||
"btc-clients": "btc-clients",
|
"btc-clients": "btc-clients",
|
||||||
"nixpkgs": "nixpkgs_2",
|
"nixpkgs": "nixpkgs_2",
|
||||||
"nixpkgs-stable": "nixpkgs-stable",
|
"nixpkgs-stable": "nixpkgs-stable",
|
||||||
"nixvim": "nixvim"
|
"nixvim": "nixvim",
|
||||||
|
"sovran-bitcoin": "sovran-bitcoin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sovran-bitcoin": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs_4",
|
||||||
|
"nixpkgs-stable": "nixpkgs-stable_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1788191399,
|
||||||
|
"narHash": "sha256-2m3eeQvPGPjcegG1+C1LnfjQ5DGJTra9Aw2RFThzT1A=",
|
||||||
|
"owner": "naturallaw777",
|
||||||
|
"repo": "Sovran_Bitcoin",
|
||||||
|
"rev": "f4e86564418bee335fd230387f07abb9c6632f65",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "naturallaw777",
|
||||||
|
"repo": "Sovran_Bitcoin",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"systems": {
|
||||||
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774449309,
|
"lastModified": 1774449309,
|
||||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
nixvim.url = "github:nix-community/nixvim";
|
nixvim.url = "github:nix-community/nixvim";
|
||||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
|
|
||||||
|
# Bitcoin / Lightning stack — standalone flake, consumed as a module.
|
||||||
|
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, ... }:
|
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, sovran-bitcoin, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
overlay-stable = final: prev: {
|
overlay-stable = final: prev: {
|
||||||
@@ -33,7 +36,8 @@
|
|||||||
modules = [
|
modules = [
|
||||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||||
./iso/common.nix
|
./iso/common.nix
|
||||||
./modules/bitcoin
|
sovran-bitcoin.nixosModules.default
|
||||||
|
./modules/sovran-bitcoin-integration.nix
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
@@ -44,7 +48,8 @@
|
|||||||
nixpkgs.overlays = [ overlay-stable ];
|
nixpkgs.overlays = [ overlay-stable ];
|
||||||
})
|
})
|
||||||
./configuration.nix
|
./configuration.nix
|
||||||
./modules/bitcoin
|
sovran-bitcoin.nixosModules.default
|
||||||
|
./modules/sovran-bitcoin-integration.nix
|
||||||
nixvim.nixosModules.nixvim
|
nixvim.nixosModules.nixvim
|
||||||
];
|
];
|
||||||
config = {
|
config = {
|
||||||
@@ -60,17 +65,9 @@
|
|||||||
pkgs = import nixpkgs {
|
pkgs = import nixpkgs {
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
};
|
};
|
||||||
fetchNodeModules =
|
|
||||||
pkgs.callPackage ./packages/build-support/fetch-node-modules.nix {};
|
|
||||||
mempoolPkgs =
|
|
||||||
pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; };
|
|
||||||
in {
|
in {
|
||||||
bitcoin-btcpay-hardening = import ./tests/bitcoin-btcpay-hardening.nix {
|
# Bitcoin hardening and package checks now live in the Sovran_Bitcoin flake.
|
||||||
inherit nixpkgs overlay-stable;
|
# Run them with: nix build github:naturallaw777/Sovran_Bitcoin#checks.x86_64-linux
|
||||||
};
|
|
||||||
mempool-backend = mempoolPkgs.mempool-backend;
|
|
||||||
mempool-frontend = mempoolPkgs.mempool-frontend;
|
|
||||||
rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; };
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-35
@@ -423,40 +423,6 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
sep.set_margin_end(40)
|
sep.set_margin_end(40)
|
||||||
outer.append(sep)
|
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:
|
if self.virtualization:
|
||||||
vm_frame = Gtk.Frame()
|
vm_frame = Gtk.Frame()
|
||||||
vm_frame.add_css_class("card")
|
vm_frame.add_css_class("card")
|
||||||
@@ -505,7 +471,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
# Role cards
|
# Role cards
|
||||||
roles = [
|
roles = [
|
||||||
("Server + Desktop",
|
("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"),
|
"Server+Desktop"),
|
||||||
("Desktop Only",
|
("Desktop Only",
|
||||||
"A beautiful, easy-to-use desktop without the background server applications.",
|
"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"
|
|
||||||
]
|
|
||||||
@@ -1,501 +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;
|
|
||||||
|
|
||||||
i2pSAM = config.services.i2pd.proto.sam;
|
|
||||||
|
|
||||||
configFile = builtins.toFile "bitcoin.conf" ''
|
|
||||||
# We're already logging via journald
|
|
||||||
nodebuglogfile=1
|
|
||||||
logtimestamps=0
|
|
||||||
|
|
||||||
startupnotify=/run/current-system/systemd/bin/systemd-notify --ready
|
|
||||||
|
|
||||||
${optionalString cfg.regtest ''
|
|
||||||
regtest=1
|
|
||||||
[regtest]
|
|
||||||
''}
|
|
||||||
${optionalString (cfg.dbCache != null) "dbcache=${toString cfg.dbCache}"}
|
|
||||||
prune=${toString cfg.prune}
|
|
||||||
${optionalString cfg.txindex "txindex=1"}
|
|
||||||
${optionalString (cfg.sysperms != null) "sysperms=${if cfg.sysperms then "1" else "0"}"}
|
|
||||||
${optionalString (cfg.disablewallet != null) "disablewallet=${if cfg.disablewallet then "1" else "0"}"}
|
|
||||||
${optionalString (cfg.assumevalid != null) "assumevalid=${cfg.assumevalid}"}
|
|
||||||
|
|
||||||
# Connection options
|
|
||||||
listen=${if (cfg.listen || cfg.listenWhitelisted) then "1" else "0"}
|
|
||||||
${optionalString cfg.listen
|
|
||||||
"bind=${cfg.address}:${toString cfg.port}"}
|
|
||||||
${optionalString (cfg.listen && cfg.onionPort != null)
|
|
||||||
"bind=${cfg.address}:${toString cfg.onionPort}=onion"}
|
|
||||||
${optionalString cfg.listenWhitelisted
|
|
||||||
"whitebind=${cfg.address}:${toString cfg.whitelistedPort}"}
|
|
||||||
${optionalString (cfg.proxy != null) "proxy=${cfg.proxy}"}
|
|
||||||
${optionalString (cfg.i2p != false) "i2psam=${nbLib.addressWithPort i2pSAM.address i2pSAM.port}"}
|
|
||||||
${optionalString (cfg.i2p == "only-outgoing") "i2pacceptincoming=0"}
|
|
||||||
|
|
||||||
${optionalString (cfg.discover != null) "discover=${if cfg.discover then "1" else "0"}"}
|
|
||||||
${lib.concatMapStrings (node: "addnode=${node}\n") cfg.addnodes}
|
|
||||||
|
|
||||||
# RPC server options
|
|
||||||
rpcbind=${cfg.rpc.address}
|
|
||||||
rpcport=${toString cfg.rpc.port}
|
|
||||||
rpcconnect=${cfg.rpc.address}
|
|
||||||
${optionalString (cfg.rpc.threads != null) "rpcthreads=${toString cfg.rpc.threads}"}
|
|
||||||
rpcwhitelistdefault=0
|
|
||||||
${concatMapStrings (user: ''
|
|
||||||
${optionalString (!user.passwordHMACFromFile) "rpcauth=${user.name}:${user.passwordHMAC}"}
|
|
||||||
${optionalString (user.rpcwhitelist != [])
|
|
||||||
"rpcwhitelist=${user.name}:${lib.strings.concatStringsSep "," user.rpcwhitelist}"}
|
|
||||||
'') (builtins.attrValues cfg.rpc.users)
|
|
||||||
}
|
|
||||||
${lib.concatMapStrings (rpcallowip: "rpcallowip=${rpcallowip}\n") cfg.rpc.allowip}
|
|
||||||
|
|
||||||
# Wallet options
|
|
||||||
${optionalString (cfg.addresstype != null) "addresstype=${cfg.addresstype}"}
|
|
||||||
|
|
||||||
# ZMQ options
|
|
||||||
${optionalString (cfg.zmqpubrawblock != null) "zmqpubrawblock=${cfg.zmqpubrawblock}"}
|
|
||||||
${optionalString (cfg.zmqpubrawtx != null) "zmqpubrawtx=${cfg.zmqpubrawtx}"}
|
|
||||||
|
|
||||||
# Extra options
|
|
||||||
${cfg.extraConfig}
|
|
||||||
'';
|
|
||||||
|
|
||||||
zmqServerEnabled = (cfg.zmqpubrawblock != null) || (cfg.zmqpubrawtx != null);
|
|
||||||
|
|
||||||
intAtLeast = n: types.addCheck types.int (x: x >= n) // {
|
|
||||||
name = "intAtLeast";
|
|
||||||
description = "integer >= ${toString n}";
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
|
||||||
|
|
||||||
services.bitcoind = mkMerge [
|
|
||||||
(mkIf cfg.dataDirReadableByGroup {
|
|
||||||
disablewallet = true;
|
|
||||||
sysperms = true;
|
|
||||||
})
|
|
||||||
{
|
|
||||||
rpc.users.privileged = {
|
|
||||||
passwordHMACFromFile = true;
|
|
||||||
};
|
|
||||||
rpc.users.public = {
|
|
||||||
passwordHMACFromFile = true;
|
|
||||||
rpcwhitelist = import ./bitcoind-rpc-public-whitelist.nix;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
services.i2pd = mkIf (cfg.i2p != false) {
|
|
||||||
enable = true;
|
|
||||||
proto.sam.enable = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
systemd.services.bitcoind = rec {
|
|
||||||
wants = [
|
|
||||||
"network-online.target"
|
|
||||||
# Use `wants` instead of `requires` for `nix-bitcoin-secrets.target`
|
|
||||||
# so that bitcoind and all dependent services are not restarted when
|
|
||||||
# the secrets target restarts.
|
|
||||||
# The secrets target always restarts when deploying with one of the methods
|
|
||||||
# in ./deployment.
|
|
||||||
#
|
|
||||||
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
|
||||||
# that propagates initial start failures but no restarts
|
|
||||||
"nix-bitcoin-secrets.target"
|
|
||||||
];
|
|
||||||
after = wants;
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
|
|
||||||
preStart = let
|
|
||||||
extraRpcauth = concatMapStrings (name: let
|
|
||||||
user = cfg.rpc.users.${name};
|
|
||||||
in optionalString user.passwordHMACFromFile ''
|
|
||||||
hmacPayload="$(readValidatedRpcHmac '${secretsDir}/bitcoin-HMAC-${name}')" || exit 1
|
|
||||||
printf '%s\n' "rpcauth=${user.name}:$hmacPayload"
|
|
||||||
''
|
|
||||||
) (builtins.attrNames cfg.rpc.users);
|
|
||||||
in ''
|
|
||||||
${optionalString cfg.dataDirReadableByGroup ''
|
|
||||||
if [[ -e '${cfg.dataDir}/blocks' ]]; then
|
|
||||||
chmod -R g+rX '${cfg.dataDir}/blocks'
|
|
||||||
fi
|
|
||||||
''}
|
|
||||||
|
|
||||||
readValidatedRpcHmac() {
|
|
||||||
local hmacFile="$1"
|
|
||||||
local hmacPayload
|
|
||||||
|
|
||||||
if [[ ! -e "$hmacFile" ]]; then
|
|
||||||
echo "Error: Bitcoin RPC HMAC file is missing: $hmacFile" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ ! -r "$hmacFile" ]]; then
|
|
||||||
echo "Error: Bitcoin RPC HMAC file is unreadable: $hmacFile" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
hmacPayload="$(<"$hmacFile")"
|
|
||||||
if [[ -z "$hmacPayload" ]]; then
|
|
||||||
echo "Error: Bitcoin RPC HMAC file is empty: $hmacFile" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ ! "$hmacPayload" =~ ^[[:xdigit:]]+\$[[:xdigit:]]+$ ]]; then
|
|
||||||
echo "Error: Bitcoin RPC HMAC file has invalid format: $hmacFile" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s\n' "$hmacPayload"
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg=$(
|
|
||||||
cat ${configFile}
|
|
||||||
${extraRpcauth}
|
|
||||||
echo
|
|
||||||
${optionalString (cfg.getPublicAddressCmd != "") ''
|
|
||||||
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
|
||||||
''}
|
|
||||||
)
|
|
||||||
confFile='${cfg.dataDir}/bitcoin.conf'
|
|
||||||
if [[ ! -e $confFile || $cfg != $(cat $confFile) ]]; then
|
|
||||||
install -o '${cfg.user}' -g '${cfg.group}' -m 640 <(echo "$cfg") $confFile
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Enable RPC access for group
|
|
||||||
postStart = ''
|
|
||||||
chmod g=r '${cfg.dataDir}/${optionalString cfg.regtest "regtest/"}.cookie'
|
|
||||||
'' + (optionalString cfg.regtest) ''
|
|
||||||
chmod g=x '${cfg.dataDir}/regtest'
|
|
||||||
'';
|
|
||||||
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
Type = "notify";
|
|
||||||
NotifyAccess = "all";
|
|
||||||
User = cfg.user;
|
|
||||||
Group = cfg.group;
|
|
||||||
TimeoutStartSec = "30min";
|
|
||||||
TimeoutStopSec = "30min";
|
|
||||||
ExecStart = "${cfg.package}/bin/bitcoind -datadir='${cfg.dataDir}'";
|
|
||||||
Restart = "on-failure";
|
|
||||||
UMask = mkIf cfg.dataDirReadableByGroup "0027";
|
|
||||||
ReadWritePaths = [ cfg.dataDir ];
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
|
||||||
// optionalAttrs zmqServerEnabled nbLib.allowNetlink;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
users.groups.bitcoinrpc-public = {};
|
|
||||||
|
|
||||||
nix-bitcoin.operator.groups = [ cfg.group ];
|
|
||||||
|
|
||||||
nix-bitcoin.secrets = {
|
|
||||||
bitcoin-rpcpassword-privileged.user = cfg.user;
|
|
||||||
bitcoin-rpcpassword-public = {
|
|
||||||
user = cfg.user;
|
|
||||||
group = "bitcoinrpc-public";
|
|
||||||
};
|
|
||||||
|
|
||||||
bitcoin-HMAC-privileged.user = cfg.user;
|
|
||||||
bitcoin-HMAC-public.user = cfg.user;
|
|
||||||
};
|
|
||||||
nix-bitcoin.generateSecretsCmds.bitcoind = ''
|
|
||||||
makeBitcoinRPCPassword privileged
|
|
||||||
makeBitcoinRPCPassword public
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services = {
|
|
||||||
nbxplorer = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable nbxplorer, a lightweight API for Bitcoin HD wallets.
|
|
||||||
|
|
||||||
Access API documentation here:
|
|
||||||
{option}`services.nbxplorer.address`:{option}`services.nbxplorer.port`
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen on.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 24444;
|
|
||||||
description = "Port to listen on.";
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = pkgs.stable.nbxplorer;
|
|
||||||
defaultText = "pkgs.stable.nbxplorer";
|
|
||||||
description = "The package providing nbxplorer binaries.";
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/nbxplorer";
|
|
||||||
description = "The data directory for nbxplorer.";
|
|
||||||
};
|
|
||||||
# TODO-EXTERNAL:
|
|
||||||
# The shortcut link `main` in the datadir has changed to a directory
|
|
||||||
# in version 2.3.3.
|
|
||||||
# Add a dummy symlink, if it does not already exist, to be compatible with older modules.
|
|
||||||
# When the old system uses a link and the new system a directory, switching fails with:
|
|
||||||
# mv: cannot move '/var/lib/nbxplorer/Main' to '/var/lib/nbxplorer/.Main.tmp':
|
|
||||||
# No such file or directory
|
|
||||||
#
|
|
||||||
# Remove this option when it is irrelevant (i.e. when the old system will never
|
|
||||||
# be nix-bitcoin <=0.0.91)
|
|
||||||
addNetworkSymlink = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = pkgs.stable.nbxplorer != cfg.nbxplorer.package;
|
|
||||||
description = ''
|
|
||||||
Whether to add a compatibility symlink (like `${cfg.nbxplorer.dataDir}/Main`)
|
|
||||||
to the dataDir.
|
|
||||||
This is enabled by default if the nbxplorer package is set to the version-locked package.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "nbxplorer";
|
|
||||||
description = "The user as which to run NBXplorer.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.nbxplorer.user;
|
|
||||||
description = "The group as which to run NBXplorer.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
|
|
||||||
btcpayserver = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable BTCPay Server, a self-hosted, open-source payment processor.
|
|
||||||
|
|
||||||
Extra recommendations:
|
|
||||||
- Enable `services.btcpayserver.lightningBackend` to provide Lightning payment support.
|
|
||||||
- Secure this service if the instance is publically accessible. For example, set
|
|
||||||
{option}`services.btcpayserver.address` to `127.0.0.1` and use a reverse proxy
|
|
||||||
that enforces TLS (Transport Layer Security).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = pkgs.stable.btcpayserver;
|
|
||||||
defaultText = "pkgs.stable.btcpayserver";
|
|
||||||
description = "The package providing BTCPay Server binaries.";
|
|
||||||
};
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen on.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 23000;
|
|
||||||
description = "Port to listen on.";
|
|
||||||
};
|
|
||||||
lightningBackend = mkOption {
|
|
||||||
type = types.nullOr (types.enum [ "lnd" ]);
|
|
||||||
default = null;
|
|
||||||
description = ''
|
|
||||||
The lightning node to use as a backend.
|
|
||||||
Enables the node service if not already enabled.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
lbtc = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable Liquid support.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/btcpayserver";
|
|
||||||
description = "The data directory for BTCPay Server.";
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "btcpayserver";
|
|
||||||
description = "The user as which to run BTCPay Server.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.btcpayserver.user;
|
|
||||||
description = "The group as which to run BTCPay Server.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = {
|
|
||||||
inherit (config.services)
|
|
||||||
nbxplorer
|
|
||||||
btcpayserver
|
|
||||||
bitcoind;
|
|
||||||
};
|
|
||||||
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkMerge [
|
|
||||||
(mkIf cfg.nbxplorer.enable {
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.nbxplorer.dataDir}' 0770 ${cfg.nbxplorer.user} ${cfg.nbxplorer.group} - -"
|
|
||||||
] ++ optional cfg.nbxplorer.addNetworkSymlink
|
|
||||||
"L+ '${cfg.nbxplorer.dataDir}/Main' - - - - '${cfg.nbxplorer.dataDir}/main'";
|
|
||||||
|
|
||||||
systemd.services.nbxplorer = let
|
|
||||||
configFile = builtins.toFile "nbxplorer-config" ''
|
|
||||||
network=${cfg.bitcoind.network}
|
|
||||||
btcrpcuser=${cfg.bitcoind.rpc.users.btcpayserver.name}
|
|
||||||
btcrpcurl=http://${nbLib.addressWithPort cfg.bitcoind.rpc.address cfg.bitcoind.rpc.port}
|
|
||||||
btcnodeendpoint=${nbLib.addressWithPort cfg.bitcoind.address cfg.bitcoind.whitelistedPort}
|
|
||||||
bind=${cfg.nbxplorer.address}
|
|
||||||
port=${toString cfg.nbxplorer.port}
|
|
||||||
postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
|
|
||||||
'';
|
|
||||||
in rec {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "postgresql.target" ];
|
|
||||||
wants = [ "bitcoind.service" ];
|
|
||||||
after = requires ++ wants ++ [ "nix-bitcoin-secrets.target" ];
|
|
||||||
preStart = ''
|
|
||||||
install -m 600 ${configFile} '${cfg.nbxplorer.dataDir}/settings.config'
|
|
||||||
printf '%s\n' "btcrpcpassword=$(<${secretsDir}/bitcoin-rpcpassword-btcpayserver)" \
|
|
||||||
>> '${cfg.nbxplorer.dataDir}/settings.config'
|
|
||||||
'';
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
ExecStart = ''
|
|
||||||
${cfg.nbxplorer.package}/bin/nbxplorer --conf=${cfg.nbxplorer.dataDir}/settings.config \
|
|
||||||
--datadir='${cfg.nbxplorer.dataDir}'
|
|
||||||
'';
|
|
||||||
RuntimeDirectory = "nbxplorer";
|
|
||||||
StateDirectory = "nbxplorer";
|
|
||||||
User = cfg.nbxplorer.user;
|
|
||||||
Group = cfg.nbxplorer.group;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.nbxplorer.dataDir ];
|
|
||||||
MemoryDenyWriteExecute = false;
|
|
||||||
} // nbLib.allowedIPAddresses cfg.nbxplorer.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
listenWhitelisted = true;
|
|
||||||
txindex = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.nbxplorer.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.nbxplorer.group;
|
|
||||||
home = cfg.nbxplorer.dataDir;
|
|
||||||
};
|
|
||||||
users.groups.${cfg.nbxplorer.group} = {};
|
|
||||||
})
|
|
||||||
|
|
||||||
(mkIf cfg.btcpayserver.enable {
|
|
||||||
services.nbxplorer.enable = true;
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
listenWhitelisted = true;
|
|
||||||
rpc.users.btcpayserver = {
|
|
||||||
name = "btcpayserver";
|
|
||||||
passwordHMACFromFile = true;
|
|
||||||
rpcwhitelist = [
|
|
||||||
"getblockchaininfo"
|
|
||||||
"getblock"
|
|
||||||
"getblockhash"
|
|
||||||
"getblockheader"
|
|
||||||
"getblockstats"
|
|
||||||
"gettransaction"
|
|
||||||
"getrawtransaction"
|
|
||||||
"sendrawtransaction"
|
|
||||||
"getblockcount"
|
|
||||||
"getbestblockhash"
|
|
||||||
"getnetworkinfo"
|
|
||||||
"getpeerinfo"
|
|
||||||
"estimatesmartfee"
|
|
||||||
"getmempoolinfo"
|
|
||||||
"getmempoolentry"
|
|
||||||
"getrawmempool"
|
|
||||||
"gettxout"
|
|
||||||
"scantxoutset"
|
|
||||||
"importmulti"
|
|
||||||
"listunspent"
|
|
||||||
"getwalletinfo"
|
|
||||||
"listtransactions"
|
|
||||||
"listreceivedbyaddress"
|
|
||||||
"getnewaddress"
|
|
||||||
"uptime"
|
|
||||||
"getrpcinfo"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.btcpayserver.dataDir}' 0770 ${cfg.btcpayserver.user} ${cfg.btcpayserver.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
systemd.services.btcpayserver = let
|
|
||||||
nbExplorerUrl = "http://${nbLib.addressWithPort cfg.nbxplorer.address cfg.nbxplorer.port}/";
|
|
||||||
nbExplorerCookie =
|
|
||||||
"${cfg.nbxplorer.dataDir}/${cfg.bitcoind.makeNetworkName "Main" "RegTest"}/.cookie";
|
|
||||||
configFile = builtins.toFile "btcpayserver-config" (
|
|
||||||
''
|
|
||||||
network=${cfg.bitcoind.network}
|
|
||||||
bind=${cfg.btcpayserver.address}
|
|
||||||
port=${toString cfg.btcpayserver.port}
|
|
||||||
socksendpoint=${config.nix-bitcoin.torClientAddressWithPort}
|
|
||||||
btcexplorerurl=${nbExplorerUrl}
|
|
||||||
btcexplorercookiefile=${nbExplorerCookie}
|
|
||||||
explorer.postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
|
|
||||||
postgres=User ID=${cfg.btcpayserver.user};Host=/run/postgresql;Database=btcpayserver
|
|
||||||
'' + optionalString (cfg.btcpayserver.lightningBackend == "lnd")
|
|
||||||
(
|
|
||||||
"btclightning=type=lnd-rest;"
|
|
||||||
+ "server=https://${nbLib.address config.services.lnd.restAddress}:${toString config.services.lnd.restPort}/;"
|
|
||||||
+ "macaroonfilepath=/run/lnd/btcpayserver.macaroon;"
|
|
||||||
+ "certfilepath=${config.services.lnd.certPath}\n"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
in rec {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "postgresql.target" "nbxplorer.service" ];
|
|
||||||
wants = optional (cfg.btcpayserver.lightningBackend == "lnd") "lnd.service";
|
|
||||||
after = requires ++ wants;
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
ExecStart = ''
|
|
||||||
${cfg.btcpayserver.package}/bin/btcpayserver --conf=${configFile} \
|
|
||||||
--datadir='${cfg.btcpayserver.dataDir}'
|
|
||||||
'';
|
|
||||||
WorkingDirectory = "${cfg.btcpayserver.package}/lib/btcpayserver";
|
|
||||||
RuntimeDirectory = "btcpayserver";
|
|
||||||
StateDirectory = "btcpayserver";
|
|
||||||
User = cfg.btcpayserver.user;
|
|
||||||
Group = cfg.btcpayserver.group;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.btcpayserver.dataDir ];
|
|
||||||
MemoryDenyWriteExecute = false;
|
|
||||||
} // nbLib.allowedIPAddresses cfg.btcpayserver.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.postgresql = {
|
|
||||||
enable = true;
|
|
||||||
ensureDatabases = [ "btcpayserver" "nbxplorer" ];
|
|
||||||
ensureUsers = [
|
|
||||||
{ name = cfg.btcpayserver.user; ensureDBOwnership = true; }
|
|
||||||
{ name = cfg.nbxplorer.user; ensureDBOwnership = true; }
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.btcpayserver.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.btcpayserver.group;
|
|
||||||
home = cfg.btcpayserver.dataDir;
|
|
||||||
extraGroups = optional (cfg.btcpayserver.lightningBackend == "lnd") config.services.lnd.group;
|
|
||||||
};
|
|
||||||
users.groups.${cfg.btcpayserver.group} = {};
|
|
||||||
|
|
||||||
nix-bitcoin.secrets = {
|
|
||||||
bitcoin-rpcpassword-btcpayserver = {
|
|
||||||
user = cfg.bitcoind.user;
|
|
||||||
group = cfg.nbxplorer.group;
|
|
||||||
};
|
|
||||||
bitcoin-HMAC-btcpayserver.user = cfg.bitcoind.user;
|
|
||||||
};
|
|
||||||
nix-bitcoin.generateSecretsCmds.btcpayserver = ''
|
|
||||||
makeBitcoinRPCPassword btcpayserver
|
|
||||||
'';
|
|
||||||
})
|
|
||||||
|
|
||||||
(mkIf (cfg.btcpayserver.enable && cfg.btcpayserver.lightningBackend == "lnd") {
|
|
||||||
services.lnd = {
|
|
||||||
enable = true;
|
|
||||||
macaroons.btcpayserver = {
|
|
||||||
user = cfg.btcpayserver.user;
|
|
||||||
permissions = ''
|
|
||||||
{"entity":"address","action":"write"},{"entity":"info","action":"read"},{"entity":"invoices","action":"read"},{"entity":"invoices","action":"write"},{"entity":"offchain","action":"read"},{"entity":"offchain","action":"write"},{"entity":"onchain","action":"read"},{"entity":"onchain","action":"write"},{"entity":"peers","action":"read"},{"entity":"peers","action":"write"}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${config.services.lnd.user}.extraGroups = [ cfg.btcpayserver.group ];
|
|
||||||
})
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Common Bitcoin infrastructure: secrets, onion services, nodeinfo, security
|
|
||||||
# Extracted from nix-bitcoin, tailored for Sovran (lnd-only)
|
|
||||||
{ config, lib, pkgs, ... }:
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
./nix-bitcoin.nix
|
|
||||||
./secrets/secrets.nix
|
|
||||||
./operator.nix
|
|
||||||
./security.nix
|
|
||||||
./onion-addresses.nix
|
|
||||||
./onion-services.nix
|
|
||||||
./nodeinfo.nix
|
|
||||||
./versioning.nix
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Sovran Bitcoin stack - tailored from nix-bitcoin, lnd-only, nixpkgs packages
|
|
||||||
# Original: https://github.com/fort-nix/nix-bitcoin
|
|
||||||
{
|
|
||||||
imports = [
|
|
||||||
./common.nix
|
|
||||||
./bitcoind.nix
|
|
||||||
./electrs.nix
|
|
||||||
./lnd.nix
|
|
||||||
./lndconnect.nix
|
|
||||||
./rtl.nix
|
|
||||||
./btcpayserver.nix
|
|
||||||
./mempool.nix
|
|
||||||
];
|
|
||||||
|
|
||||||
disabledModules = [ "services/networking/bitcoind.nix" ];
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services.electrs = {
|
|
||||||
enable = mkEnableOption "electrs, an Electrum server implemented in Rust";
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for RPC connections.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 50001;
|
|
||||||
description = "Port to listen for RPC connections.";
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/electrs";
|
|
||||||
description = "The data directory for electrs.";
|
|
||||||
};
|
|
||||||
monitoringPort = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 4224;
|
|
||||||
description = "Prometheus monitoring port.";
|
|
||||||
};
|
|
||||||
extraArgs = mkOption {
|
|
||||||
type = types.separatedString " ";
|
|
||||||
default = "";
|
|
||||||
description = "Extra command line arguments passed to electrs.";
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "electrs";
|
|
||||||
description = "The user as which to run electrs.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run electrs.";
|
|
||||||
};
|
|
||||||
tor.enforce = nbLib.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.electrs;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
bitcoind = config.services.bitcoind;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
assertions = [
|
|
||||||
{ assertion = bitcoind.prune == 0;
|
|
||||||
message = "electrs does not support bitcoind pruning.";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
listenWhitelisted = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
systemd.services.electrs = {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "bitcoind.service" ];
|
|
||||||
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
|
||||||
preStart = ''
|
|
||||||
echo "auth = \"${bitcoind.rpc.users.public.name}:$(cat ${secretsDir}/bitcoin-rpcpassword-public)\"" \
|
|
||||||
> electrs.toml
|
|
||||||
'';
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
# electrs only uses the working directory for reading electrs.toml
|
|
||||||
WorkingDirectory = cfg.dataDir;
|
|
||||||
ExecStart = ''
|
|
||||||
${pkgs.electrs}/bin/electrs \
|
|
||||||
--log-filters=INFO \
|
|
||||||
--network=${bitcoind.makeNetworkName "bitcoin" "regtest"} \
|
|
||||||
--db-dir='${cfg.dataDir}' \
|
|
||||||
--daemon-dir='${bitcoind.dataDir}' \
|
|
||||||
--electrum-rpc-addr=${cfg.address}:${toString cfg.port} \
|
|
||||||
--monitoring-addr=${cfg.address}:${toString cfg.monitoringPort} \
|
|
||||||
--daemon-rpc-addr=${nbLib.addressWithPort bitcoind.rpc.address bitcoind.rpc.port} \
|
|
||||||
--daemon-p2p-addr=${nbLib.addressWithPort bitcoind.address bitcoind.whitelistedPort} \
|
|
||||||
${cfg.extraArgs}
|
|
||||||
'';
|
|
||||||
User = cfg.user;
|
|
||||||
Group = cfg.group;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.dataDir ];
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
extraGroups = [ "bitcoinrpc-public" ];
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
lib: pkgs: config:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
|
|
||||||
# See `man systemd.exec` and `man systemd.resource-control` for an explanation
|
|
||||||
# of the systemd-related options available through this file.
|
|
||||||
let self = {
|
|
||||||
# These settings roughly follow systemd's "strict" security profile
|
|
||||||
defaultHardening = {
|
|
||||||
PrivateTmp = true;
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
ProtectHome = true;
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
PrivateDevices = true;
|
|
||||||
MemoryDenyWriteExecute = true;
|
|
||||||
ProtectKernelTunables = true;
|
|
||||||
ProtectKernelModules = true;
|
|
||||||
ProtectKernelLogs = true;
|
|
||||||
ProtectClock = true;
|
|
||||||
ProtectProc = "invisible";
|
|
||||||
ProcSubset = "pid";
|
|
||||||
ProtectControlGroups = true;
|
|
||||||
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
|
|
||||||
RestrictNamespaces = true;
|
|
||||||
LockPersonality = true;
|
|
||||||
IPAddressDeny = "any";
|
|
||||||
PrivateUsers = true;
|
|
||||||
RestrictSUIDSGID = true;
|
|
||||||
RemoveIPC = true;
|
|
||||||
RestrictRealtime = true;
|
|
||||||
ProtectHostname = true;
|
|
||||||
CapabilityBoundingSet = "";
|
|
||||||
# @system-service whitelist and docker seccomp blacklist (except for "clone"
|
|
||||||
# which is a core requirement for systemd services)
|
|
||||||
# @system-service is defined in src/shared/seccomp-util.c (systemd source)
|
|
||||||
SystemCallFilter = [ "@system-service" "~add_key kcmp keyctl mbind move_pages name_to_handle_at personality process_vm_readv process_vm_writev request_key setns unshare userfaultfd" ];
|
|
||||||
SystemCallArchitectures = "native";
|
|
||||||
};
|
|
||||||
|
|
||||||
allowNetlink = {
|
|
||||||
RestrictAddressFamilies = self.defaultHardening.RestrictAddressFamilies + " AF_NETLINK";
|
|
||||||
};
|
|
||||||
|
|
||||||
nodejs = {
|
|
||||||
# Required for JIT compilation
|
|
||||||
MemoryDenyWriteExecute = false;
|
|
||||||
# Required by nodejs >= 18
|
|
||||||
SystemCallFilter = self.defaultHardening.SystemCallFilter ++ [ "@pkey" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
# Allow takes precedence over Deny.
|
|
||||||
allowLocalIPAddresses = {
|
|
||||||
IPAddressAllow = [
|
|
||||||
"127.0.0.1/32"
|
|
||||||
"::1/128"
|
|
||||||
"169.254.0.0/16"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
allowAllIPAddresses = { IPAddressAllow = "any"; };
|
|
||||||
allowTor = self.allowLocalIPAddresses;
|
|
||||||
allowedIPAddresses = onlyLocal:
|
|
||||||
if onlyLocal
|
|
||||||
then self.allowLocalIPAddresses
|
|
||||||
else self.allowAllIPAddresses;
|
|
||||||
|
|
||||||
tor = {
|
|
||||||
proxy = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Whether to proxy outgoing connections with Tor.";
|
|
||||||
};
|
|
||||||
enforce = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Whether to enforce Tor on this service by only allowing connections
|
|
||||||
from and to localhost and link-local addresses.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
script = name: src: pkgs.writers.writeBash name ''
|
|
||||||
set -eo pipefail
|
|
||||||
${src}
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Used for ExecStart*
|
|
||||||
rootScript = name: src: "+${self.script name src}";
|
|
||||||
|
|
||||||
cliExec = mkOption {
|
|
||||||
# Used by netns-isolation to execute the cli in the service's private netns
|
|
||||||
internal = true;
|
|
||||||
type = types.str;
|
|
||||||
default = "exec";
|
|
||||||
};
|
|
||||||
|
|
||||||
mkOnionService = map: {
|
|
||||||
map = [ map ];
|
|
||||||
version = 3;
|
|
||||||
};
|
|
||||||
|
|
||||||
# Convert a bind address, which may be a special INADDR_ANY address,
|
|
||||||
# to an actual IP address
|
|
||||||
address = addr:
|
|
||||||
if addr == "0.0.0.0" then
|
|
||||||
"127.0.0.1"
|
|
||||||
else if addr == "::" then
|
|
||||||
"::1"
|
|
||||||
else
|
|
||||||
addr;
|
|
||||||
|
|
||||||
addressWithPort = addr: port: "${self.address addr}:${toString port}";
|
|
||||||
|
|
||||||
optionalAttr = cond: name: if cond then name else null;
|
|
||||||
|
|
||||||
mkCertExtraAltNames = cert:
|
|
||||||
builtins.concatStringsSep "," (
|
|
||||||
(map (domain: "DNS:${domain}") cert.extraDomains) ++
|
|
||||||
(map (ip: "IP:${ip}") cert.extraIPs)
|
|
||||||
);
|
|
||||||
|
|
||||||
test = {
|
|
||||||
mkIfTest = test: mkIf (config.tests.${test} or false);
|
|
||||||
};
|
|
||||||
|
|
||||||
mkAlias = default: mkOption {
|
|
||||||
internal = true;
|
|
||||||
readOnly = true;
|
|
||||||
inherit default;
|
|
||||||
};
|
|
||||||
|
|
||||||
}; in self
|
|
||||||
@@ -1,313 +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.callPackage ../../packages/lndinit {})}/bin/lndinit";
|
|
||||||
|
|
||||||
bitcoind = config.services.bitcoind;
|
|
||||||
|
|
||||||
bitcoindRpcAddress = nbLib.address bitcoind.rpc.address;
|
|
||||||
networkDir = cfg.networkDir;
|
|
||||||
configFile = pkgs.writeText "lnd.conf" ''
|
|
||||||
datadir=${cfg.dataDir}
|
|
||||||
tlscertpath=${cfg.certPath}
|
|
||||||
tlskeypath=${secretsDir}/lnd-key
|
|
||||||
|
|
||||||
# We're logging via journald
|
|
||||||
logging.file.disable=1
|
|
||||||
logging.console.no-timestamps=1
|
|
||||||
|
|
||||||
listen=${toString cfg.address}:${toString cfg.port}
|
|
||||||
rpclisten=${cfg.rpcAddress}:${toString cfg.rpcPort}
|
|
||||||
restlisten=${cfg.restAddress}:${toString cfg.restPort}
|
|
||||||
|
|
||||||
bitcoin.${bitcoind.network}=1
|
|
||||||
bitcoin.node=bitcoind
|
|
||||||
|
|
||||||
${optionalString (cfg.tor.proxy) "tor.active=true"}
|
|
||||||
${optionalString (cfg.tor-socks != null) "tor.socks=${cfg.tor-socks}"}
|
|
||||||
|
|
||||||
bitcoind.rpchost=${bitcoindRpcAddress}:${toString bitcoind.rpc.port}
|
|
||||||
bitcoind.rpcuser=${bitcoind.rpc.users.public.name}
|
|
||||||
bitcoind.zmqpubrawblock=${zmqHandleSpecialAddress bitcoind.zmqpubrawblock}
|
|
||||||
bitcoind.zmqpubrawtx=${zmqHandleSpecialAddress bitcoind.zmqpubrawtx}
|
|
||||||
|
|
||||||
wallet-unlock-password-file=${secretsDir}/lnd-wallet-password
|
|
||||||
|
|
||||||
${cfg.extraConfig}
|
|
||||||
'';
|
|
||||||
|
|
||||||
zmqHandleSpecialAddress = builtins.replaceStrings [ "0.0.0.0" "[::]" ] [ "127.0.0.1" "[::1]" ];
|
|
||||||
in {
|
|
||||||
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
assertions = [
|
|
||||||
{ assertion =
|
|
||||||
!(config.services ? clightning)
|
|
||||||
|| true; # clightning enable/port check disabled - option structure differs between nixpkgs versions (f13ff45 has plugins only, 8b8c811 removed). Sovran uses lnd only, so no conflict.
|
|
||||||
message = ''
|
|
||||||
LND and clightning can't both bind to lightning port 9735. Either
|
|
||||||
disable LND/clightning or change services.clightning.port or
|
|
||||||
services.lnd.port to a port other than 9735.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
|
|
||||||
# Increase rpc thread count due to reports that lightning implementations fail
|
|
||||||
# under high bitcoind rpc load
|
|
||||||
rpc.threads = 16;
|
|
||||||
|
|
||||||
zmqpubrawblock = mkDefault "tcp://${bitcoindRpcAddress}:28332";
|
|
||||||
zmqpubrawtx = mkDefault "tcp://${bitcoindRpcAddress}:28333";
|
|
||||||
};
|
|
||||||
|
|
||||||
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
services.lnd.certificate.extraIPs = mkIf (cfg.rpcAddress != "127.0.0.1") [ "${cfg.rpcAddress}" ];
|
|
||||||
|
|
||||||
systemd.services.lnd = {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "bitcoind.service" ];
|
|
||||||
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
|
||||||
preStart = ''
|
|
||||||
install -m600 ${configFile} '${cfg.dataDir}/lnd.conf'
|
|
||||||
{
|
|
||||||
echo "bitcoind.rpcpass=$(cat ${secretsDir}/bitcoin-rpcpassword-public)"
|
|
||||||
${optionalString (cfg.getPublicAddressCmd != "") ''
|
|
||||||
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
|
||||||
''}
|
|
||||||
} >> '${cfg.dataDir}/lnd.conf'
|
|
||||||
|
|
||||||
if [[ ! -f ${networkDir}/wallet.db ]]; then
|
|
||||||
seed='${cfg.dataDir}/lnd-seed-mnemonic'
|
|
||||||
|
|
||||||
if [[ ! -f "$seed" ]]; then
|
|
||||||
echo "Create lnd seed"
|
|
||||||
(umask u=r,go=; ${lndinit} gen-seed > "$seed")
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Create lnd wallet"
|
|
||||||
${lndinit} -v init-wallet \
|
|
||||||
--file.seed="$seed" \
|
|
||||||
--file.wallet-password='${secretsDir}/lnd-wallet-password' \
|
|
||||||
--init-file.output-wallet-dir='${cfg.networkDir}'
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
Type = "notify";
|
|
||||||
RuntimeDirectory = "lnd"; # Only used to store custom macaroons
|
|
||||||
RuntimeDirectoryMode = "711";
|
|
||||||
ExecStart = "${cfg.package}/bin/lnd --configfile='${cfg.dataDir}/lnd.conf'";
|
|
||||||
User = cfg.user;
|
|
||||||
TimeoutSec = "15min";
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.dataDir ];
|
|
||||||
ExecStartPost = let
|
|
||||||
curl = "${pkgs.curl}/bin/curl -fsS --cacert ${cfg.certPath}";
|
|
||||||
restUrl = "https://${nbLib.addressWithPort cfg.restAddress cfg.restPort}/v1";
|
|
||||||
# Setting macaroon permissions for other users needs root permissions
|
|
||||||
script = nbLib.rootScript "lnd-create-macaroons" ''
|
|
||||||
umask ug=r,o=
|
|
||||||
${lib.concatMapStrings (macaroon: ''
|
|
||||||
echo "Create custom macaroon ${macaroon}"
|
|
||||||
macaroonPath="$RUNTIME_DIRECTORY/${macaroon}.macaroon"
|
|
||||||
${curl} \
|
|
||||||
-H "Grpc-Metadata-macaroon: $(${pkgs.xxd}/bin/xxd -ps -u -c 99999 '${networkDir}/admin.macaroon')" \
|
|
||||||
-X POST \
|
|
||||||
-d '{"permissions":[${cfg.macaroons.${macaroon}.permissions}]}' \
|
|
||||||
${restUrl}/macaroon |\
|
|
||||||
${pkgs.jq}/bin/jq -c '.macaroon' | ${pkgs.xxd}/bin/xxd -p -r > "$macaroonPath"
|
|
||||||
chown ${cfg.macaroons.${macaroon}.user}: "$macaroonPath"
|
|
||||||
'') (attrNames cfg.macaroons)}
|
|
||||||
'';
|
|
||||||
in [
|
|
||||||
script
|
|
||||||
];
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
extraGroups = [ "bitcoinrpc-public" ];
|
|
||||||
home = cfg.dataDir; # lnd creates .lnd dir in HOME
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
nix-bitcoin.operator = {
|
|
||||||
groups = [ cfg.group ];
|
|
||||||
allowRunAsUsers = [ cfg.user ];
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.secrets = {
|
|
||||||
lnd-wallet-password.user = cfg.user;
|
|
||||||
lnd-key.user = cfg.user;
|
|
||||||
lnd-cert.user = cfg.user;
|
|
||||||
lnd-cert.permissions = "444"; # world readable
|
|
||||||
};
|
|
||||||
# Advantages of manually pre-generating certs:
|
|
||||||
# - Reduces dynamic state
|
|
||||||
# - Enables deployment of a mesh of server plus client nodes with predefined certs
|
|
||||||
nix-bitcoin.generateSecretsCmds.lnd = ''
|
|
||||||
makePasswordSecret lnd-wallet-password
|
|
||||||
makeCert lnd '${nbLib.mkCertExtraAltNames cfg.certificate}'
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
cfg = config.services.lnd;
|
|
||||||
operatorName = config.nix-bitcoin.operator.name;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
|
|
||||||
mkLndconnect = { name, isClightning ? false, enableOnion, onionService, port, certPath, authSecretPath }:
|
|
||||||
let
|
|
||||||
lnd = config.services.lnd;
|
|
||||||
getOnionAddress = "cat ${config.nix-bitcoin.secretsDir}/onion-address-${onionService} 2>/dev/null || echo ${onionService}.onion";
|
|
||||||
in pkgs.writeScriptBin name ''
|
|
||||||
#!${pkgs.bash}/bin/bash
|
|
||||||
set -e
|
|
||||||
certPath="${certPath}"
|
|
||||||
authSecretPath="${authSecretPath}"
|
|
||||||
if [ "${toString enableOnion}" = "1" ]; then
|
|
||||||
host=$(cat /var/lib/tor/onion/${onionService}/hostname 2>/dev/null || echo "${onionService}.onion")
|
|
||||||
port="${toString port}"
|
|
||||||
else
|
|
||||||
host="${nbLib.address lnd.restAddress}"
|
|
||||||
port="${toString lnd.restPort}"
|
|
||||||
fi
|
|
||||||
# lndconnect is provided by pkgs.lndconnect
|
|
||||||
${getExe pkgs.lndconnect} --host="$host" --port="$port" --cert="$certPath" --macaroon="$authSecretPath" "$@"
|
|
||||||
'';
|
|
||||||
in {
|
|
||||||
options.services.lnd.lndconnect = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable lndconnect for LND";
|
|
||||||
};
|
|
||||||
onion = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Expose lndconnect via Tor onion service";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = mkIf cfg.enable (mkMerge [
|
|
||||||
(mkIf cfg.lndconnect.enable {
|
|
||||||
environment.systemPackages = [
|
|
||||||
(mkLndconnect {
|
|
||||||
name = "lndconnect";
|
|
||||||
enableOnion = cfg.lndconnect.onion;
|
|
||||||
onionService = "${operatorName}/lnd";
|
|
||||||
port = cfg.restPort;
|
|
||||||
certPath = cfg.certPath;
|
|
||||||
authSecretPath = "${cfg.networkDir}/admin.macaroon";
|
|
||||||
})
|
|
||||||
];
|
|
||||||
})
|
|
||||||
(mkIf (cfg.lndconnect.enable && cfg.lndconnect.onion) {
|
|
||||||
services.tor.relay.onionServices.lnd = nbLib.mkOnionService {
|
|
||||||
port = cfg.restPort;
|
|
||||||
target = { addr = nbLib.address cfg.restAddress; port = cfg.restPort; };
|
|
||||||
};
|
|
||||||
nix-bitcoin.onionAddresses.access.${operatorName} = [ "lnd" ];
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services = {
|
|
||||||
mempool = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Enable Mempool, a fully featured Bitcoin visualizer, explorer, and API service.
|
|
||||||
|
|
||||||
Note: Mempool enables `txindex` in bitcoind (this is a requirement).
|
|
||||||
|
|
||||||
This module has two components:
|
|
||||||
- A backend service (systemd service `mempool`)
|
|
||||||
|
|
||||||
- An optional web interface run by nginx, defined by options `services.mempool.frontend.*`.
|
|
||||||
The frontend is enabled by default when mempool is enabled.
|
|
||||||
For details, see `services.mempool.frontend.enable`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
frontend = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = cfg.enable;
|
|
||||||
description = ''
|
|
||||||
Enable the mempool frontend (web interface).
|
|
||||||
This starts a simple nginx instance, configured for local usage with
|
|
||||||
settings similar to the `mempool/frontend` Docker image.
|
|
||||||
|
|
||||||
IMPORTANT:
|
|
||||||
If you want to expose the mempool frontend to the internet, you
|
|
||||||
should create a custom nginx config that includes TLS, backend caching, rate limiting
|
|
||||||
and performance tuning.
|
|
||||||
For this task, reuse the config snippets from option `services.mempool.frontend.nginxConfig`.
|
|
||||||
See also: https://github.com/fort-nix/nixbitcoin.org/blob/master/website/mempool.nix,
|
|
||||||
which contains a mempool nginx config for public hosting (running at
|
|
||||||
https://mempool.nixbitcoin.org).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "HTTP server address.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 60845; # A random private port
|
|
||||||
description = "HTTP server port.";
|
|
||||||
};
|
|
||||||
settings = mkOption {
|
|
||||||
type = with types; attrsOf anything;
|
|
||||||
default = {};
|
|
||||||
example = {
|
|
||||||
TESTNET_ENABLED = true;
|
|
||||||
MEMPOOL_WEBSITE_URL = "mempool.mynode.org";
|
|
||||||
};
|
|
||||||
description = ''
|
|
||||||
Mempool frontend settings.
|
|
||||||
See here for available options:
|
|
||||||
https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
|
|
||||||
(`interface Env` and `defaultEnv`)
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
staticContentRoot = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-frontend.withConfig cfg.frontend.settings;
|
|
||||||
defaultText = "mempoolPkgs.mempool-frontend";
|
|
||||||
description = "
|
|
||||||
Path of the static frontend content root.
|
|
||||||
";
|
|
||||||
};
|
|
||||||
nginxConfig = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = frontend.nginxConfig;
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = "
|
|
||||||
An attrset of nginx config snippets for assembling a custom
|
|
||||||
mempool nginx config.
|
|
||||||
For details, see the source comments at the point of definition.
|
|
||||||
";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Mempool backend address.";
|
|
||||||
};
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 8999;
|
|
||||||
description = "Mempool backend port.";
|
|
||||||
};
|
|
||||||
electrumServer = mkOption {
|
|
||||||
type = types.enum [ "electrs" ];
|
|
||||||
default = "electrs";
|
|
||||||
description = ''
|
|
||||||
The Electrum server to use for fetching address information.
|
|
||||||
|
|
||||||
Possible options:
|
|
||||||
- electrs:
|
|
||||||
Small database size, slow when querying new addresses.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
settings = mkOption {
|
|
||||||
type = with types; attrsOf (attrsOf anything);
|
|
||||||
example = {
|
|
||||||
MEMPOOL = {
|
|
||||||
POLL_RATE_MS = 3000;
|
|
||||||
STDOUT_LOG_MIN_PRIORITY = "debug";
|
|
||||||
};
|
|
||||||
PRICE_DATA_SERVER = {
|
|
||||||
CLEARNET_URL = "https://myserver.org/prices";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
description = ''
|
|
||||||
Mempool backend settings.
|
|
||||||
See here for available options:
|
|
||||||
https://github.com/mempool/mempool/blob/master/backend/src/config.ts
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
database = {
|
|
||||||
name = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "mempool";
|
|
||||||
description = "Database name.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
package = mkOption {
|
|
||||||
type = types.package;
|
|
||||||
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-backend;
|
|
||||||
defaultText = "mempoolPkgs.mempool-backend";
|
|
||||||
description = "The package providing mempool binaries.";
|
|
||||||
};
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "mempool";
|
|
||||||
description = "The user as which to run Mempool.";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run Mempool.";
|
|
||||||
};
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
|
|
||||||
# Internal read-only options used by `./nodeinfo.nix` and `./onion-services.nix`
|
|
||||||
mempool-frontend = let
|
|
||||||
inherit (nbLib) mkAlias;
|
|
||||||
in {
|
|
||||||
enable = mkAlias cfg.frontend.enable;
|
|
||||||
address = mkAlias cfg.frontend.address;
|
|
||||||
port = mkAlias cfg.frontend.port;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.mempool;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
nbPkgs = pkgs; # vendored: now alias to pkgs
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
|
|
||||||
configFile = builtins.toFile "mempool-config" (builtins.toJSON cfg.settings);
|
|
||||||
cacheDir = "/var/cache/mempool";
|
|
||||||
|
|
||||||
inherit (config.services)
|
|
||||||
bitcoind
|
|
||||||
electrs;
|
|
||||||
|
|
||||||
torSocket = config.services.tor.client.socksListenAddress;
|
|
||||||
# Vendored mempool package
|
|
||||||
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
|
|
||||||
mempoolPkgs = pkgs.callPackage ../../packages/mempool { inherit fetchNodeModules; };
|
|
||||||
|
|
||||||
# See the `services.nginx` definition further below
|
|
||||||
# on how to use these snippets.
|
|
||||||
frontend.nginxConfig = {
|
|
||||||
# This must be added to `services.nginx.commonHttpConfig` when
|
|
||||||
# `mempool/location-static.conf` is used
|
|
||||||
httpConfig = ''
|
|
||||||
include ${mempoolPkgs.mempool-nginx-conf}/http-language.conf;
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Config for static website content.
|
|
||||||
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
|
|
||||||
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-redirects.conf
|
|
||||||
staticContent = ''
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
add_header Cache-Control "public, no-transform";
|
|
||||||
add_header Vary Accept-Language;
|
|
||||||
add_header Vary Cookie;
|
|
||||||
|
|
||||||
include ${mempoolPkgs.mempool-nginx-conf}/location-static.conf;
|
|
||||||
|
|
||||||
# Redirect /api to /docs/api
|
|
||||||
location = /api {
|
|
||||||
return 308 https://$host/docs/api;
|
|
||||||
}
|
|
||||||
location = /api/ {
|
|
||||||
return 308 https://$host/docs/api;
|
|
||||||
}
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Config for backend API.
|
|
||||||
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
|
|
||||||
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-api.conf.
|
|
||||||
proxyApi = let
|
|
||||||
backend = "http://${nbLib.addressWithPort cfg.address cfg.port}";
|
|
||||||
in ''
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass ${backend}/api/v1/;
|
|
||||||
}
|
|
||||||
location /api/v1 {
|
|
||||||
proxy_pass ${backend};
|
|
||||||
}
|
|
||||||
# Websocket API
|
|
||||||
location /api/v1/ws {
|
|
||||||
proxy_pass ${backend};
|
|
||||||
|
|
||||||
# Websocket header settings
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "Upgrade";
|
|
||||||
|
|
||||||
# Relevant settings from `recommendedProxyConfig` (nixos/nginx/default.nix)
|
|
||||||
# (In the above api locations, these are inherited from the parent scope)
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
services.bitcoind.txindex = true;
|
|
||||||
services.electrs.enable = true;
|
|
||||||
services.mysql = {
|
|
||||||
enable = true;
|
|
||||||
package = pkgs.mariadb;
|
|
||||||
ensureDatabases = [ cfg.database.name ];
|
|
||||||
ensureUsers = [
|
|
||||||
{
|
|
||||||
name = cfg.user;
|
|
||||||
ensurePermissions."${cfg.database.name}.*" = "ALL PRIVILEGES";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
# Available options:
|
|
||||||
# https://github.com/mempool/mempool/blob/master/backend/src/config.ts
|
|
||||||
services.mempool.settings = {
|
|
||||||
MEMPOOL = {
|
|
||||||
# mempool doesn't support regtest
|
|
||||||
NETWORK = "mainnet";
|
|
||||||
BACKEND = "electrum";
|
|
||||||
HTTP_PORT = cfg.port;
|
|
||||||
CACHE_DIR = "${cacheDir}/cache";
|
|
||||||
STDOUT_LOG_MIN_PRIORITY = mkDefault "info";
|
|
||||||
AUTOMATIC_POOLS_UPDATE = true;
|
|
||||||
};
|
|
||||||
CORE_RPC = {
|
|
||||||
HOST = bitcoind.rpc.address;
|
|
||||||
PORT = bitcoind.rpc.port;
|
|
||||||
USERNAME = bitcoind.rpc.users.public.name;
|
|
||||||
PASSWORD = "@btcRpcPassword@";
|
|
||||||
};
|
|
||||||
ELECTRUM = let
|
|
||||||
server = config.services.${cfg.electrumServer};
|
|
||||||
in {
|
|
||||||
HOST = server.address;
|
|
||||||
PORT = server.port;
|
|
||||||
TLS_ENABLED = false;
|
|
||||||
};
|
|
||||||
DATABASE = {
|
|
||||||
ENABLED = true;
|
|
||||||
DATABASE = cfg.database.name;
|
|
||||||
SOCKET = "/run/mysqld/mysqld.sock";
|
|
||||||
PID_DIR = cacheDir;
|
|
||||||
};
|
|
||||||
} // optionalAttrs (cfg.tor.proxy) {
|
|
||||||
# Use Tor for rate fetching and pool updating
|
|
||||||
SOCKS5PROXY = {
|
|
||||||
ENABLED = true;
|
|
||||||
USE_ONION = true;
|
|
||||||
HOST = torSocket.addr;
|
|
||||||
PORT = torSocket.port;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.mempool = rec {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
requires = [ "mysql.service" ];
|
|
||||||
wants = [ "${cfg.electrumServer}.service" ];
|
|
||||||
after = requires ++ wants;
|
|
||||||
preStart = ''
|
|
||||||
mkdir -p '${cacheDir}/cache'
|
|
||||||
<${configFile} sed \
|
|
||||||
-e "s|@btcRpcPassword@|$(cat ${secretsDir}/bitcoin-rpcpassword-public)|" \
|
|
||||||
> '${cacheDir}/config.json'
|
|
||||||
'';
|
|
||||||
environment.MEMPOOL_CONFIG_FILE = "${cacheDir}/config.json";
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
ExecStart = "${cfg.package}/bin/mempool-backend";
|
|
||||||
CacheDirectory = "mempool";
|
|
||||||
CacheDirectoryMode = "770";
|
|
||||||
# Show "mempool" instead of "node" in the journal
|
|
||||||
SyslogIdentifier = "mempool";
|
|
||||||
User = cfg.user;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
|
||||||
// nbLib.nodejs;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.nginx = mkIf cfg.frontend.enable {
|
|
||||||
enable = true;
|
|
||||||
enableReload = true;
|
|
||||||
recommendedBrotliSettings = true;
|
|
||||||
recommendedGzipSettings = true;
|
|
||||||
recommendedOptimisation = true;
|
|
||||||
recommendedProxySettings = true;
|
|
||||||
recommendedTlsSettings = true;
|
|
||||||
commonHttpConfig = frontend.nginxConfig.httpConfig;
|
|
||||||
virtualHosts."mempool" = {
|
|
||||||
serverName = "_";
|
|
||||||
listen = [ { addr = cfg.frontend.address; port = cfg.frontend.port; } ];
|
|
||||||
root = cfg.frontend.staticContentRoot;
|
|
||||||
extraConfig =
|
|
||||||
frontend.nginxConfig.staticContent +
|
|
||||||
frontend.nginxConfig.proxyApi;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
extraGroups = [ "bitcoinrpc-public" ];
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
{
|
|
||||||
options = {
|
|
||||||
nix-bitcoin = {
|
|
||||||
# Kept for compatibility, now simply aliases system pkgs
|
|
||||||
pkgs = mkOption {
|
|
||||||
type = types.attrs;
|
|
||||||
default = pkgs;
|
|
||||||
defaultText = "pkgs";
|
|
||||||
description = "Alias to system pkgs (vendored nix-bitcoin now uses nixpkgs directly).";
|
|
||||||
};
|
|
||||||
|
|
||||||
useVersionLockedPkgs = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Deprecated — vendored modules always use system pkgs.";
|
|
||||||
};
|
|
||||||
|
|
||||||
pkgOverlays = mkOption {
|
|
||||||
internal = true;
|
|
||||||
type = with types; functionTo attrs;
|
|
||||||
default = _: _: {};
|
|
||||||
description = "Deprecated stub.";
|
|
||||||
};
|
|
||||||
|
|
||||||
lib = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = import ./lib.nix lib pkgs config;
|
|
||||||
defaultText = "vendor/nix-bitcoin/lib.nix";
|
|
||||||
};
|
|
||||||
|
|
||||||
torClientAddressWithPort = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = with config.services.tor.client.socksListenAddress;
|
|
||||||
"${addr}:${toString port}";
|
|
||||||
defaultText = "(See source)";
|
|
||||||
};
|
|
||||||
|
|
||||||
torify = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = pkgs.writers.writeBashBin "torify" ''
|
|
||||||
${pkgs.tor}/bin/torify \
|
|
||||||
--address ${config.services.tor.client.socksListenAddress.addr} \
|
|
||||||
"$@"
|
|
||||||
'';
|
|
||||||
defaultText = "(See source)";
|
|
||||||
};
|
|
||||||
|
|
||||||
runAsUserCmd = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = if config.security.doas.enable
|
|
||||||
then "doas -u"
|
|
||||||
else "sudo -u";
|
|
||||||
defaultText = "(See source)";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options = {
|
|
||||||
nix-bitcoin.nodeinfo = {
|
|
||||||
enable = mkEnableOption "nodeinfo";
|
|
||||||
|
|
||||||
program = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = script;
|
|
||||||
defaultText = "(See source)";
|
|
||||||
};
|
|
||||||
|
|
||||||
services = mkOption {
|
|
||||||
internal = true;
|
|
||||||
type = types.attrs;
|
|
||||||
default = {};
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = ''
|
|
||||||
Nodeinfo service definitions.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
lib = mkOption {
|
|
||||||
internal = true;
|
|
||||||
readOnly = true;
|
|
||||||
default = nodeinfoLib;
|
|
||||||
defaultText = "(See source)";
|
|
||||||
description = ''
|
|
||||||
Helper functions for defining nodeinfo services.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin.nodeinfo;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
|
|
||||||
script = pkgs.writeScriptBin "nodeinfo" ''
|
|
||||||
#!${pkgs.python3}/bin/python
|
|
||||||
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from collections import OrderedDict
|
|
||||||
|
|
||||||
def success(*args):
|
|
||||||
return subprocess.call(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
|
|
||||||
|
|
||||||
def is_active(unit):
|
|
||||||
return success("systemctl", "is-active", "--quiet", unit)
|
|
||||||
|
|
||||||
def is_enabled(unit):
|
|
||||||
return success("systemctl", "is-enabled", "--quiet", unit)
|
|
||||||
|
|
||||||
def cmd(*args):
|
|
||||||
return subprocess.run(args, stdout=subprocess.PIPE).stdout.decode('utf-8')
|
|
||||||
|
|
||||||
def shell(*args):
|
|
||||||
return cmd("bash", "-c", *args).strip()
|
|
||||||
|
|
||||||
infos = OrderedDict()
|
|
||||||
operator = "${config.nix-bitcoin.operator.name}"
|
|
||||||
|
|
||||||
def get_onion_address(name, port):
|
|
||||||
path = f"/var/lib/onion-addresses/{operator}/{name}"
|
|
||||||
try:
|
|
||||||
with open(path, "r") as f:
|
|
||||||
onion_address = f.read().strip()
|
|
||||||
except OSError:
|
|
||||||
print(f"error reading file {path}", file=sys.stderr)
|
|
||||||
return
|
|
||||||
return f"{onion_address}:{port}"
|
|
||||||
|
|
||||||
def add_service(service, make_info, systemd_service = None):
|
|
||||||
systemd_service = systemd_service or service
|
|
||||||
if not is_active(systemd_service):
|
|
||||||
infos[service] = f"'{systemd_service}.service' is not running"
|
|
||||||
else:
|
|
||||||
info = OrderedDict()
|
|
||||||
exec(make_info, globals(), locals())
|
|
||||||
infos[service] = info
|
|
||||||
|
|
||||||
if is_enabled("onion-addresses") and not is_active("onion-addresses"):
|
|
||||||
print("error: service 'onion-addresses' is not running")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
${concatStrings infos}
|
|
||||||
|
|
||||||
print(json.dumps(infos, indent=2))
|
|
||||||
'';
|
|
||||||
|
|
||||||
infos = map (serviceName:
|
|
||||||
let serviceCfg = config.services.${serviceName};
|
|
||||||
in optionalString serviceCfg.enable (cfg.services.${serviceName} serviceName serviceCfg)
|
|
||||||
) (builtins.attrNames cfg.services);
|
|
||||||
|
|
||||||
nodeinfoLib = rec {
|
|
||||||
mkInfo = extraCode: name: cfg:
|
|
||||||
mkInfoLong {
|
|
||||||
inherit extraCode name cfg;
|
|
||||||
};
|
|
||||||
|
|
||||||
mkInfoLong = { extraCode ? "", name, cfg, systemdServiceName ? name }: ''
|
|
||||||
add_service("${name}", """
|
|
||||||
info["local_address"] = "${nbLib.addressWithPort cfg.address cfg.port}"
|
|
||||||
'' + mkIfOnionPort name (onionPort: ''
|
|
||||||
info["onion_address"] = get_onion_address("${name}", ${onionPort})
|
|
||||||
'') + extraCode + ''
|
|
||||||
|
|
||||||
""", "${systemdServiceName}")
|
|
||||||
'';
|
|
||||||
|
|
||||||
mkIfOnionPort = name: fn:
|
|
||||||
if onionServices ? ${name} then
|
|
||||||
fn (toString (builtins.elemAt onionServices.${name}.map 0).port)
|
|
||||||
else
|
|
||||||
"";
|
|
||||||
};
|
|
||||||
|
|
||||||
inherit (config.services.tor.relay) onionServices;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
environment.systemPackages = [ script ];
|
|
||||||
|
|
||||||
nix-bitcoin.operator.enable = true;
|
|
||||||
|
|
||||||
nix-bitcoin.nodeinfo.services = with nodeinfoLib; {
|
|
||||||
bitcoind = mkInfo "";
|
|
||||||
lnd = name: cfg: mkInfo (''
|
|
||||||
info["rest_address"] = "${nbLib.addressWithPort cfg.restAddress cfg.restPort}"
|
|
||||||
'' + mkIfOnionPort "lnd-rest" (onionPort: ''
|
|
||||||
info["onion_rest_address"] = get_onion_address("lnd-rest", ${onionPort})
|
|
||||||
'') + ''
|
|
||||||
info["nodeid"] = shell("lncli getinfo | jq -r '.identity_pubkey'")
|
|
||||||
'') name cfg;
|
|
||||||
electrs = mkInfo "";
|
|
||||||
btcpayserver = mkInfo "";
|
|
||||||
rtl = mkInfo "";
|
|
||||||
mempool = mkInfo "";
|
|
||||||
mempool-frontend = name: cfg: mkInfoLong {
|
|
||||||
inherit name cfg;
|
|
||||||
systemdServiceName = "nginx";
|
|
||||||
};
|
|
||||||
# Only add sshd when it has an onion service
|
|
||||||
sshd = name: cfg: mkIfOnionPort "sshd" (onionPort: ''
|
|
||||||
add_service("sshd", """info["onion_address"] = get_onion_address("sshd", ${onionPort})""")
|
|
||||||
'');
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# This module enables unprivileged users to read onion addresses.
|
|
||||||
# By default, onion addresses in /var/lib/tor/onion are only readable by the
|
|
||||||
# tor user.
|
|
||||||
# The included service copies onion addresses to /var/lib/onion-addresses/<user>/
|
|
||||||
# and sets permissions according to option 'access'.
|
|
||||||
|
|
||||||
{ config, lib, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin.onionAddresses = {
|
|
||||||
access = mkOption {
|
|
||||||
type = with types; attrsOf (listOf str);
|
|
||||||
default = {};
|
|
||||||
description = ''
|
|
||||||
This option controls who is allowed to access onion addresses.
|
|
||||||
For example, the following allows user 'myuser' to access bitcoind
|
|
||||||
and clightning onion addresses:
|
|
||||||
```nix
|
|
||||||
{
|
|
||||||
"myuser" = [ "bitcoind" "clightning" ];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
The onion hostnames can then be read from
|
|
||||||
{file}`/var/lib/onion-addresses/myuser`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
services = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
description = ''
|
|
||||||
Services that can access their onion address via file
|
|
||||||
{file}`/var/lib/onion-addresses/<service>`
|
|
||||||
The file is readable only by the service user.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
dataDir = mkOption {
|
|
||||||
readOnly = true;
|
|
||||||
default = "/var/lib/onion-addresses";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin.onionAddresses;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf (cfg.access != {} || cfg.services != []) {
|
|
||||||
systemd.services.onion-addresses = {
|
|
||||||
wantedBy = [ "tor.service" ];
|
|
||||||
bindsTo = [ "tor.service" ];
|
|
||||||
after = [ "tor.service" ];
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
StateDirectory = "onion-addresses";
|
|
||||||
StateDirectoryMode = "771";
|
|
||||||
PrivateNetwork = true; # This service needs no network access
|
|
||||||
PrivateUsers = false;
|
|
||||||
CapabilityBoundingSet = "CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER";
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
waitForFile() {
|
|
||||||
file=$1
|
|
||||||
for ((i=0; i<300; i++)); do
|
|
||||||
if [[ -e $file ]]; then
|
|
||||||
return;
|
|
||||||
fi
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
echo "Error: File $file did not appear after 30 sec."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Wait until tor is up
|
|
||||||
waitForFile /var/lib/tor/state
|
|
||||||
|
|
||||||
cd ${cfg.dataDir}
|
|
||||||
rm -rf ./*
|
|
||||||
|
|
||||||
${concatMapStrings
|
|
||||||
(user: ''
|
|
||||||
mkdir -p -m 0700 ${user}
|
|
||||||
chown ${user} ${user}
|
|
||||||
${concatMapStrings
|
|
||||||
(service: ''
|
|
||||||
onionFile='/var/lib/tor/onion/${service}/hostname'
|
|
||||||
waitForFile "$onionFile"
|
|
||||||
cp "$onionFile" '${user}/${service}'
|
|
||||||
chown '${user}' '${user}/${service}'
|
|
||||||
'')
|
|
||||||
cfg.access.${user}
|
|
||||||
}
|
|
||||||
'')
|
|
||||||
(builtins.attrNames cfg.access)
|
|
||||||
}
|
|
||||||
|
|
||||||
${concatMapStrings (service: ''
|
|
||||||
onionFile=/var/lib/tor/onion/${service}/hostname
|
|
||||||
waitForFile "$onionFile"
|
|
||||||
install -D -o ${config.systemd.services.${service}.serviceConfig.User} -m 400 "$onionFile" services/${service}
|
|
||||||
'') cfg.services}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# This module creates onion-services for NixOS services.
|
|
||||||
# An onion service can be enabled for every service that defines
|
|
||||||
# options 'address', 'port' and optionally 'getPublicAddressCmd'.
|
|
||||||
#
|
|
||||||
# See it in use at ./presets/enable-tor.nix
|
|
||||||
|
|
||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin.onionServices = mkOption {
|
|
||||||
default = {};
|
|
||||||
type = with types; attrsOf (submodule (
|
|
||||||
{ config, ... }: {
|
|
||||||
options = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = config.public;
|
|
||||||
description = ''
|
|
||||||
Create an onion service for the given service.
|
|
||||||
The service must define options {option}`address` and {option}`onionPort` (or `port`).
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
public = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Make the onion address accessible to the service.
|
|
||||||
If enabled, the onion service is automatically enabled.
|
|
||||||
Only available for services that define option {option}`getPublicAddressCmd`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
externalPort = mkOption {
|
|
||||||
type = types.nullOr types.port;
|
|
||||||
default = null;
|
|
||||||
description = "Override the external port of the onion service.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin.onionServices;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
|
|
||||||
onionServices = builtins.attrNames cfg;
|
|
||||||
|
|
||||||
activeServices = builtins.filter (service:
|
|
||||||
config.services.${service}.enable && cfg.${service}.enable
|
|
||||||
) onionServices;
|
|
||||||
|
|
||||||
publicServices = builtins.filter (service: cfg.${service}.public) activeServices;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkMerge [
|
|
||||||
(mkIf (activeServices != []) {
|
|
||||||
# Define hidden services
|
|
||||||
services.tor = {
|
|
||||||
enable = true;
|
|
||||||
relay.onionServices = genAttrs activeServices (name:
|
|
||||||
let
|
|
||||||
service = config.services.${name};
|
|
||||||
inherit (cfg.${name}) externalPort;
|
|
||||||
in nbLib.mkOnionService {
|
|
||||||
port = if externalPort != null then externalPort else service.port;
|
|
||||||
target.port = service.onionPort or service.port;
|
|
||||||
target.addr = nbLib.address service.address;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.onionAddresses = {
|
|
||||||
# Enable public services to access their own onion addresses
|
|
||||||
services = publicServices;
|
|
||||||
|
|
||||||
# Allow the operator user to access onion addresses for all active services
|
|
||||||
access.${config.nix-bitcoin.operator.name} = mkIf config.nix-bitcoin.operator.enable activeServices;
|
|
||||||
};
|
|
||||||
systemd.services = let
|
|
||||||
onionAddresses = [ "onion-addresses.service" ];
|
|
||||||
in genAttrs publicServices (service: {
|
|
||||||
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
|
||||||
# that propagates initial start failures but no restarts
|
|
||||||
wants = onionAddresses;
|
|
||||||
after = onionAddresses;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
# Set getPublicAddressCmd for public services
|
|
||||||
{
|
|
||||||
services = let
|
|
||||||
# publicServices' doesn't depend on config.services.*.enable,
|
|
||||||
# so we can use it to define config.services without causing infinite recursion
|
|
||||||
publicServices' = builtins.filter (service:
|
|
||||||
let srv = cfg.${service};
|
|
||||||
in srv.public && srv.enable
|
|
||||||
) onionServices;
|
|
||||||
in genAttrs publicServices' (service: {
|
|
||||||
getPublicAddressCmd = "cat ${config.nix-bitcoin.onionAddresses.dataDir}/services/${service}";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set sensible defaults for some services
|
|
||||||
{
|
|
||||||
nix-bitcoin.onionServices = {
|
|
||||||
btcpayserver = {
|
|
||||||
externalPort = 80;
|
|
||||||
};
|
|
||||||
rtl = {
|
|
||||||
externalPort = 80;
|
|
||||||
};
|
|
||||||
mempool-frontend = {
|
|
||||||
externalPort = 80;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin.operator = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Whether to define a user named `operator` for convenient interactive access
|
|
||||||
to nix-bitcoin features (like `bitcoin-cli`).
|
|
||||||
|
|
||||||
When using nix-bitcoin as part of a larger system config, it makes sense
|
|
||||||
to set your main system user as the operator, by setting option
|
|
||||||
`nix-bitcoin.operator.name = "MAIN_USER_NAME";`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
name = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "operator";
|
|
||||||
description = "Name of the operator user.";
|
|
||||||
};
|
|
||||||
groups = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
description = "Extra groups of the operatur user.";
|
|
||||||
};
|
|
||||||
allowRunAsUsers = mkOption {
|
|
||||||
type = with types; listOf str;
|
|
||||||
default = [];
|
|
||||||
description = "Users as which the operator is allowed to run commands.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin.operator;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
users.users.${cfg.name} = {
|
|
||||||
isNormalUser = true;
|
|
||||||
extraGroups = [
|
|
||||||
"systemd-journal"
|
|
||||||
"proc" # Enable full /proc access and systemd-status
|
|
||||||
] ++ cfg.groups;
|
|
||||||
};
|
|
||||||
|
|
||||||
security = mkIf (cfg.allowRunAsUsers != []) {
|
|
||||||
# Use doas instead of sudo if enabled
|
|
||||||
doas.extraConfig = mkIf config.security.doas.enable ''
|
|
||||||
${lib.concatMapStrings (user: "permit nopass ${cfg.name} as ${user}\n") cfg.allowRunAsUsers}
|
|
||||||
'';
|
|
||||||
sudo.extraConfig = mkIf (!config.security.doas.enable) ''
|
|
||||||
${cfg.name} ALL=(${builtins.concatStringsSep "," cfg.allowRunAsUsers}) NOPASSWD: ALL
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.services.rtl = {
|
|
||||||
enable = mkEnableOption "RTL, a web interface for LND";
|
|
||||||
|
|
||||||
address = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "127.0.0.1";
|
|
||||||
description = "Address to listen for HTTP connections.";
|
|
||||||
};
|
|
||||||
|
|
||||||
port = mkOption {
|
|
||||||
type = types.port;
|
|
||||||
default = 3000;
|
|
||||||
description = "Port to listen for HTTP connections.";
|
|
||||||
};
|
|
||||||
|
|
||||||
dataDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/var/lib/rtl";
|
|
||||||
description = "The data directory for RTL.";
|
|
||||||
};
|
|
||||||
|
|
||||||
nightTheme = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable night theme by default.";
|
|
||||||
};
|
|
||||||
|
|
||||||
extraCurrency = mkOption {
|
|
||||||
type = types.nullOr types.str;
|
|
||||||
default = null;
|
|
||||||
example = "USD";
|
|
||||||
description = ''
|
|
||||||
Additional currency for displaying amounts.
|
|
||||||
When set, Tor is disabled for the RTL service to allow currency rate fetching.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
nodes = {
|
|
||||||
lnd = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable LND node in RTL.";
|
|
||||||
};
|
|
||||||
loop = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable Lightning Loop integration (requires loopd).";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
clightning = {
|
|
||||||
enable = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Enable Core Lightning node in RTL (not supported in Sovran).";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
user = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "rtl";
|
|
||||||
description = "The user as which to run RTL.";
|
|
||||||
};
|
|
||||||
|
|
||||||
group = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = cfg.user;
|
|
||||||
description = "The group as which to run RTL.";
|
|
||||||
};
|
|
||||||
|
|
||||||
tor = nbLib.tor;
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.services.rtl;
|
|
||||||
nbLib = config.nix-bitcoin.lib;
|
|
||||||
secretsDir = config.nix-bitcoin.secretsDir;
|
|
||||||
|
|
||||||
# Vendored RTL package
|
|
||||||
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
|
|
||||||
rtlPackage = pkgs.callPackage ../../packages/rtl { inherit fetchNodeModules; };
|
|
||||||
|
|
||||||
runePath = "${cfg.dataDir}/CLN-Rune.env";
|
|
||||||
|
|
||||||
rtlConfig = {
|
|
||||||
multiPass = "@multiPass@";
|
|
||||||
port = cfg.port;
|
|
||||||
host = cfg.address;
|
|
||||||
defaultNodeIndex = 1;
|
|
||||||
dbDirectoryPath = cfg.dataDir;
|
|
||||||
SSO = {
|
|
||||||
rtlSSO = 0;
|
|
||||||
rtlCookiePath = "";
|
|
||||||
logoutRedirectLink = "";
|
|
||||||
};
|
|
||||||
nodes = optional cfg.nodes.lnd.enable ({
|
|
||||||
index = 1;
|
|
||||||
lnNode = "lnd";
|
|
||||||
lnImplementation = "LND";
|
|
||||||
authentication = {
|
|
||||||
macaroonPath = "${cfg.dataDir}/macaroons";
|
|
||||||
swapMacaroonPath = if cfg.nodes.lnd.loop then "${cfg.dataDir}/loop-macaroons" else "";
|
|
||||||
boltzMacaroonPath = "";
|
|
||||||
};
|
|
||||||
settings = {
|
|
||||||
userPersona = "OPERATOR";
|
|
||||||
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
|
|
||||||
themeColor = "PURPLE";
|
|
||||||
channelBackupPath = "${cfg.dataDir}/backup";
|
|
||||||
logLevel = "INFO";
|
|
||||||
lnServerUrl = "https://${lnd.restAddress}:${toString lnd.restPort}";
|
|
||||||
swapServerUrl = if cfg.nodes.lnd.loop then "https://127.0.0.1:8081" else "";
|
|
||||||
boltzServerUrl = "";
|
|
||||||
fiatConversion = cfg.extraCurrency != null;
|
|
||||||
unannouncedChannels = true;
|
|
||||||
} // optionalAttrs (cfg.extraCurrency != null) {
|
|
||||||
currencyUnit = cfg.extraCurrency;
|
|
||||||
};
|
|
||||||
}) ++ optional cfg.nodes.clightning.enable {
|
|
||||||
index = 2;
|
|
||||||
lnNode = "clightning";
|
|
||||||
lnImplementation = "CLN";
|
|
||||||
authentication = {
|
|
||||||
runePath = runePath;
|
|
||||||
};
|
|
||||||
settings = {
|
|
||||||
userPersona = "OPERATOR";
|
|
||||||
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
|
|
||||||
themeColor = "PURPLE";
|
|
||||||
logLevel = "INFO";
|
|
||||||
fiatConversion = cfg.extraCurrency != null;
|
|
||||||
} // optionalAttrs (cfg.extraCurrency != null) {
|
|
||||||
currencyUnit = cfg.extraCurrency;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
configFile = builtins.toFile "config" (builtins.toJSON rtlConfig);
|
|
||||||
|
|
||||||
inherit (config.services)
|
|
||||||
bitcoind
|
|
||||||
lnd;
|
|
||||||
|
|
||||||
lndLoopEnabled = cfg.nodes.lnd.enable && cfg.nodes.lnd.loop;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = mkIf cfg.enable {
|
|
||||||
assertions = [
|
|
||||||
{ assertion = cfg.nodes.lnd.enable;
|
|
||||||
message = ''
|
|
||||||
RTL: At least one node must be enabled. Sovran supports LND only.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
{ assertion = !cfg.nodes.clightning.enable;
|
|
||||||
message = ''
|
|
||||||
RTL: Core Lightning (clightning) is not supported in Sovran. Use LND instead.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
services.lnd.enable = mkIf cfg.nodes.lnd.enable true;
|
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
|
||||||
];
|
|
||||||
|
|
||||||
services.rtl.tor.enforce = mkIf (cfg.extraCurrency != null) false;
|
|
||||||
|
|
||||||
systemd.services.rtl = rec {
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
wants = optional cfg.nodes.lnd.enable "lnd.service";
|
|
||||||
after = wants ++ [ "nix-bitcoin-secrets.target" ];
|
|
||||||
environment.RTL_CONFIG_PATH = cfg.dataDir;
|
|
||||||
environment.DB_DIRECTORY_PATH = cfg.dataDir;
|
|
||||||
serviceConfig = nbLib.defaultHardening // {
|
|
||||||
ExecStartPre = [
|
|
||||||
(nbLib.script "rtl-setup-config" ''
|
|
||||||
<${configFile} sed "s|@multiPass@|$(cat ${secretsDir}/rtl-password)|" \
|
|
||||||
> '${cfg.dataDir}/RTL-Config.json'
|
|
||||||
'')
|
|
||||||
]
|
|
||||||
++ optional cfg.nodes.lnd.enable
|
|
||||||
# The lnd admin macaroon is not readable by group `lnd`, so copy it
|
|
||||||
(nbLib.rootScript "rtl-copy-macaroon" ''
|
|
||||||
install --compare -m 640 -o ${cfg.user} -g ${cfg.group} -D ${lnd.networkDir}/admin.macaroon \
|
|
||||||
'${cfg.dataDir}/macaroons/admin.macaroon'
|
|
||||||
'');
|
|
||||||
ExecStart = "${rtlPackage}/bin/rtl";
|
|
||||||
# Show "rtl" instead of "node" in the journal
|
|
||||||
SyslogIdentifier = "rtl";
|
|
||||||
User = cfg.user;
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
ReadWritePaths = [ cfg.dataDir ];
|
|
||||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
|
||||||
// nbLib.nodejs;
|
|
||||||
};
|
|
||||||
|
|
||||||
users.users.${cfg.user} = {
|
|
||||||
isSystemUser = true;
|
|
||||||
group = cfg.group;
|
|
||||||
extraGroups = optional lndLoopEnabled lnd.group;
|
|
||||||
};
|
|
||||||
users.groups.${cfg.group} = {};
|
|
||||||
|
|
||||||
nix-bitcoin.secrets.rtl-password.user = cfg.user;
|
|
||||||
nix-bitcoin.generateSecretsCmds.rtl = ''
|
|
||||||
makePasswordSecret rtl-password
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin = {
|
|
||||||
secretsDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
default = "/etc/nix-bitcoin-secrets";
|
|
||||||
description = "Directory to store secrets";
|
|
||||||
};
|
|
||||||
|
|
||||||
setupSecrets = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Set permissions for existing secrets in {option}`nix-bitcoin.secretsDir`
|
|
||||||
before services are started.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
generateSecrets = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Automatically generate all required secrets before services are started.
|
|
||||||
Note: Make sure to create a backup of the generated secrets.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
generateSecretsCmds = mkOption {
|
|
||||||
type = types.attrsOf types.lines;
|
|
||||||
default = {};
|
|
||||||
description = ''
|
|
||||||
Bash expressions for generating secrets.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
# Currently, this is used only by ../deployment/nixops.nix
|
|
||||||
deployment.secretsDir = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
description = ''
|
|
||||||
Directory of local secrets that are transferred to the nix-bitcoin node on deployment
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
secrets = mkOption {
|
|
||||||
default = {};
|
|
||||||
type = with types; attrsOf (submodule (
|
|
||||||
{ config, ... }: {
|
|
||||||
options = {
|
|
||||||
user = mkOption {
|
|
||||||
type = str;
|
|
||||||
default = "root";
|
|
||||||
};
|
|
||||||
group = mkOption {
|
|
||||||
type = str;
|
|
||||||
default = config.user;
|
|
||||||
};
|
|
||||||
permissions = mkOption {
|
|
||||||
type = str;
|
|
||||||
default = "440";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
secretsSetupMethod = mkOption {
|
|
||||||
type = with types; nullOr str;
|
|
||||||
default = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
generateSecretsScript = mkOption {
|
|
||||||
internal = true;
|
|
||||||
default = let
|
|
||||||
rpcauthSrc = pkgs.fetchurl {
|
|
||||||
url = "https://raw.githubusercontent.com/bitcoin/bitcoin/d6cde007db9d3e6ee93bd98a9bbfdce9bfa9b15b/share/rpcauth/rpcauth.py";
|
|
||||||
sha256 = "189mpplam6yzizssrgiyv70c9899ggh8cac76j4n7v0xqzfip07n";
|
|
||||||
};
|
|
||||||
rpcauth = pkgs.writers.writeBash "rpcauth" ''
|
|
||||||
exec ${pkgs.python3}/bin/python ${rpcauthSrc} "$@"
|
|
||||||
'';
|
|
||||||
# Writes secrets to PWD
|
|
||||||
in pkgs.writers.writeBash "generate-secrets" ''
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
export PATH=${lib.makeBinPath (with pkgs; [ coreutils gnugrep ])}
|
|
||||||
|
|
||||||
makePasswordSecret() {
|
|
||||||
# Passwords have alphabet {a-z, A-Z, 0-9} and ~119 bits of entropy
|
|
||||||
[[ -e $1 ]] || ${pkgs.pwgen}/bin/pwgen -s 20 1 > "$1"
|
|
||||||
}
|
|
||||||
makeBitcoinRPCPassword() {
|
|
||||||
user=$1
|
|
||||||
file=bitcoin-rpcpassword-$user
|
|
||||||
HMACfile=bitcoin-HMAC-$user
|
|
||||||
makePasswordSecret "$file"
|
|
||||||
if [[ $file -nt $HMACfile ]]; then
|
|
||||||
${rpcauth} $user $(cat "$file") | grep rpcauth | cut -d ':' -f 2 > "$HMACfile"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
makeCert() {
|
|
||||||
name=$1
|
|
||||||
# Add leading comma if not empty
|
|
||||||
extraAltNames=''${2:+,}''${2:-}
|
|
||||||
if [[ ! -e $name-key ]]; then
|
|
||||||
# Create new key and cert
|
|
||||||
doMakeCert "-newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout $name-key"
|
|
||||||
elif [[ ! -e $name-cert \
|
|
||||||
|| $(cat "$name-cert-alt-names" 2>/dev/null) != $extraAltNames ]]; then
|
|
||||||
# Create cert from existing key
|
|
||||||
doMakeCert "-key $name-key"
|
|
||||||
fi;
|
|
||||||
}
|
|
||||||
doMakeCert() {
|
|
||||||
# This fn uses global variables `name` and `extraAltNames`
|
|
||||||
keyOpts=$1
|
|
||||||
${pkgs.openssl}/bin/openssl req -x509 \
|
|
||||||
-sha256 -days 3650 $keyOpts -out "$name-cert" \
|
|
||||||
-subj "/CN=localhost/O=$name" \
|
|
||||||
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1$extraAltNames"
|
|
||||||
echo "$extraAltNames" > "$name-cert-alt-names"
|
|
||||||
}
|
|
||||||
|
|
||||||
umask u=rw,go=
|
|
||||||
${builtins.concatStringsSep "\n" (builtins.attrValues cfg.generateSecretsCmds)}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
cfg = config.nix-bitcoin;
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
|
|
||||||
config = {
|
|
||||||
assertions = [
|
|
||||||
{ assertion = cfg.secretsSetupMethod != null;
|
|
||||||
message = ''
|
|
||||||
No secrets setup method has been defined.
|
|
||||||
To fix this, choose one of the following:
|
|
||||||
|
|
||||||
- Use one of the deployment methods in ${toString ./../deployment}
|
|
||||||
|
|
||||||
- Set `nix-bitcoin.generateSecrets = true` to automatically generate secrets
|
|
||||||
|
|
||||||
- Set `nix-bitcoin.secretsSetupMethod = "manual"` if you want to manually setup secrets
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
# This target is active when secrets have been setup successfully.
|
|
||||||
systemd.targets.nix-bitcoin-secrets = mkIf (cfg.secretsSetupMethod != "manual") {
|
|
||||||
# This ensures that the secrets target is always activated when switching
|
|
||||||
# configurations.
|
|
||||||
# In this way `switch-to-configuration` is guaranteed to show an error
|
|
||||||
# when activating the secrets target fails on deployment.
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.setupSecrets = mkIf cfg.generateSecrets true;
|
|
||||||
|
|
||||||
nix-bitcoin.secretsSetupMethod = mkIf cfg.setupSecrets "setup-secrets";
|
|
||||||
|
|
||||||
# Operation of this service:
|
|
||||||
# - Set owner and permissions for all used secrets
|
|
||||||
# - Make all other secrets accessible to root only
|
|
||||||
# For all steps make sure that no secrets are copied to the nix store.
|
|
||||||
#
|
|
||||||
systemd.services.setup-secrets = mkIf cfg.setupSecrets {
|
|
||||||
requiredBy = [ "nix-bitcoin-secrets.target" ];
|
|
||||||
before = [ "nix-bitcoin-secrets.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
# Use the same sort order for globbing and sorting as in Nix attrsets.
|
|
||||||
# Required for `comm` below.
|
|
||||||
export LC_COLLATE=C
|
|
||||||
|
|
||||||
${optionalString cfg.generateSecrets ''
|
|
||||||
mkdir -p "${cfg.secretsDir}"
|
|
||||||
cd "${cfg.secretsDir}"
|
|
||||||
chown root: .
|
|
||||||
chmod 0700 .
|
|
||||||
${cfg.generateSecretsScript}
|
|
||||||
''}
|
|
||||||
|
|
||||||
setupSecret() {
|
|
||||||
file="$1"
|
|
||||||
user="$2"
|
|
||||||
group="$3"
|
|
||||||
permissions="$4"
|
|
||||||
if [[ ! -e $file ]]; then
|
|
||||||
echo "Error: Secret file '$file' is missing"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
chown "$user:$group" "$file"
|
|
||||||
chmod "$permissions" "$file"
|
|
||||||
processedFiles+=("$file")
|
|
||||||
}
|
|
||||||
|
|
||||||
dir="${cfg.secretsDir}"
|
|
||||||
if [[ ! -e $dir ]]; then
|
|
||||||
echo "Error: Secrets dir '$dir' is missing"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
chown root: "$dir"
|
|
||||||
cd "$dir"
|
|
||||||
|
|
||||||
processedFiles=()
|
|
||||||
${
|
|
||||||
concatStrings (mapAttrsToList (n: v: ''
|
|
||||||
setupSecret ${n} ${v.user} ${v.group} ${v.permissions}
|
|
||||||
'') cfg.secrets)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Make all other files accessible to root only
|
|
||||||
unprocessedFiles=$(
|
|
||||||
comm -23 <(shopt -s nullglob; printf '%s\n' *) <(printf '%s\n' "''${processedFiles[@]}")
|
|
||||||
)
|
|
||||||
if [[ $unprocessedFiles ]]; then
|
|
||||||
IFS=$'\n'
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
chown root: $unprocessedFiles
|
|
||||||
# shellcheck disable=SC2086
|
|
||||||
chmod 0440 $unprocessedFiles
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Now make the secrets dir accessible to other users
|
|
||||||
chmod 0751 "$dir"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
with lib;
|
|
||||||
{
|
|
||||||
options = {
|
|
||||||
nix-bitcoin.security.dbusHideProcessInformation = mkOption {
|
|
||||||
type = types.bool;
|
|
||||||
default = false;
|
|
||||||
description = ''
|
|
||||||
Only allow users with group `proc` to retrieve systemd unit information like
|
|
||||||
cgroup paths (i.e. (sub)process command lines) via D-Bus.
|
|
||||||
|
|
||||||
This mitigates a systemd security issue where (sub)process command lines can
|
|
||||||
be retrieved by services even when their access to /proc is restricted
|
|
||||||
(via ProtectProc).
|
|
||||||
|
|
||||||
This option works by restricting the D-Bus method `GetUnitProcesses`, which
|
|
||||||
is also used internally by {command}`systemctl status`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = mkIf config.nix-bitcoin.security.dbusHideProcessInformation {
|
|
||||||
users.groups.proc = {};
|
|
||||||
nix-bitcoin.operator.groups = [ "proc" ]; # Enable operator access to systemd-status
|
|
||||||
|
|
||||||
services.dbus.packages = lib.mkAfter [ # Apply at the end to override the default policy
|
|
||||||
(pkgs.writeTextDir "etc/dbus-1/system.d/dbus.conf" ''
|
|
||||||
<busconfig>
|
|
||||||
<policy context="default">
|
|
||||||
<deny
|
|
||||||
send_destination="org.freedesktop.systemd1"
|
|
||||||
send_interface="org.freedesktop.systemd1.Manager"
|
|
||||||
send_member="GetUnitProcesses"
|
|
||||||
/>
|
|
||||||
</policy>
|
|
||||||
<policy group="proc">
|
|
||||||
<allow
|
|
||||||
send_destination="org.freedesktop.systemd1"
|
|
||||||
send_interface="org.freedesktop.systemd1.Manager"
|
|
||||||
send_member="GetUnitProcesses"
|
|
||||||
/>
|
|
||||||
</policy>
|
|
||||||
</busconfig>
|
|
||||||
'')
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{ config, lib, ... }:
|
|
||||||
with lib;
|
|
||||||
let
|
|
||||||
options.nix-bitcoin.configVersion = mkOption {
|
|
||||||
type = with types; nullOr str;
|
|
||||||
default = null;
|
|
||||||
description = "Vendored stub — no version migration needed.";
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
inherit options;
|
|
||||||
config = {};
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
|
||||||
|
|
||||||
lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|
||||||
|
|
||||||
services.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
# Keep the normal loopback P2P socket available for local clients such as
|
|
||||||
# Bisq. Because `address` defaults to 127.0.0.1 this does not expose a
|
|
||||||
# clearnet or LAN listener. When the existing bitcoind onion service is
|
|
||||||
# enabled, this also creates its Tor-tagged loopback target on port 8334.
|
|
||||||
listen = true;
|
|
||||||
package = pkgs.bitcoind;
|
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
|
||||||
txindex = true;
|
|
||||||
tor.proxy = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
disablewallet = true;
|
|
||||||
extraConfig = ''
|
|
||||||
peerbloomfilters=1
|
|
||||||
server=1
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.onionServices.bitcoind = {
|
|
||||||
enable = true;
|
|
||||||
# This is a locally vendored option namespace, not an upstream dependency.
|
|
||||||
# The onion listener remains available to peers that already know it;
|
|
||||||
# advertising its address through Bitcoin peer gossip is opt-in in the Hub.
|
|
||||||
public = config.sovran_systemsOS.features.bitcoin-tor-gossip;
|
|
||||||
};
|
|
||||||
nix-bitcoin.onionServices.electrs.enable = true;
|
|
||||||
nix-bitcoin.onionServices.rtl.enable = true;
|
|
||||||
|
|
||||||
services.electrs = {
|
|
||||||
enable = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data";
|
|
||||||
};
|
|
||||||
|
|
||||||
services.lnd = {
|
|
||||||
enable = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
tor.proxy = true;
|
|
||||||
extraConfig = ''
|
|
||||||
protocol.option-scid-alias=true
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
nix-bitcoin.onionServices.lnd.public = true;
|
|
||||||
|
|
||||||
services.lnd.lndconnect = {
|
|
||||||
enable = true;
|
|
||||||
onion = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.rtl = {
|
|
||||||
enable = true;
|
|
||||||
tor.enforce = true;
|
|
||||||
port = 3050;
|
|
||||||
nightTheme = true;
|
|
||||||
nodes = {
|
|
||||||
lnd = {
|
|
||||||
enable = true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
services.btcpayserver = {
|
|
||||||
enable = config.sovran_systemsOS.web.btcpayserver;
|
|
||||||
};
|
|
||||||
|
|
||||||
services.btcpayserver.lightningBackend = "lnd";
|
|
||||||
|
|
||||||
nix-bitcoin.generateSecrets = true;
|
|
||||||
nix-bitcoin.nodeinfo.enable = true;
|
|
||||||
|
|
||||||
nix-bitcoin.operator = {
|
|
||||||
enable = true;
|
|
||||||
name = "free";
|
|
||||||
};
|
|
||||||
|
|
||||||
# vendored: now no-op (always uses nixpkgs)
|
|
||||||
nix-bitcoin.useVersionLockedPkgs = false;
|
|
||||||
|
|
||||||
systemd.services.bitcoind = {
|
|
||||||
requires = [ "run-media-Second_Drive.mount" ];
|
|
||||||
after = [ "run-media-Second_Drive.mount" ];
|
|
||||||
serviceConfig.PrivateUsers = lib.mkForce false;
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.electrs = {
|
|
||||||
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
|
|
||||||
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
|
|
||||||
wants = [ "bitcoind.service" ];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.lnd = {
|
|
||||||
wants = [ "bitcoind.service" ];
|
|
||||||
# requires for bitcoind set by nix-bitcoin; mkForce removes it
|
|
||||||
requires = lib.mkForce [ ];
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.services.sovran-btc-permissions = {
|
|
||||||
description = "Fix Bitcoin/Electrs data directory ownership on second drive";
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
after = [ "run-media-Second_Drive.mount" ];
|
|
||||||
before = [ "bitcoind.service" "electrs.service" ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
};
|
|
||||||
script = ''
|
|
||||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node ]; then
|
|
||||||
chown -R bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
|
|
||||||
fi
|
|
||||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data ]; then
|
|
||||||
chown -R electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [ 3051 ];
|
|
||||||
networking.firewall.allowedUDPPorts = [ 3051 ];
|
|
||||||
|
|
||||||
sovran_systemsOS.domainRequirements = [
|
|
||||||
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
+17
-4
@@ -33,7 +33,7 @@
|
|||||||
# /var/lib/njalla/ddns_urls.json.
|
# /var/lib/njalla/ddns_urls.json.
|
||||||
NoNewPrivileges = true;
|
NoNewPrivileges = true;
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
ReadWritePaths = [ "/var/lib/njalla" ];
|
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
||||||
ReadOnlyPaths = [ "/etc/sovran" ];
|
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||||
ProtectHome = true;
|
ProtectHome = true;
|
||||||
PrivateTmp = true;
|
PrivateTmp = true;
|
||||||
@@ -88,12 +88,15 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
sys.exit(0) # no URLs configured — nothing to do
|
sys.exit(0) # no URLs configured — nothing to do
|
||||||
|
|
||||||
# Resolve current public IP once
|
# Resolve current public IP via the shared detector — one script, one cache
|
||||||
|
# (STUN -> DNS -> opt-in HTTPS echo; see /var/lib/sovran/public-ip.py).
|
||||||
|
# The detector refreshes /var/lib/secrets/external-ip, which the Hub and
|
||||||
|
# LiveKit read as well, so the whole system shares a single detected value.
|
||||||
public_ip = ""
|
public_ip = ""
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
|
||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=20,
|
||||||
)
|
)
|
||||||
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
|
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
|
||||||
ipaddress.ip_address(raw) # validates — raises if not a real IP
|
ipaddress.ip_address(raw) # validates — raises if not a real IP
|
||||||
@@ -101,6 +104,16 @@ try:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if not public_ip:
|
||||||
|
# Last resort: the shared cache file, if the detector is unavailable.
|
||||||
|
try:
|
||||||
|
with open("/var/lib/secrets/external-ip") as f:
|
||||||
|
raw = f.read().strip()
|
||||||
|
ipaddress.ip_address(raw)
|
||||||
|
public_ip = raw
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if not public_ip:
|
if not public_ip:
|
||||||
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
|
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
# ── Unified public-IP detection (privacy-first) ─────────────────────────────
|
||||||
|
#
|
||||||
|
# One script, one cache file, every consumer on the system reads the same
|
||||||
|
# value. Previously the public IP was detected independently in three places,
|
||||||
|
# each phoning home to a different third party:
|
||||||
|
# * the Hub (server.py _get_external_ip) → api.ipify.org / ifconfig.me /
|
||||||
|
# icanhazip.com over HTTPS on every /api/network call and every
|
||||||
|
# background-loop tick
|
||||||
|
# * DDNS (ddns-update.py) → myip.opendns.com via OpenDNS
|
||||||
|
# * LiveKit → STUN (its own embedded detection)
|
||||||
|
#
|
||||||
|
# This module replaces all of that with a single script
|
||||||
|
# (/var/lib/sovran/public-ip.py) that detects the IP once per TTL using the
|
||||||
|
# least-exposing mechanism available, and caches it in
|
||||||
|
# /var/lib/secrets/external-ip. Consumers (Hub, DDNS, LiveKit) read the cache
|
||||||
|
# and only invoke the script when it is missing or stale.
|
||||||
|
#
|
||||||
|
# Detection chain (first success wins, stops immediately):
|
||||||
|
# 1. pin — sovran_systemsOS.elementCalling.externalIP (baked in)
|
||||||
|
# 2. cache — /var/lib/secrets/external-ip if newer than cacheTTL
|
||||||
|
# 3. STUN — UDP binding request (one packet, no application data,
|
||||||
|
# no HTTP metadata; the same protocol every WebRTC client
|
||||||
|
# uses). Server configurable via publicIP.stunServer.
|
||||||
|
# 4. DNS — "myip.opendns.com" A query via publicIP.dnsResolver
|
||||||
|
# (single DNS query, no HTTP headers)
|
||||||
|
# 5. HTTPS echo — ONLY endpoints listed in publicIP.httpsEcho (empty by
|
||||||
|
# default → never contacted)
|
||||||
|
#
|
||||||
|
# Privacy property: while the cache is fresh, zero third parties are
|
||||||
|
# contacted. When detection runs, at most ONE party learns the IP per
|
||||||
|
# refresh interval (default 5 minutes), and the STUN/DNS mechanisms expose
|
||||||
|
# nothing beyond the bare address.
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
pkgs,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
stunServer = config.sovran_systemsOS.publicIP.stunServer;
|
||||||
|
stunPort = config.sovran_systemsOS.publicIP.stunPort;
|
||||||
|
dnsResolver = config.sovran_systemsOS.publicIP.dnsResolver;
|
||||||
|
httpsEcho = config.sovran_systemsOS.publicIP.httpsEcho;
|
||||||
|
cacheTTL = config.sovran_systemsOS.publicIP.cacheTTL;
|
||||||
|
|
||||||
|
# Optional pin shared with element-calling (baked in at build time).
|
||||||
|
pin = if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else "";
|
||||||
|
|
||||||
|
echoList = lib.concatStringsSep "," (map (u: "'${u}'") httpsEcho);
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.sovran_systemsOS.publicIP = {
|
||||||
|
stunServer = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "stun.l.google.com";
|
||||||
|
description = ''
|
||||||
|
STUN server used to discover the public IP over UDP. STUN is the most
|
||||||
|
privacy-preserving detection mechanism: a single stateless packet,
|
||||||
|
no HTTP metadata. Only used when the cache is stale.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
stunPort = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 19302;
|
||||||
|
};
|
||||||
|
dnsResolver = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "resolver4.opendns.com";
|
||||||
|
description = ''
|
||||||
|
DNS resolver used as fallback (myip.opendns.com trick) when STUN is
|
||||||
|
unavailable (e.g. ISP blocks UDP egress). A single DNS query, no
|
||||||
|
HTTP headers.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
httpsEcho = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [ "https://api.ipify.org" ];
|
||||||
|
description = ''
|
||||||
|
OPT-IN HTTPS endpoints that return the caller's public IP as a bare
|
||||||
|
IPv4 literal. Each listed endpoint observes this server's public IP
|
||||||
|
and HTTP metadata every time detection runs. Empty by default — no
|
||||||
|
HTTPS echo service is ever contacted unless you add one here. This is
|
||||||
|
the last-resort fallback after STUN and DNS.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
cacheTTL = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 300;
|
||||||
|
description = "Seconds the detected public IP is cached before re-detection.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── Install the unified detector ──────────────────────────────────────────
|
||||||
|
# This module declares `options` above, so ALL configuration must go under
|
||||||
|
# the `config` attribute: NixOS forbids mixing bare top-level settings
|
||||||
|
# (like `system.*`) with the `options`/`config` keyword attributes in the
|
||||||
|
# same module. (Fixes: "Module ... has an unsupported attribute `system'".)
|
||||||
|
config.system.activationScripts.sovranPublicIpInstall = lib.stringAfter [ "users" ] ''
|
||||||
|
install -d -m 0755 /var/lib/sovran
|
||||||
|
cat > /var/lib/sovran/public-ip.py <<'PYEOF'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""sovran-public-ip — one detector, one cache, every consumer reads the same IP.
|
||||||
|
|
||||||
|
Privacy-first detection chain (first success wins):
|
||||||
|
1. pin — baked in from sovran_systemsOS.elementCalling.externalIP
|
||||||
|
2. cache — /var/lib/secrets/external-ip if newer than CACHE_TTL seconds
|
||||||
|
3. STUN — UDP binding request (one packet, no application data)
|
||||||
|
4. DNS — myip.opendns.com A query via the configured resolver
|
||||||
|
5. HTTPS — ONLY endpoints baked in from publicIP.httpsEcho (opt-in)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
public-ip.py check print current public IP (cache first; refresh if stale)
|
||||||
|
public-ip.py refresh force re-detection, update the cache file, print IP
|
||||||
|
|
||||||
|
Exit status: 0 with the IP on stdout on success; 1 if no IP is available
|
||||||
|
(cached value, if any, is still printed to stdout with a warning on stderr).
|
||||||
|
"""
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
CACHE_FILE = "/var/lib/secrets/external-ip"
|
||||||
|
PIN = "${pin}"
|
||||||
|
STUN_SERVER = "${stunServer}"
|
||||||
|
STUN_PORT = ${toString stunPort}
|
||||||
|
DNS_RESOLVER = "${dnsResolver}"
|
||||||
|
DNS_HOST = "myip.opendns.com"
|
||||||
|
ECHO_URLS = [ ${echoList} ]
|
||||||
|
CACHE_TTL = ${toString cacheTTL}
|
||||||
|
TIMEOUT = 3.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Detection primitives
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def is_usable_ip(text: str) -> bool:
|
||||||
|
"""True if text is a globally routable IPv4 that LiveKit may advertise."""
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(text)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if ip.version != 4:
|
||||||
|
return False
|
||||||
|
if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast
|
||||||
|
or ip.is_reserved or ip.is_unspecified or not ip.is_global):
|
||||||
|
return False
|
||||||
|
# RFC 6598 shared (CGNAT) space — not reachable from the internet.
|
||||||
|
if ip in ipaddress.ip_network("100.64.0.0/10"):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def stun_public_ip() -> str | None:
|
||||||
|
"""RFC 5389 Binding request over UDP; returns the mapped (public) IPv4."""
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.settimeout(TIMEOUT)
|
||||||
|
try:
|
||||||
|
txid = random.randbytes(12)
|
||||||
|
req = struct.pack("!HHI", 0x0001, 0, 0) + txid # Binding request
|
||||||
|
sock.sendto(req, (STUN_SERVER, STUN_PORT))
|
||||||
|
data, _ = sock.recvfrom(2048)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
if len(data) < 20:
|
||||||
|
return None
|
||||||
|
mtype, _mlen = struct.unpack("!HH", data[:4])
|
||||||
|
if mtype != 0x0101: # Binding success response
|
||||||
|
return None
|
||||||
|
|
||||||
|
cookie = data[4:8]
|
||||||
|
i = 20
|
||||||
|
while i + 4 <= len(data):
|
||||||
|
atype, alen = struct.unpack("!HH", data[i : i + 4])
|
||||||
|
aval = data[i + 4 : i + 4 + alen]
|
||||||
|
if atype in (0x0001, 0x0020) and len(aval) >= 8: # MAPPED / XOR-MAPPED
|
||||||
|
family = aval[1]
|
||||||
|
if family == 0x01: # IPv4
|
||||||
|
raw = aval[4:8]
|
||||||
|
if atype == 0x0020: # XOR with magic cookie + txid prefix
|
||||||
|
raw = bytes(b ^ c for b, c in zip(raw, cookie + txid[:4]))
|
||||||
|
return socket.inet_ntop(socket.AF_INET, raw)
|
||||||
|
i += 4 + ((alen + 3) // 4) * 4
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def dns_public_ip() -> str | None:
|
||||||
|
"""Minimal DNS A query for myip.opendns.com against the given resolver."""
|
||||||
|
qid = random.randint(0, 0xFFFF)
|
||||||
|
qname = b"".join(bytes([len(p)]) + p.encode() for p in DNS_HOST.split(".")) + b"\x00"
|
||||||
|
query = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0) + qname + struct.pack("!HH", 1, 1)
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.settimeout(TIMEOUT)
|
||||||
|
try:
|
||||||
|
sock.sendto(query, (DNS_RESOLVER, 53))
|
||||||
|
data, _ = sock.recvfrom(4096)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if len(data) < 12:
|
||||||
|
return None
|
||||||
|
rid, _flags, _qd, an, _ns, _ar = struct.unpack("!HHHHHH", data[:12])
|
||||||
|
if rid != qid or an == 0:
|
||||||
|
return None
|
||||||
|
i = 12
|
||||||
|
for _ in range(_qd): # skip question
|
||||||
|
while data[i] != 0:
|
||||||
|
i += 1 + data[i]
|
||||||
|
i += 5
|
||||||
|
for _ in range(an):
|
||||||
|
if data[i] & 0xC0 == 0xC0:
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
while data[i] != 0:
|
||||||
|
i += 1 + data[i]
|
||||||
|
i += 1
|
||||||
|
rtype, _rclass, _ttl, rdlen = struct.unpack("!HHIH", data[i : i + 10])
|
||||||
|
i += 10
|
||||||
|
if rtype == 1 and rdlen == 4:
|
||||||
|
return socket.inet_ntop(socket.AF_INET, data[i : i + 4])
|
||||||
|
i += rdlen
|
||||||
|
except (IndexError, struct.error):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def echo_public_ip() -> str | None:
|
||||||
|
"""Opt-in HTTPS echo endpoints (baked in at build time; empty by default)."""
|
||||||
|
for url in ECHO_URLS:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "sovran-public-ip"})
|
||||||
|
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
||||||
|
text = resp.read().decode().strip()
|
||||||
|
if is_usable_ip(text):
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cache handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def read_cache() -> str:
|
||||||
|
try:
|
||||||
|
with open(CACHE_FILE) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def write_cache(ip: str) -> None:
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
|
||||||
|
tmp = f"{CACHE_FILE}.tmp"
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
f.write(ip + "\n")
|
||||||
|
os.replace(tmp, CACHE_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def cache_fresh() -> bool:
|
||||||
|
try:
|
||||||
|
return time.time() - os.path.getmtime(CACHE_FILE) < CACHE_TTL
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def detect() -> str:
|
||||||
|
"""Run the chain; returns usable IP or an empty string."""
|
||||||
|
if PIN and is_usable_ip(PIN):
|
||||||
|
return PIN
|
||||||
|
for fn in (stun_public_ip, dns_public_ip, echo_public_ip):
|
||||||
|
try:
|
||||||
|
cand = fn()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if cand and is_usable_ip(cand):
|
||||||
|
return cand
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
force = len(sys.argv) > 1 and sys.argv[1] == "refresh"
|
||||||
|
ip = ""
|
||||||
|
if not force and cache_fresh():
|
||||||
|
ip = read_cache()
|
||||||
|
if not ip:
|
||||||
|
ip = detect()
|
||||||
|
if ip:
|
||||||
|
write_cache(ip)
|
||||||
|
else:
|
||||||
|
stale = read_cache()
|
||||||
|
if stale:
|
||||||
|
print(stale)
|
||||||
|
print("WARNING: detection failed; using last known public IP", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
print("ERROR: could not determine a public IP (STUN/DNS unreachable)", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(ip)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
PYEOF
|
||||||
|
chmod 0555 /var/lib/sovran/public-ip.py
|
||||||
|
'';
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
{
|
{
|
||||||
config = lib.mkMerge [
|
config = lib.mkMerge [
|
||||||
|
|
||||||
# Vendored nix-bitcoin is always imported via modules/vendor/nix-bitcoin/modules.nix.
|
# The Sovran_Bitcoin flake module is always imported (see flake.nix).
|
||||||
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
||||||
# without enabling any Bitcoin services.
|
# without enabling any Bitcoin services.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -79,6 +79,41 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# ── Element Calling (video/audio) tuning ──────────────────
|
||||||
|
elementCalling = {
|
||||||
|
fullAccessHomeservers = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [ "matrix.peer.example.com" ];
|
||||||
|
description = ''
|
||||||
|
Additional Matrix server_names (beyond this server itself) that may
|
||||||
|
trigger LiveKit room creation on this server's SFU via lk-jwt-service.
|
||||||
|
|
||||||
|
Not needed for the common federated setup: each participant's client
|
||||||
|
always obtains its token from its own homeserver's JWT service and
|
||||||
|
publishes to its own SFU, and the participant who starts a call
|
||||||
|
creates the room on their own SFU — the remote user merely joins
|
||||||
|
(joining does not require full access).
|
||||||
|
|
||||||
|
Only set this for asymmetric cases: e.g. a peer homeserver that has
|
||||||
|
no focus of its own, or calls whose first participant lands on this
|
||||||
|
server's SFU but belongs to the peer.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
externalIP = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "203.0.113.10";
|
||||||
|
description = ''
|
||||||
|
Optional pin: force LiveKit to advertise this public IPv4 in its
|
||||||
|
host/TURN ICE candidates. Not required in normal operation — the
|
||||||
|
module auto-detects the public IP at runtime (HTTPS egress
|
||||||
|
detection, falling back to STUN). Set it only to override a
|
||||||
|
mis-detected address (e.g. multi-WAN/VPN setups).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
# ── Domain setup registry ─────────────────────────────────
|
# ── Domain setup registry ─────────────────────────────────
|
||||||
domainRequirements = lib.mkOption {
|
domainRequirements = lib.mkOption {
|
||||||
type = lib.types.listOf (lib.types.submodule {
|
type = lib.types.listOf (lib.types.submodule {
|
||||||
|
|||||||
@@ -135,16 +135,13 @@ let
|
|||||||
"bitcoind.service" = if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0";
|
"bitcoind.service" = if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0";
|
||||||
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
|
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
|
||||||
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
||||||
# Keep the fallbacks aligned with the vendored packages used by the
|
# Vendored packages come from the Sovran_Bitcoin flake overlay
|
||||||
# service modules (RTL 0.15.8 and Mempool 3.2.1). The nixpkgs attrs are
|
# (pkgs.sovran-bitcoin.*). Evaluate them directly so the Hub shows
|
||||||
# optional because these packages are built locally in this repository.
|
# the exact versions this system ships.
|
||||||
"rtl.service" = if pkgs ? clightning-rtl then pkgs.clightning-rtl.version else (if pkgs ? rtl then pkgs.rtl.version else "0.15.8");
|
"rtl.service" = pkgs.sovran-bitcoin.rtl.version;
|
||||||
# BTCPay Server is intentionally sourced from pkgs.stable by the service
|
|
||||||
# module. Read the configured package here rather than pkgs.btcpayserver
|
|
||||||
# (unstable), otherwise the Hub can advertise a version that is not running.
|
|
||||||
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
||||||
"albyhub.service" = if pkgs ? albyhub then pkgs.albyhub.version else "1.8.0";
|
"albyhub.service" = pkgs.sovran-bitcoin.albyhub.version;
|
||||||
"mempool.service" = if pkgs ? mempool then pkgs.mempool.version else "3.2.1";
|
"mempool.service" = pkgs.sovran-bitcoin.mempool-backend.version;
|
||||||
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
||||||
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
||||||
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
||||||
@@ -160,8 +157,10 @@ let
|
|||||||
|
|
||||||
LOG="/var/log/sovran-hub-update.log"
|
LOG="/var/log/sovran-hub-update.log"
|
||||||
STATUS="/var/log/sovran-hub-update.status"
|
STATUS="/var/log/sovran-hub-update.status"
|
||||||
|
GENERATION="/var/log/sovran-hub-update.generation"
|
||||||
|
|
||||||
echo "RUNNING" > "$STATUS"
|
echo "RUNNING" > "$STATUS"
|
||||||
|
rm -f "$GENERATION"
|
||||||
: > "$LOG"
|
: > "$LOG"
|
||||||
exec > >(tee -a "$LOG") 2>&1
|
exec > >(tee -a "$LOG") 2>&1
|
||||||
|
|
||||||
@@ -196,6 +195,12 @@ let
|
|||||||
if [ "$BOOT_RC" -ne 0 ]; then
|
if [ "$BOOT_RC" -ne 0 ]; then
|
||||||
echo "[ERROR] nixos-rebuild boot failed"
|
echo "[ERROR] nixos-rebuild boot failed"
|
||||||
RC=1
|
RC=1
|
||||||
|
elif ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
|
||||||
|
# The marker is informational only. The Hub derives pending-reboot
|
||||||
|
# state from the NixOS system profile itself, so failing to record
|
||||||
|
# the marker must not fail an otherwise successful update.
|
||||||
|
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
||||||
|
rm -f "$GENERATION"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|||||||
+261
-21
@@ -33,9 +33,15 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
####### ENSURE SERVICES START AFTER KEY EXISTS #######
|
####### ENSURE SERVICES START AFTER KEY & NETWORK EXIST #######
|
||||||
systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
# Ordering against network-online.target matters: livekit-turn-setup detects
|
||||||
systemd.services.livekit.wants = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
# the primary interface from the IPv4 default route. If it runs before the
|
||||||
|
# network is up (no default route yet) it exits 1 and, being a hard
|
||||||
|
# dependency of livekit.service, takes livekit down with it — the Hub then
|
||||||
|
# shows a "failed" red dot until livekit is restarted manually. See the
|
||||||
|
# livekit-turn-setup block for the matching network-online ordering.
|
||||||
|
systemd.services.livekit.after = [ "network-online.target" "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||||
|
systemd.services.livekit.wants = [ "network-online.target" "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||||
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
|
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
|
||||||
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
|
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
|
||||||
|
|
||||||
@@ -114,7 +120,12 @@ EOF
|
|||||||
# substituted) that the overridden ExecStart loads.
|
# substituted) that the overridden ExecStart loads.
|
||||||
systemd.services.livekit-turn-setup = {
|
systemd.services.livekit-turn-setup = {
|
||||||
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
|
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
|
||||||
after = [ "caddy.service" "livekit-key-setup.service" ];
|
# Wait for a default IPv4 route before detecting the interface, and for
|
||||||
|
# Caddy to have started (cert generation is async, so also see the retry
|
||||||
|
# loop below). Otherwise on a cold boot this unit can fail / produce empty
|
||||||
|
# certs, which breaks livekit.service (requiredBy) and shows a red dot.
|
||||||
|
after = [ "network-online.target" "caddy.service" "livekit-key-setup.service" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
before = [ "livekit.service" ];
|
before = [ "livekit.service" ];
|
||||||
requiredBy = [ "livekit.service" ];
|
requiredBy = [ "livekit.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
@@ -125,19 +136,39 @@ EOF
|
|||||||
unitConfig = {
|
unitConfig = {
|
||||||
ConditionPathExists = "/var/lib/domains/element-calling";
|
ConditionPathExists = "/var/lib/domains/element-calling";
|
||||||
};
|
};
|
||||||
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ];
|
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk pkgs.python3 ];
|
||||||
script = ''
|
script = ''
|
||||||
MATRIX=$(cat /var/lib/domains/matrix)
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
|
|
||||||
mkdir -p /run/livekit
|
mkdir -p /run/livekit
|
||||||
|
|
||||||
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir.
|
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir.
|
||||||
# The ACME CA hostname directory can vary, so glob for the domain dir.
|
# The ACME CA hostname directory can vary, so glob for the domain dir.
|
||||||
|
# Caddy issues ACME certs asynchronously, so on a fresh boot the cert may
|
||||||
|
# not exist yet. Retry (bounded) so we never write an empty turn.crt/key;
|
||||||
|
# otherwise embedded TURN silently breaks until the next livekit restart.
|
||||||
|
CRT=""
|
||||||
|
KEY=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
|
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
|
||||||
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
|
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
|
||||||
|
if [ -n "$CRT" ] && [ -n "$KEY" ] \
|
||||||
|
&& [ -s "$CRT" ] && [ -s "$KEY" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
CRT=""
|
||||||
|
KEY=""
|
||||||
|
echo "Waiting for Caddy to issue the $MATRIX ACME certificate..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ -z "$CRT" ] || [ -z "$KEY" ]; then
|
||||||
|
echo "ERROR: Caddy ACME certificate for $MATRIX not available after retries; TURN will not be enabled for LiveKit." >&2
|
||||||
|
else
|
||||||
cp "$CRT" /var/lib/livekit/turn.crt
|
cp "$CRT" /var/lib/livekit/turn.crt
|
||||||
cp "$KEY" /var/lib/livekit/turn.key
|
cp "$KEY" /var/lib/livekit/turn.key
|
||||||
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
|
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
|
||||||
|
fi
|
||||||
|
|
||||||
# Detect the primary network interface from the IPv4 default route.
|
# Detect the primary network interface from the IPv4 default route.
|
||||||
# Restricting LiveKit to this single interface prevents it from
|
# Restricting LiveKit to this single interface prevents it from
|
||||||
@@ -158,6 +189,56 @@ EOF
|
|||||||
# rtc.interfaces.includes are only known at runtime, so they are
|
# rtc.interfaces.includes are only known at runtime, so they are
|
||||||
# substituted here. The cert/key paths point at the LoadCredential-staged
|
# substituted here. The cert/key paths point at the LoadCredential-staged
|
||||||
# copies under /run/credentials.
|
# copies under /run/credentials.
|
||||||
|
#
|
||||||
|
# Determine the public IPv4 to advertise in LiveKit ICE candidates.
|
||||||
|
# Remote peers must be able to reach this address, so it must be the
|
||||||
|
# server's public IP — or the router's WAN IP when the server is behind
|
||||||
|
# NAT with port-forwarding. It does not need to be assigned to this box,
|
||||||
|
# and it may be dynamic.
|
||||||
|
#
|
||||||
|
# Reuse the shared detector (/var/lib/sovran/public-ip.py — see
|
||||||
|
# modules/core/public-ip.nix) instead of running our own: one script,
|
||||||
|
# one cache, privacy-first (STUN -> DNS -> opt-in HTTPS echo). Priority:
|
||||||
|
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
||||||
|
# 2. /var/lib/secrets/external-ip (the shared cache)
|
||||||
|
# 3. run the detector now (it refreshes the cache)
|
||||||
|
# 4. STUN auto-detection (use_external_ip) as the fallback, with a
|
||||||
|
# warning — this is where broken installs used to silently end up
|
||||||
|
# advertising a private IP, causing "call connects but no video".
|
||||||
|
EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}'
|
||||||
|
|
||||||
|
PUBLIC_IP="$EXTERNAL_IP"
|
||||||
|
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
||||||
|
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
||||||
|
fi
|
||||||
|
if [ -z "$PUBLIC_IP" ] && [ -x /var/lib/sovran/public-ip.py ]; then
|
||||||
|
PUBLIC_IP=$(python3 /var/lib/sovran/public-ip.py check 2>/dev/null | head -n1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
||||||
|
# A detected/pinned address like this must never be advertised.
|
||||||
|
if [ -n "$PUBLIC_IP" ] && printf '%s' "$PUBLIC_IP" | grep -qE \
|
||||||
|
'^(0\.|127\.|10\.|100\.64\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
|
||||||
|
echo "WARNING: external IP '$PUBLIC_IP' is not routable; falling back to STUN auto-detection." >&2
|
||||||
|
PUBLIC_IP=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$PUBLIC_IP" ]; then
|
||||||
|
cat > /run/livekit/livekit.yaml <<EOF
|
||||||
|
port: 7880
|
||||||
|
rtc:
|
||||||
|
use_external_ip: false
|
||||||
|
node_ip: $PUBLIC_IP
|
||||||
|
tcp_port: 7881
|
||||||
|
udp_port: 7882
|
||||||
|
port_range_start: 30000
|
||||||
|
port_range_end: 40000
|
||||||
|
interfaces:
|
||||||
|
includes:
|
||||||
|
- $IFACE
|
||||||
|
EOF
|
||||||
|
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
||||||
|
else
|
||||||
cat > /run/livekit/livekit.yaml <<EOF
|
cat > /run/livekit/livekit.yaml <<EOF
|
||||||
port: 7880
|
port: 7880
|
||||||
rtc:
|
rtc:
|
||||||
@@ -170,6 +251,20 @@ rtc:
|
|||||||
interfaces:
|
interfaces:
|
||||||
includes:
|
includes:
|
||||||
- $IFACE
|
- $IFACE
|
||||||
|
EOF
|
||||||
|
echo "WARNING: could not determine a public IP for LiveKit; using STUN auto-detection. If calls connect without media, check STUN egress or set sovran_systemsOS.elementCalling.externalIP." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
||||||
|
# signature against the same key file it issues tokens with, and uses
|
||||||
|
# the events (participant_left / room_finished) to detect abruptly
|
||||||
|
# disconnected participants instead of waiting for the delayed-event
|
||||||
|
# timeout. The URL hits local Caddy via the /etc/hosts loopback
|
||||||
|
# override and is routed to the JWT service by the element-calling
|
||||||
|
# vhost (/livekit/jwt/sfu_webhook → 8073).
|
||||||
|
LK_KEY=$(cut -d: -f1 < ${livekitKeyFile} | tr -d '[:space:]')
|
||||||
|
|
||||||
|
cat >> /run/livekit/livekit.yaml <<EOF
|
||||||
room:
|
room:
|
||||||
auto_create: false
|
auto_create: false
|
||||||
turn:
|
turn:
|
||||||
@@ -179,6 +274,10 @@ turn:
|
|||||||
udp_port: 3478
|
udp_port: 3478
|
||||||
cert_file: /run/credentials/livekit.service/turn-cert
|
cert_file: /run/credentials/livekit.service/turn-cert
|
||||||
key_file: /run/credentials/livekit.service/turn-key
|
key_file: /run/credentials/livekit.service/turn-key
|
||||||
|
webhook:
|
||||||
|
api_key: $LK_KEY
|
||||||
|
urls:
|
||||||
|
- https://$ELEMENT_CALLING/livekit/jwt/sfu_webhook
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 644 /run/livekit/livekit.yaml
|
chmod 644 /run/livekit/livekit.yaml
|
||||||
@@ -186,24 +285,17 @@ EOF
|
|||||||
};
|
};
|
||||||
|
|
||||||
####### LIVEKIT SERVICE #######
|
####### LIVEKIT SERVICE #######
|
||||||
|
# NOTE: the runtime config (rtc ports, TURN, webhook, node_ip) is generated
|
||||||
|
# by livekit-turn-setup and delivered via LoadCredential; the upstream
|
||||||
|
# module's `settings` block is therefore intentionally NOT used (it would
|
||||||
|
# be dead config that silently diverges from what LiveKit actually loads).
|
||||||
|
# The firewall ports are opened explicitly below; openFirewall is left off
|
||||||
|
# so the upstream module does not also open 7880/tcp publicly (Caddy fronts
|
||||||
|
# the SFU on this host).
|
||||||
services.livekit = {
|
services.livekit = {
|
||||||
enable = true;
|
enable = true;
|
||||||
openFirewall = true;
|
openFirewall = false;
|
||||||
keyFile = livekitKeyFile;
|
keyFile = livekitKeyFile;
|
||||||
settings = {
|
|
||||||
rtc.use_external_ip = true;
|
|
||||||
rtc.skip_external_ip_validation = true;
|
|
||||||
rtc.tcp_port = 7881;
|
|
||||||
rtc.udp_port = 7882;
|
|
||||||
rtc.port_range_start = 30000;
|
|
||||||
rtc.port_range_end = 40000;
|
|
||||||
room.auto_create = false;
|
|
||||||
turn = {
|
|
||||||
enabled = true;
|
|
||||||
tls_port = 5349;
|
|
||||||
udp_port = 3478;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
# Override ExecStart to load the runtime-generated config (which carries the
|
# Override ExecStart to load the runtime-generated config (which carries the
|
||||||
@@ -247,12 +339,26 @@ EOF
|
|||||||
script = ''
|
script = ''
|
||||||
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
MATRIX=$(cat /var/lib/domains/matrix)
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
FULL_ACCESS_HOMESERVERS="$MATRIX"
|
||||||
|
|
||||||
|
# Federated peers may also be granted LiveKit room-creation (full access)
|
||||||
|
# on this SFU via sovran_systemsOS.elementCalling.fullAccessHomeservers.
|
||||||
|
# Without this, remote users can join existing calls but cannot be the
|
||||||
|
# first to start one on your SFU.
|
||||||
|
EXTRA_HS='${lib.concatStringsSep "," config.sovran_systemsOS.elementCalling.fullAccessHomeservers}'
|
||||||
|
if [ -n "$EXTRA_HS" ]; then
|
||||||
|
FULL_ACCESS_HOMESERVERS="$FULL_ACCESS_HOMESERVERS,$EXTRA_HS"
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p /run/lk-jwt-service
|
mkdir -p /run/lk-jwt-service
|
||||||
|
|
||||||
cat > /run/lk-jwt-service/env <<EOF
|
cat > /run/lk-jwt-service/env <<EOF
|
||||||
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
||||||
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX
|
LIVEKIT_FULL_ACCESS_HOMESERVERS=$FULL_ACCESS_HOMESERVERS
|
||||||
|
# Re-check, every 60s, that connected participants are still on the SFU;
|
||||||
|
# guards against missed SFU webhooks (e.g. an SFU restart) leaving stale
|
||||||
|
# call members in Matrix rooms.
|
||||||
|
LIVEKIT_SANITY_CHECK_INTERVAL_SECONDS=60
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 640 /run/lk-jwt-service/env
|
chmod 640 /run/lk-jwt-service/env
|
||||||
@@ -264,6 +370,9 @@ EOF
|
|||||||
enable = true;
|
enable = true;
|
||||||
port = 8073;
|
port = 8073;
|
||||||
keyFile = livekitKeyFile;
|
keyFile = livekitKeyFile;
|
||||||
|
# Required by the upstream module's option type, but overridden at runtime
|
||||||
|
# by EnvironmentFile (/run/lk-jwt-service/env, generated above from the
|
||||||
|
# element-calling domain). Kept as a harmless placeholder.
|
||||||
livekitUrl = "wss://placeholder.local";
|
livekitUrl = "wss://placeholder.local";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,6 +380,126 @@ EOF
|
|||||||
"/run/lk-jwt-service/env"
|
"/run/lk-jwt-service/env"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
|
||||||
|
# configs (new domains, externalIP, full-access list), mirroring the domain
|
||||||
|
# change flow.
|
||||||
|
# Re-run the config generator and restart LiveKit when a rebuild regenerates
|
||||||
|
# the runtime config, or when the Hub persists a new external IP (dynamic
|
||||||
|
# WAN IPs), so the advertised ICE candidate stays current without a manual
|
||||||
|
# restart. The trigger chain: external-ip change → livekit-turn-setup
|
||||||
|
# re-runs → rewrites livekit.yaml → livekit restarts with the new config.
|
||||||
|
systemd.services.livekit-turn-setup.restartTriggers = [ "/var/lib/secrets/external-ip" ];
|
||||||
|
systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
|
||||||
|
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
|
||||||
|
|
||||||
|
####### PUBLIC REACHABILITY SELF-CHECK #######
|
||||||
|
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
||||||
|
# classic "call connects but no media" setup errors at boot instead of at
|
||||||
|
# call time:
|
||||||
|
# * the element-calling domain having no public records (or resolving to
|
||||||
|
# loopback/link-local/CGNAT for remote peers),
|
||||||
|
# * the lk-jwt-service being unreachable through Caddy,
|
||||||
|
# * the MatrixRTC transports endpoint being absent (Element X cannot
|
||||||
|
# discover calling and shows MISSING_MATRIX_RTC_TRANSPORT).
|
||||||
|
# The check queries only the operator's own DNS provider (the domain's
|
||||||
|
# authoritative nameservers, resolved via the local resolver) plus the
|
||||||
|
# server's own Caddy and public IP — no third-party resolver or service is
|
||||||
|
# contacted. dig queries resolvers directly, so the /etc/hosts loopback
|
||||||
|
# overrides (modules/core/local-domain-loopback.nix) do not influence the
|
||||||
|
# result.
|
||||||
|
systemd.services.element-calling-public-check = {
|
||||||
|
description = "Verify Element Calling domain, JWT service and MatrixRTC transports endpoint are publicly reachable";
|
||||||
|
after = [ "network-online.target" "caddy.service" "livekit.service" "lk-jwt-service.service" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
};
|
||||||
|
unitConfig = {
|
||||||
|
ConditionPathExists = "/var/lib/domains/element-calling";
|
||||||
|
};
|
||||||
|
path = [ pkgs.coreutils pkgs.gawk pkgs.dnsutils pkgs.curl ];
|
||||||
|
script = ''
|
||||||
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
echo "── Element Calling public reachability self-check ──"
|
||||||
|
|
||||||
|
# 1) Authoritative DNS view (bypassing the /etc/hosts loopback
|
||||||
|
# overrides). Resolve the domain's own nameservers via the local
|
||||||
|
# resolver, then query those nameservers directly — the only party
|
||||||
|
# that sees the query is the DNS provider the operator already uses
|
||||||
|
# for the domain.
|
||||||
|
NS_LIST=$(dig +short NS "$ELEMENT_CALLING" 2>/dev/null | tr '\n' ' ')
|
||||||
|
if [ -n "$NS_LIST" ]; then
|
||||||
|
IPS=""
|
||||||
|
for NSRV in $NS_LIST; do
|
||||||
|
IPS=$( { dig +short A "$ELEMENT_CALLING" "@$NSRV" 2>/dev/null; dig +short AAAA "$ELEMENT_CALLING" "@$NSRV" 2>/dev/null; } | tr '\n' ' ' )
|
||||||
|
[ -n "$IPS" ] && break
|
||||||
|
done
|
||||||
|
echo "Authoritative nameservers for $ELEMENT_CALLING: $NS_LIST"
|
||||||
|
else
|
||||||
|
echo "WARNING: could not resolve nameservers for $ELEMENT_CALLING via the local resolver; using the local resolver's answer instead." >&2
|
||||||
|
IPS=$( { dig +short A "$ELEMENT_CALLING" 2>/dev/null; dig +short AAAA "$ELEMENT_CALLING" 2>/dev/null; } | tr '\n' ' ' )
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$IPS" ]; then
|
||||||
|
echo "ERROR: no A/AAAA records for $ELEMENT_CALLING at its authoritative nameservers. Remote peers cannot reach this LiveKit; calls will connect without media." >&2
|
||||||
|
FAIL=1
|
||||||
|
else
|
||||||
|
echo "Public DNS for $ELEMENT_CALLING: $IPS"
|
||||||
|
for IP in $IPS; do
|
||||||
|
case "$IP" in
|
||||||
|
0.*|127.*|169.254.*|100.64.*|::1|fe80:*|fc*:*|fd*:*)
|
||||||
|
echo "ERROR: $ELEMENT_CALLING resolves to $IP (loopback/link-local/CGNAT). Remote peers cannot reach it." >&2
|
||||||
|
FAIL=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) lk-jwt-service healthz through Caddy (validates the proxy chain).
|
||||||
|
if curl -fsS --max-time 10 "https://$ELEMENT_CALLING/livekit/jwt/healthz" >/dev/null 2>&1; then
|
||||||
|
echo "OK: https://$ELEMENT_CALLING/livekit/jwt/healthz responds"
|
||||||
|
else
|
||||||
|
echo "ERROR: https://$ELEMENT_CALLING/livekit/jwt/healthz not reachable through Caddy." >&2
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3) Same healthz via the first public IP (tests the full NAT path).
|
||||||
|
# NOTE: if this box is behind the same NAT you are testing through,
|
||||||
|
# routers without hairpin NAT will fail this step — the warning is
|
||||||
|
# then expected and harmless; verify from an external device instead.
|
||||||
|
if [ -n "$IPS" ]; then
|
||||||
|
PUBIP=$(echo "$IPS" | awk '{print $1}')
|
||||||
|
if curl -fsS --max-time 15 --resolve "$ELEMENT_CALLING:443:$PUBIP" "https://$ELEMENT_CALLING/livekit/jwt/healthz" >/dev/null 2>&1; then
|
||||||
|
echo "OK: healthz reachable via public IP $PUBIP (NAT path works)"
|
||||||
|
else
|
||||||
|
echo "WARNING: healthz NOT reachable via public IP $PUBIP — check router port-forwarding (443/TCP) and NAT hairpin. Expected if the router lacks hairpin NAT; verify from an external device." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4) MatrixRTC transports registry (MSC4519) — required by Element X.
|
||||||
|
CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "https://$MATRIX/_matrix/client/unstable/org.matrix.msc4143/rtc/transports")
|
||||||
|
case "$CODE" in
|
||||||
|
401|200)
|
||||||
|
echo "OK: MatrixRTC transports endpoint present (HTTP $CODE; auth required is expected)" ;;
|
||||||
|
404)
|
||||||
|
echo "ERROR: /_matrix/client/unstable/org.matrix.msc4143/rtc/transports missing (HTTP 404) — Element X cannot discover calling. Enable msc4143_enabled and matrix_rtc.transports in Synapse." >&2
|
||||||
|
FAIL=1 ;;
|
||||||
|
*)
|
||||||
|
echo "WARNING: transports endpoint returned HTTP $CODE" >&2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$FAIL" -eq 1 ]; then
|
||||||
|
echo "── Element Calling self-check FAILED — see errors above ──" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "── Element Calling self-check passed ──"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
####### SYNAPSE RUNTIME CONFIG (element-calling additions) #######
|
####### SYNAPSE RUNTIME CONFIG (element-calling additions) #######
|
||||||
systemd.services.element-calling-synapse-config = {
|
systemd.services.element-calling-synapse-config = {
|
||||||
description = "Generate Synapse runtime config for Element Calling";
|
description = "Generate Synapse runtime config for Element Calling";
|
||||||
@@ -287,6 +516,7 @@ EOF
|
|||||||
path = [ pkgs.coreutils ];
|
path = [ pkgs.coreutils ];
|
||||||
script = ''
|
script = ''
|
||||||
MATRIX=$(cat /var/lib/domains/matrix)
|
MATRIX=$(cat /var/lib/domains/matrix)
|
||||||
|
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||||
|
|
||||||
mkdir -p /run/matrix-synapse
|
mkdir -p /run/matrix-synapse
|
||||||
|
|
||||||
@@ -296,7 +526,17 @@ public_baseurl: "https://$MATRIX"
|
|||||||
serve_server_wellknown: true
|
serve_server_wellknown: true
|
||||||
experimental_features:
|
experimental_features:
|
||||||
msc3266_enabled: true
|
msc3266_enabled: true
|
||||||
|
# MSC4143: enables the MatrixRTC transports registry endpoint
|
||||||
|
# (/_matrix/client/unstable/org.matrix.msc4143/rtc/transports, MSC4519).
|
||||||
|
# Element X requires this endpoint to discover the LiveKit focus; without it
|
||||||
|
# mobile clients fail with MISSING_MATRIX_RTC_TRANSPORT / cannot start calls.
|
||||||
|
msc4143_enabled: true
|
||||||
msc4222_enabled: true
|
msc4222_enabled: true
|
||||||
|
# MSC4519: advertise this site's LiveKit focus via the transports registry.
|
||||||
|
matrix_rtc:
|
||||||
|
transports:
|
||||||
|
- type: livekit
|
||||||
|
livekit_service_url: "https://$ELEMENT_CALLING/livekit/jwt"
|
||||||
max_event_delay_duration: "24h"
|
max_event_delay_duration: "24h"
|
||||||
rc_message:
|
rc_message:
|
||||||
per_second: 0.5
|
per_second: 0.5
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
}
|
|
||||||
+1
-3
@@ -17,6 +17,7 @@
|
|||||||
./core/no-sleep.nix
|
./core/no-sleep.nix
|
||||||
./core/cpu-performance.nix
|
./core/cpu-performance.nix
|
||||||
./core/local-domain-loopback.nix
|
./core/local-domain-loopback.nix
|
||||||
|
./core/public-ip.nix
|
||||||
|
|
||||||
# ── Always on (no flag) ───────────────────────────────────
|
# ── Always on (no flag) ───────────────────────────────────
|
||||||
./php.nix
|
./php.nix
|
||||||
@@ -27,14 +28,11 @@
|
|||||||
./wordpress.nix
|
./wordpress.nix
|
||||||
./nextcloud.nix
|
./nextcloud.nix
|
||||||
./vaultwarden.nix
|
./vaultwarden.nix
|
||||||
./bitcoinecosystem.nix
|
|
||||||
./wallet-autoconnect.nix
|
./wallet-autoconnect.nix
|
||||||
|
|
||||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||||
./haven.nix
|
./haven.nix
|
||||||
./nwc-wallets.nix
|
|
||||||
./element-calling.nix
|
./element-calling.nix
|
||||||
./mempool.nix
|
|
||||||
./rdp.nix
|
./rdp.nix
|
||||||
./sshd.nix
|
./sshd.nix
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,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;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -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; }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,29 +47,44 @@ EOF
|
|||||||
systemd.services.zeus-connect-setup = {
|
systemd.services.zeus-connect-setup = {
|
||||||
description = "Save Zeus lndconnect URL";
|
description = "Save Zeus lndconnect URL";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
after = [ "lnd.service" ];
|
after = [ "lnd.service" "onion-addresses.service" ];
|
||||||
|
wants = [ "lnd.service" "onion-addresses.service" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
};
|
};
|
||||||
path = [ pkgs.coreutils "/run/current-system/sw" ];
|
# sudo is required: the lndconnect wrapper re-execs as the lnd user so it
|
||||||
|
# can read admin.macaroon (not group-readable).
|
||||||
|
path = [ pkgs.coreutils pkgs.gnugrep pkgs.sudo "/run/current-system/sw" ];
|
||||||
script = ''
|
script = ''
|
||||||
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
|
SECRET_FILE="/var/lib/secrets/zeus-connect-url"
|
||||||
mkdir -p /var/lib/secrets
|
mkdir -p /var/lib/secrets
|
||||||
|
|
||||||
|
# LND may still be creating the wallet / macaroon, and the dedicated
|
||||||
|
# lnd-rest onion hostname is published by onion-addresses.service.
|
||||||
URL=""
|
URL=""
|
||||||
|
ATTEMPTS=0
|
||||||
|
while [ "$ATTEMPTS" -lt 60 ]; do
|
||||||
if command -v lndconnect >/dev/null 2>&1; then
|
if command -v lndconnect >/dev/null 2>&1; then
|
||||||
URL=$(lndconnect --url 2>/dev/null || true)
|
URL=$(lndconnect --url 2>/dev/null | tr -d '\r' | tail -n 1 || true)
|
||||||
elif command -v lnconnect-clnrest >/dev/null 2>&1; then
|
|
||||||
URL=$(lnconnect-clnrest --url 2>/dev/null || true)
|
|
||||||
fi
|
fi
|
||||||
|
# Zeus LND REST over Tor: lndconnect://<v3-onion>:8080?macaroon=...
|
||||||
|
if echo "$URL" | grep -q '^lndconnect://' \
|
||||||
|
&& echo "$URL" | grep -q '\.onion' \
|
||||||
|
&& echo "$URL" | grep -q 'macaroon='; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
URL=""
|
||||||
|
ATTEMPTS=$((ATTEMPTS + 1))
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
if [ -n "$URL" ]; then
|
if [ -n "$URL" ]; then
|
||||||
echo "$URL" > "$SECRET_FILE"
|
printf '%s\n' "$URL" > "$SECRET_FILE"
|
||||||
chmod 600 "$SECRET_FILE"
|
chmod 600 "$SECRET_FILE"
|
||||||
echo "Zeus connect URL saved."
|
echo "Zeus connect URL saved."
|
||||||
else
|
else
|
||||||
echo "No lndconnect URL available yet."
|
echo "No valid lndconnect URL available yet."
|
||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,19 +0,0 @@
|
|||||||
{ lib, buildGoModule, fetchFromGitHub }:
|
|
||||||
|
|
||||||
buildGoModule rec {
|
|
||||||
pname = "lndinit";
|
|
||||||
version = "0.1.3-beta";
|
|
||||||
src = fetchFromGitHub {
|
|
||||||
owner = "lightninglabs";
|
|
||||||
repo = pname;
|
|
||||||
rev = "v${version}";
|
|
||||||
sha256 = "sha256-sO1DpbppCurxr9g9nUl9Vx82FJK1mTcUw3rY1Fm1wEU=";
|
|
||||||
};
|
|
||||||
vendorHash = "sha256-El44BS5Bu0K/klMxkajciU/R6uqiXBMOiLN536QztbE=";
|
|
||||||
subPackages = [ "." ];
|
|
||||||
meta = with lib; {
|
|
||||||
description = "Wallet initializer for lnd (from nix-bitcoin)";
|
|
||||||
homepage = "https://github.com/lightninglabs/lndinit";
|
|
||||||
license = licenses.mit;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
From e4b3ebaf0451c1bddbd7dcf8527c296938ebb607 Mon Sep 17 00:00:00 2001
|
|
||||||
From: Erik Arvstedt <erik.arvstedt@gmail.com>
|
|
||||||
Date: Sun, 1 Jun 2025 11:17:22 +0200
|
|
||||||
Subject: [PATCH] allow disabling mining pool fetching in offline environments
|
|
||||||
|
|
||||||
Previously, Mempool strictly required fetching mining pool data from
|
|
||||||
Github and failed when this was not possible, e.g. in offline
|
|
||||||
environments.
|
|
||||||
|
|
||||||
This patch allows disabling pool fetching.
|
|
||||||
When disabled, empty pool data is inserted into the DB, which
|
|
||||||
effectively turns off block pool classification.
|
|
||||||
---
|
|
||||||
backend/src/tasks/pools-updater.ts | 5 ++++-
|
|
||||||
1 file changed, 4 insertions(+), 1 deletion(-)
|
|
||||||
|
|
||||||
diff --git a/backend/src/tasks/pools-updater.ts b/backend/src/tasks/pools-updater.ts
|
|
||||||
index 6b0520dfc..a74259b95 100644
|
|
||||||
--- a/backend/src/tasks/pools-updater.ts
|
|
||||||
+++ b/backend/src/tasks/pools-updater.ts
|
|
||||||
@@ -75,7 +75,7 @@ class PoolsUpdater {
|
|
||||||
} else {
|
|
||||||
logger.warn(`pools-v2.json is outdated, fetching latest from ${this.poolsUrl} over ${network}`, this.tag);
|
|
||||||
}
|
|
||||||
- const poolsJson = await this.query(this.poolsUrl);
|
|
||||||
+ const poolsJson = (githubSha == "disable-pool-fetching") ? [] : await this.query(this.poolsUrl);
|
|
||||||
if (poolsJson === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
@@ -136,6 +136,9 @@ class PoolsUpdater {
|
|
||||||
* Fetch our latest pools-v2.json sha from github
|
|
||||||
*/
|
|
||||||
private async fetchPoolsSha(): Promise<string | null> {
|
|
||||||
+ if (this.poolsUrl == "disable-pool-fetching") {
|
|
||||||
+ return "disable-pool-fetching";
|
|
||||||
+ }
|
|
||||||
const response = await this.query(this.treeUrl);
|
|
||||||
|
|
||||||
if (response !== undefined) {
|
|
||||||
--
|
|
||||||
2.47.2
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
|
||||||
# This local copy does not fetch or import nix-bitcoin.
|
|
||||||
{ lib
|
|
||||||
, stdenvNoCC
|
|
||||||
, nodejs_22
|
|
||||||
, nodejs-slim_22
|
|
||||||
, fetchFromGitHub
|
|
||||||
, fetchNodeModules
|
|
||||||
, runCommand
|
|
||||||
, makeWrapper
|
|
||||||
, curl
|
|
||||||
, cacert
|
|
||||||
, rsync
|
|
||||||
# for rust-gbt (backend module)
|
|
||||||
, cargo
|
|
||||||
, rustc
|
|
||||||
, rustPlatform
|
|
||||||
, napi-rs-cli
|
|
||||||
}:
|
|
||||||
rec {
|
|
||||||
nodejs = nodejs_22;
|
|
||||||
nodejsRuntime = nodejs-slim_22;
|
|
||||||
|
|
||||||
version = "3.2.1";
|
|
||||||
|
|
||||||
src = fetchFromGitHub {
|
|
||||||
owner = "mempool";
|
|
||||||
repo = "mempool";
|
|
||||||
tag = "v${version}";
|
|
||||||
hash = "sha256-O2XPD1/BXQnzuOP/vMVyRfmFZEgjA85r+PShWne0vqU=";
|
|
||||||
};
|
|
||||||
|
|
||||||
nodeModules = {
|
|
||||||
frontend = fetchNodeModules {
|
|
||||||
inherit src nodejs;
|
|
||||||
sourceRoot = "source/frontend";
|
|
||||||
hash = "sha256-+jfgsAkDdYvgso8uSHaBj/sQL3fC/ABQWzVTXfdZcU0=";
|
|
||||||
};
|
|
||||||
backend = fetchNodeModules {
|
|
||||||
inherit src nodejs;
|
|
||||||
sourceRoot = "source/backend";
|
|
||||||
hash = "sha256-y5l2SYZYK9SKSy6g0+mtTWD6JFkkdQHHBboECpEvWZ4=";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
frontendAssets = fetchFiles {
|
|
||||||
name = "mempool-frontend-assets";
|
|
||||||
hash = "sha256-r6GfOY8Pdh15o2OQMk8syfvWMV6WMCReToAEkQm7tqQ=";
|
|
||||||
fetcher = ./frontend-assets-fetch.sh;
|
|
||||||
};
|
|
||||||
|
|
||||||
mempool-backend = mkDerivationMempool {
|
|
||||||
pname = "mempool-backend";
|
|
||||||
|
|
||||||
patches = [ ./0001-allow-disabling-mining-pool-fetching.patch ];
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
cd backend
|
|
||||||
${sync} --chmod=+w ${nodeModules.backend}/lib/node_modules .
|
|
||||||
patchShebangs node_modules
|
|
||||||
|
|
||||||
${sync} ${mempool-rust-gbt}/ rust-gbt
|
|
||||||
npm run package
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/lib/mempool-backend
|
|
||||||
${sync} package/ $out/lib/mempool-backend
|
|
||||||
|
|
||||||
makeWrapper ${nodejsRuntime}/bin/node $out/bin/mempool-backend \
|
|
||||||
--add-flags $out/lib/mempool-backend/index.js
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
passthru = {
|
|
||||||
inherit nodejs nodejsRuntime;
|
|
||||||
nodeModules = nodeModules.backend;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
mempool-frontend = mkFrontend {};
|
|
||||||
|
|
||||||
# Argument `config` (type: attrset) defines the mempool frontend config.
|
|
||||||
# If `{}`, the default config is used.
|
|
||||||
# See here for available options:
|
|
||||||
# https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
|
|
||||||
# (`interface Env` and `defaultEnv`)
|
|
||||||
mkFrontend = config: mkDerivationMempool {
|
|
||||||
pname = "mempool-frontend";
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
cd frontend
|
|
||||||
|
|
||||||
${sync} --chmod=+w ${nodeModules.frontend}/lib/node_modules .
|
|
||||||
patchShebangs node_modules
|
|
||||||
|
|
||||||
# sync-assets.js is called during `npm run build` and downloads assets from the
|
|
||||||
# internet. Disable this script and instead add the assets manually after building.
|
|
||||||
: > sync-assets.js
|
|
||||||
|
|
||||||
${lib.optionalString (config != {}) ''
|
|
||||||
ln -s ${builtins.toFile "mempool-frontend-config" (builtins.toJSON config)} mempool-frontend-config.json
|
|
||||||
''}
|
|
||||||
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Add assets that would otherwise be downloaded by sync-assets.js
|
|
||||||
${sync} ${frontendAssets}/ dist/mempool/browser/resources
|
|
||||||
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
${sync} dist/mempool/browser/ $out
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
passthru = {
|
|
||||||
withConfig = mkFrontend;
|
|
||||||
assets = frontendAssets;
|
|
||||||
nodeModules = nodeModules.frontend;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
mempool-rust-gbt = stdenvNoCC.mkDerivation rec {
|
|
||||||
pname = "mempool-rust-gbt";
|
|
||||||
inherit version src meta;
|
|
||||||
|
|
||||||
sourceRoot = "source/rust/gbt";
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
rustPlatform.cargoSetupHook
|
|
||||||
cargo
|
|
||||||
rustc
|
|
||||||
napi-rs-cli
|
|
||||||
];
|
|
||||||
|
|
||||||
cargoDeps = rustPlatform.fetchCargoVendor {
|
|
||||||
inherit src;
|
|
||||||
name = "${pname}-${version}";
|
|
||||||
inherit sourceRoot;
|
|
||||||
hash = "sha256-eox/K3ipjAqNyFt87lZnxaU/okQLF/KIhqXrX86n+qw=";
|
|
||||||
};
|
|
||||||
|
|
||||||
buildPhase = ''
|
|
||||||
runHook preBuild
|
|
||||||
# napi doesn't accept an absolute path as dest dir, so we can't directly write to $out
|
|
||||||
napi build --platform --release --strip out
|
|
||||||
runHook postBuild
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
mv out $out
|
|
||||||
cp package.json $out
|
|
||||||
'';
|
|
||||||
|
|
||||||
passthru = { inherit cargoDeps; };
|
|
||||||
};
|
|
||||||
|
|
||||||
mempool-nginx-conf = runCommand "mempool-nginx-conf" {} ''
|
|
||||||
${sync} --chmod=u+w ${./nginx-conf}/ $out
|
|
||||||
${sync} ${src}/production/nginx/http-language.conf $out
|
|
||||||
'';
|
|
||||||
|
|
||||||
sync = "${rsync}/bin/rsync -a --inplace";
|
|
||||||
|
|
||||||
mkDerivationMempool = args: stdenvNoCC.mkDerivation ({
|
|
||||||
inherit version src meta;
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
makeWrapper
|
|
||||||
nodejs
|
|
||||||
rsync
|
|
||||||
];
|
|
||||||
|
|
||||||
phases = "unpackPhase patchPhase buildPhase installPhase";
|
|
||||||
} // args);
|
|
||||||
|
|
||||||
fetchFiles = { name, hash, fetcher }: stdenvNoCC.mkDerivation {
|
|
||||||
inherit name;
|
|
||||||
outputHashMode = "recursive";
|
|
||||||
outputHashAlgo = "sha256";
|
|
||||||
outputHash = hash;
|
|
||||||
nativeBuildInputs = [ curl cacert ];
|
|
||||||
buildCommand = ''
|
|
||||||
mkdir $out
|
|
||||||
cd $out
|
|
||||||
${builtins.readFile fetcher}
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
meta = with lib; {
|
|
||||||
description = "Bitcoin blockchain and mempool explorer";
|
|
||||||
homepage = "https://github.com/mempool/mempool/";
|
|
||||||
license = licenses.agpl3Plus;
|
|
||||||
maintainers = with maintainers; [ erikarvstedt ];
|
|
||||||
platforms = platforms.unix;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Fetch hash-locked versions of assets that are dynamically fetched via
|
|
||||||
# https://github.com/mempool/mempool/blob/master/frontend/sync-assets.js
|
|
||||||
# when running `npm run build` in the frontend.
|
|
||||||
#
|
|
||||||
# This file is updated by ./frontend-assets-update.sh
|
|
||||||
|
|
||||||
declare -A revs=(
|
|
||||||
["mempool/mining-pool-logos"]=53972ebbd08373cf4910cbb3e6421a1f3bba4563
|
|
||||||
)
|
|
||||||
|
|
||||||
fetchFile() {
|
|
||||||
repo=$1
|
|
||||||
file=$2
|
|
||||||
rev=${revs["$repo"]}
|
|
||||||
curl -fsS "https://raw.githubusercontent.com/$repo/$rev/$file"
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchRepo() {
|
|
||||||
repo=$1
|
|
||||||
rev=${revs["$repo"]}
|
|
||||||
curl -fsSL "https://github.com/$repo/archive/$rev.tar.gz"
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdir mining-pools
|
|
||||||
fetchRepo "mempool/mining-pool-logos" | tar xz --strip-components=1 -C mining-pools
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Settings adapted from
|
|
||||||
# https://github.com/mempool/mempool/blob/v3.2.1/production/nginx/server-common.conf
|
|
||||||
|
|
||||||
# see order of nginx location rules
|
|
||||||
# https://stackoverflow.com/questions/5238377/nginx-location-priority
|
|
||||||
|
|
||||||
# for exact / requests, redirect based on $lang
|
|
||||||
# cache redirect for 5 minutes
|
|
||||||
location = / {
|
|
||||||
if ($lang != '') {
|
|
||||||
return 302 $scheme://$host/$lang/;
|
|
||||||
}
|
|
||||||
try_files /en-US/index.html =404;
|
|
||||||
expires 5m;
|
|
||||||
}
|
|
||||||
|
|
||||||
# cache /<lang>/main.f40e91d908a068a2.js forever since they never change
|
|
||||||
location ~ ^/([a-z][a-z])/(.+\..+\.(js|css))$ {
|
|
||||||
try_files $uri =404;
|
|
||||||
expires 1y;
|
|
||||||
}
|
|
||||||
# cache everything else for 5 minutes
|
|
||||||
location ~ ^/([a-z][a-z])$ {
|
|
||||||
try_files $uri /$1/index.html /en-US/index.html =404;
|
|
||||||
expires 5m;
|
|
||||||
}
|
|
||||||
location ~ ^/([a-z][a-z])/ {
|
|
||||||
try_files $uri /$1/index.html /en-US/index.html =404;
|
|
||||||
expires 5m;
|
|
||||||
}
|
|
||||||
|
|
||||||
# cache /resources/** for 1 week since they don't change often
|
|
||||||
location /resources {
|
|
||||||
try_files $uri /en-US/index.html;
|
|
||||||
expires 1w;
|
|
||||||
}
|
|
||||||
# cache /main.f40e91d908a068a2.js forever since they never change
|
|
||||||
location ~* ^/.+\..+\.(js|css)$ {
|
|
||||||
try_files /$lang/$uri /en-US/$uri =404;
|
|
||||||
expires 1y;
|
|
||||||
}
|
|
||||||
# catch-all for all URLs i.e. /address/foo /tx/foo /block/000
|
|
||||||
# cache 5 minutes since they change frequently
|
|
||||||
location / {
|
|
||||||
try_files /$lang/$uri $uri /en-US/$uri /en-US/index.html =404;
|
|
||||||
expires 5m;
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
|
||||||
# This local copy does not fetch or import nix-bitcoin.
|
|
||||||
{ lib
|
|
||||||
, stdenvNoCC
|
|
||||||
, nodejs_22
|
|
||||||
, nodejs-slim_22
|
|
||||||
, fetchNodeModules
|
|
||||||
, fetchurl
|
|
||||||
, makeWrapper
|
|
||||||
}:
|
|
||||||
let self = stdenvNoCC.mkDerivation {
|
|
||||||
pname = "rtl";
|
|
||||||
version = "0.15.8";
|
|
||||||
|
|
||||||
src = fetchurl {
|
|
||||||
url = "https://github.com/Ride-The-Lightning/RTL/archive/refs/tags/v${self.version}.tar.gz";
|
|
||||||
hash = "sha256-8XdGyORxB2dkZRB/Yl7zh+Quqo4L/Y0VmC6Brbr/hqU=";
|
|
||||||
};
|
|
||||||
|
|
||||||
passthru = {
|
|
||||||
nodejs = nodejs_22;
|
|
||||||
nodejsRuntime = nodejs-slim_22;
|
|
||||||
|
|
||||||
nodeModules = fetchNodeModules {
|
|
||||||
inherit (self) src nodejs;
|
|
||||||
# TODO-EXTERNAL: Remove `npmFlags` when no longer required
|
|
||||||
# See: https://github.com/Ride-The-Lightning/RTL/issues/1182
|
|
||||||
npmFlags = "--legacy-peer-deps";
|
|
||||||
hash = "sha256-oMqd6nLzS6iQ9w4z2yzpR2unA5qhOq5YdvfoS8IgYLY=";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
nativeBuildInputs = [
|
|
||||||
makeWrapper
|
|
||||||
];
|
|
||||||
|
|
||||||
phases = "unpackPhase patchPhase installPhase";
|
|
||||||
|
|
||||||
# `src` already contains the precompiled frontend and backend.
|
|
||||||
# Copy all files required for packaging, like in
|
|
||||||
# https://github.com/Ride-The-Lightning/RTL/blob/master/dockerfiles/Dockerfile
|
|
||||||
installPhase = ''
|
|
||||||
dest=$out/lib/node_modules/rtl
|
|
||||||
mkdir -p $dest
|
|
||||||
cp -r \
|
|
||||||
rtl.js \
|
|
||||||
package.json \
|
|
||||||
frontend \
|
|
||||||
backend \
|
|
||||||
${self.nodeModules}/lib/node_modules \
|
|
||||||
$dest
|
|
||||||
|
|
||||||
makeWrapper ${self.nodejsRuntime}/bin/node "$out/bin/rtl" \
|
|
||||||
--add-flags "$dest/rtl.js"
|
|
||||||
|
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
meta = with lib; {
|
|
||||||
description = "A web interface for LND, c-lightning and Eclair";
|
|
||||||
homepage = "https://github.com/Ride-The-Lightning/RTL";
|
|
||||||
license = licenses.mit;
|
|
||||||
maintainers = with maintainers; [ nixbitcoin erikarvstedt ];
|
|
||||||
platforms = platforms.unix;
|
|
||||||
};
|
|
||||||
}; in self
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
{ nixpkgs, overlay-stable, system ? "x86_64-linux" }:
|
|
||||||
|
|
||||||
let
|
|
||||||
lib = nixpkgs.lib;
|
|
||||||
pkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
overlays = [ overlay-stable ];
|
|
||||||
};
|
|
||||||
|
|
||||||
normalize = s:
|
|
||||||
lib.replaceStrings [ "\n" "\\" " " ] [ " " "" " " ] s;
|
|
||||||
|
|
||||||
extractAfter = prefix: str:
|
|
||||||
let
|
|
||||||
match = builtins.match ".*${prefix} ([^ ]+).*" (normalize str);
|
|
||||||
in
|
|
||||||
if match == null then
|
|
||||||
throw "Unable to extract ${prefix} from: ${normalize str}"
|
|
||||||
else
|
|
||||||
builtins.head match;
|
|
||||||
|
|
||||||
extractFlagValue = flag: str:
|
|
||||||
let
|
|
||||||
match = builtins.match ".*${flag}=([^ ]+).*" (normalize str);
|
|
||||||
in
|
|
||||||
if match == null then
|
|
||||||
throw "Unable to extract ${flag} from: ${normalize str}"
|
|
||||||
else
|
|
||||||
builtins.head match;
|
|
||||||
|
|
||||||
config = (lib.nixosSystem {
|
|
||||||
inherit system;
|
|
||||||
modules = [
|
|
||||||
{ nixpkgs.hostPlatform = system; nixpkgs.overlays = [ overlay-stable ]; }
|
|
||||||
../modules/bitcoin
|
|
||||||
{
|
|
||||||
nix-bitcoin.generateSecrets = true;
|
|
||||||
nix-bitcoin.secretsDir = "/build/secrets";
|
|
||||||
|
|
||||||
services.btcpayserver.enable = true;
|
|
||||||
services.btcpayserver.lightningBackend = "lnd";
|
|
||||||
services.nbxplorer.dataDir = "/build/nbxplorer";
|
|
||||||
services.btcpayserver.dataDir = "/build/btcpayserver";
|
|
||||||
services.lnd.dataDir = "/build/lnd";
|
|
||||||
services.bitcoind.dataDir = "/build/bitcoind";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}).config;
|
|
||||||
|
|
||||||
nbxplorerPreStart = config.systemd.services.nbxplorer.preStart;
|
|
||||||
bitcoindPreStart = config.systemd.services.bitcoind.preStart;
|
|
||||||
btcpayExecStart = config.systemd.services.btcpayserver.serviceConfig.ExecStart;
|
|
||||||
btcpayWorkingDir = config.systemd.services.btcpayserver.serviceConfig.WorkingDirectory;
|
|
||||||
|
|
||||||
nbxplorerConfigPath = extractAfter "install -m 600" nbxplorerPreStart;
|
|
||||||
btcpayConfigPath = extractFlagValue "--conf" btcpayExecStart;
|
|
||||||
|
|
||||||
nbxplorerConfig = builtins.readFile nbxplorerConfigPath;
|
|
||||||
btcpayConfig = builtins.readFile btcpayConfigPath;
|
|
||||||
in
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.users.users.${config.services.btcpayserver.user}.home == config.services.btcpayserver.dataDir)
|
|
||||||
"btcpayserver user home must match btcpayserver dataDir";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.users.users.${config.services.nbxplorer.user}.home == config.services.nbxplorer.dataDir)
|
|
||||||
"nbxplorer user home must match nbxplorer dataDir";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.nix-bitcoin.secrets.bitcoin-HMAC-btcpayserver.user == config.services.bitcoind.user)
|
|
||||||
"bitcoin-HMAC-btcpayserver must be owned by bitcoind";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.user == config.services.bitcoind.user)
|
|
||||||
"bitcoin-rpcpassword-btcpayserver must be owned by bitcoind";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.group == config.services.nbxplorer.group)
|
|
||||||
"bitcoin-rpcpassword-btcpayserver must be group-readable by nbxplorer";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(!(lib.elem config.services.nbxplorer.group config.users.users.${config.services.btcpayserver.user}.extraGroups))
|
|
||||||
"btcpayserver must not receive the nbxplorer group";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.elem "nix-bitcoin-secrets.target" config.systemd.services.nbxplorer.after)
|
|
||||||
"nbxplorer must wait for nix-bitcoin-secrets.target";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.systemd.services.nbxplorer.serviceConfig.MemoryDenyWriteExecute == false)
|
|
||||||
"nbxplorer needs MemoryDenyWriteExecute = false";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(config.systemd.services.btcpayserver.serviceConfig.MemoryDenyWriteExecute == false)
|
|
||||||
"btcpayserver needs MemoryDenyWriteExecute = false";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasInfix "network=mainnet" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "btcrpcuser=btcpayserver" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "btcnodeendpoint=127.0.0.1:8335" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "bind=127.0.0.1" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "port=24444" nbxplorerConfig
|
|
||||||
&& lib.hasInfix "postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" nbxplorerConfig)
|
|
||||||
"nbxplorer base config must contain the expected non-secret settings";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
|
|
||||||
&& lib.hasInfix "btcexplorercookiefile=/build/nbxplorer/Main/.cookie" btcpayConfig)
|
|
||||||
"btcpayserver config must contain btcexplorerurl and btcexplorercookiefile";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasSuffix "/lib/btcpayserver" btcpayWorkingDir)
|
|
||||||
"btcpayserver WorkingDirectory must end with /lib/btcpayserver";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(!lib.hasInfix "/build/btcpayserver/settings.config" btcpayExecStart
|
|
||||||
&& lib.hasInfix "--datadir='/build/btcpayserver'" btcpayExecStart)
|
|
||||||
"btcpayserver must use a deterministic config file plus --datadir";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasInfix "network=mainnet" btcpayConfig
|
|
||||||
&& lib.hasInfix "bind=127.0.0.1" btcpayConfig
|
|
||||||
&& lib.hasInfix "port=23000" btcpayConfig
|
|
||||||
&& lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
|
|
||||||
&& lib.hasInfix "explorer.postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" btcpayConfig
|
|
||||||
&& lib.hasInfix "postgres=User ID=btcpayserver;Host=/run/postgresql;Database=btcpayserver" btcpayConfig
|
|
||||||
&& lib.hasInfix "btclightning=type=lnd-rest;server=https://127.0.0.1:8080/;macaroonfilepath=/run/lnd/btcpayserver.macaroon;certfilepath=/build/secrets/lnd-cert" btcpayConfig)
|
|
||||||
"btcpayserver config must preserve BTCPay, NBXplorer, database, and LND settings";
|
|
||||||
assert lib.assertMsg
|
|
||||||
(lib.hasInfix "readValidatedRpcHmac()" bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''if [[ ! -e "$hmacFile" ]]; then'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''if [[ ! -r "$hmacFile" ]]; then'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''if [[ -z "$hmacPayload" ]]; then'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''^[[:xdigit:]]+\$[[:xdigit:]]+$'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''Bitcoin RPC HMAC file has invalid format'' bitcoindPreStart
|
|
||||||
&& lib.hasInfix ''hmacPayload="$(readValidatedRpcHmac '/build/secrets/bitcoin-HMAC-btcpayserver')" || exit 1'' bitcoindPreStart)
|
|
||||||
"bitcoind preStart must validate missing, unreadable, empty, and malformed HMAC files";
|
|
||||||
pkgs.runCommand "bitcoin-btcpay-hardening" {} ''
|
|
||||||
mkdir -p /build/secrets /build/nbxplorer
|
|
||||||
|
|
||||||
printf '%s' 'first-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
|
|
||||||
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
|
|
||||||
|
|
||||||
test "$(stat -c '%a' /build/nbxplorer/settings.config)" = "600"
|
|
||||||
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
|
|
||||||
printf '%s' 'rotated-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
|
|
||||||
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
|
|
||||||
|
|
||||||
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
|
|
||||||
! grep -q 'first-password' /build/nbxplorer/settings.config
|
|
||||||
grep -q 'rotated-password' /build/nbxplorer/settings.config
|
|
||||||
|
|
||||||
touch "$out"
|
|
||||||
''
|
|
||||||
@@ -27,17 +27,12 @@ def _literal_assignment(source: str, name: str):
|
|||||||
|
|
||||||
|
|
||||||
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
|
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
|
||||||
def test_bitcoind_loopback_listener_is_always_enabled(self):
|
def test_integration_layer_maps_tor_gossip_option(self):
|
||||||
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
"""The integration layer must bridge sovran_systemsOS.features.bitcoin-tor-gossip
|
||||||
self.assertIn("listen = true;", ecosystem)
|
to sovran-bitcoin.bitcoindTorGossip."""
|
||||||
self.assertIn("peerbloomfilters=1", ecosystem)
|
integration = _read("modules", "sovran-bitcoin-integration.nix")
|
||||||
|
self.assertIn("bitcoindTorGossip", integration)
|
||||||
def test_gossip_is_opt_in(self):
|
self.assertIn("bitcoin-tor-gossip", integration)
|
||||||
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
|
||||||
self.assertIn(
|
|
||||||
"public = config.sovran_systemsOS.features.bitcoin-tor-gossip;",
|
|
||||||
ecosystem,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_hub_option_and_evaluated_state_are_declared(self):
|
def test_hub_option_and_evaluated_state_are_declared(self):
|
||||||
roles = _read("modules", "core", "roles.nix")
|
roles = _read("modules", "core", "roles.nix")
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Regression tests for Hub update completion and polling recovery."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
_APP_PARENT = _REPO_ROOT / "app"
|
||||||
|
if str(_APP_PARENT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_APP_PARENT))
|
||||||
|
|
||||||
|
from sovran_systemsos_web.update_state import ( # noqa: E402
|
||||||
|
effective_update_status,
|
||||||
|
reboot_is_pending,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Real store paths observed on the incident machine that prompted this rework.
|
||||||
|
RUNNING_GENERATION = (
|
||||||
|
"84rsiqi66nc68jbikd26ms50ap831xf8-nixos-system-nixos-26.11.20260817.ec2d622"
|
||||||
|
)
|
||||||
|
PREVIOUS_GENERATION = (
|
||||||
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-nixos-system-nixos-26.11.20260816.old"
|
||||||
|
)
|
||||||
|
# What the stale Hub log still claimed was staged: a two-week-old update cycle.
|
||||||
|
HUB_LOG_GENERATION = (
|
||||||
|
"yis0saq6p8fhqcaii0h2yzqf0blhdwns-nixos-system-nixos-26.11.20260804.e72e4f2"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRebootPendingState(unittest.TestCase):
|
||||||
|
"""Reboot-pending state is derived from NixOS, not from Hub marker files.
|
||||||
|
|
||||||
|
The system profile (what boots next) is compared against
|
||||||
|
/run/current-system (what is running). This stays correct no matter
|
||||||
|
which tool performed the update: Hub "Update System", a terminal
|
||||||
|
``nixos-rebuild``, or an SSH support session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
root = Path(self.tmp.name)
|
||||||
|
self.store = root / "store"
|
||||||
|
self.store.mkdir()
|
||||||
|
# /nix/var/nix/profiles/system is a two-hop chain: the diagnostics on
|
||||||
|
# the incident box showed ``system`` -> ``system-148-link`` -> store.
|
||||||
|
self.profile = root / "system"
|
||||||
|
self.profile_entry = root / "system-148-link"
|
||||||
|
# /run/current-system links straight to the running store path.
|
||||||
|
self.current = root / "current-system"
|
||||||
|
|
||||||
|
def _store_dir(self, name: str) -> str:
|
||||||
|
path = self.store / name
|
||||||
|
path.mkdir(exist_ok=True)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
def _stage(self, booted: str, running: str) -> None:
|
||||||
|
"""Stage ``booted`` as the boot default with ``running`` live."""
|
||||||
|
os.symlink(self._store_dir(booted), self.profile_entry)
|
||||||
|
os.symlink(self.profile_entry, self.profile)
|
||||||
|
os.symlink(self._store_dir(running), self.current)
|
||||||
|
|
||||||
|
def test_hub_update_staged_and_not_yet_rebooted_is_pending(self):
|
||||||
|
self._stage(booted=RUNNING_GENERATION, running=PREVIOUS_GENERATION)
|
||||||
|
self.assertTrue(
|
||||||
|
reboot_is_pending(str(self.profile), str(self.current))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_staged_generation_booted_is_no_longer_pending(self):
|
||||||
|
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
|
||||||
|
self.assertFalse(
|
||||||
|
reboot_is_pending(str(self.profile), str(self.current))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_terminal_switch_needs_no_reboot(self):
|
||||||
|
# nixos-rebuild switch moves the profile AND current-system together.
|
||||||
|
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
|
||||||
|
self.assertFalse(
|
||||||
|
reboot_is_pending(str(self.profile), str(self.current))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rollback_leaves_nothing_pending(self):
|
||||||
|
# nixos-rebuild switch --rollback points both at the rollback target.
|
||||||
|
self._stage(booted=PREVIOUS_GENERATION, running=PREVIOUS_GENERATION)
|
||||||
|
self.assertFalse(
|
||||||
|
reboot_is_pending(str(self.profile), str(self.current))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unverifiable_state_is_never_a_reboot_demand(self):
|
||||||
|
# No profile and no running system readable: the Hub must not nag
|
||||||
|
# about a reboot it cannot substantiate.
|
||||||
|
self.assertFalse(
|
||||||
|
reboot_is_pending(str(self.profile), str(self.current))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stale_hub_marker_clears_after_terminal_updates(self):
|
||||||
|
"""The exact incident: terminal-updated machine, frozen Hub marker.
|
||||||
|
|
||||||
|
The user's last Hub update (old updater, weeks prior) left
|
||||||
|
REBOOT_REQUIRED behind. Every update since ran in a terminal and
|
||||||
|
never touched the Hub's files, so the log still records a staged
|
||||||
|
generation from 2026-08-04 while the machine runs a 2026-08-17
|
||||||
|
build. With the profile and the running system in agreement, the
|
||||||
|
stale claim must reconcile to IDLE regardless of anything the old
|
||||||
|
marker/log files say.
|
||||||
|
"""
|
||||||
|
root = Path(self.tmp.name)
|
||||||
|
log = root / "sovran-hub-update.log"
|
||||||
|
log.write_text(
|
||||||
|
"Done. The new configuration is "
|
||||||
|
f"/nix/store/{HUB_LOG_GENERATION}\n"
|
||||||
|
"✓ Update staged successfully\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
# Deliberately no sovran-hub-update.generation marker: the updater
|
||||||
|
# that produced this state predates the marker feature.
|
||||||
|
self.assertFalse(
|
||||||
|
(root / "sovran-hub-update.generation").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
|
||||||
|
self.assertEqual(
|
||||||
|
effective_update_status(
|
||||||
|
"REBOOT_REQUIRED", str(self.profile), str(self.current)
|
||||||
|
),
|
||||||
|
"IDLE",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_genuine_pending_reboot_claim_survives(self):
|
||||||
|
# A staged generation that has NOT been booted yet: the claim is
|
||||||
|
# true and must keep surfacing until the reboot really happens.
|
||||||
|
self._stage(booted=RUNNING_GENERATION, running=PREVIOUS_GENERATION)
|
||||||
|
self.assertEqual(
|
||||||
|
effective_update_status(
|
||||||
|
"REBOOT_REQUIRED", str(self.profile), str(self.current)
|
||||||
|
),
|
||||||
|
"REBOOT_REQUIRED",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_other_statuses_pass_through_unchanged(self):
|
||||||
|
self._stage(booted=RUNNING_GENERATION, running=RUNNING_GENERATION)
|
||||||
|
for status in ("RUNNING", "FAILED", "SUCCESS", "IDLE"):
|
||||||
|
self.assertEqual(
|
||||||
|
effective_update_status(
|
||||||
|
status, str(self.profile), str(self.current)
|
||||||
|
),
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdatePollingWiring(unittest.TestCase):
|
||||||
|
"""Guard the browser failure modes that caused a permanent spinner."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
js_dir = _REPO_ROOT / "app" / "sovran_systemsos_web" / "static" / "js"
|
||||||
|
cls.update_js = (js_dir / "update.js").read_text(encoding="utf-8")
|
||||||
|
cls.rebuild_js = (js_dir / "rebuild.js").read_text(encoding="utf-8")
|
||||||
|
cls.helpers_js = (js_dir / "helpers.js").read_text(encoding="utf-8")
|
||||||
|
cls.events_js = (js_dir / "events.js").read_text(encoding="utf-8")
|
||||||
|
cls.template = (
|
||||||
|
_REPO_ROOT / "app" / "sovran_systemsos_web" / "templates" / "index.html"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_status_fetches_have_an_abort_timeout(self):
|
||||||
|
self.assertIn("function apiFetchWithTimeout", self.helpers_js)
|
||||||
|
self.assertIn("new AbortController()", self.helpers_js)
|
||||||
|
self.assertIn("controller.abort()", self.helpers_js)
|
||||||
|
self.assertIn("apiFetchWithTimeout(", self.update_js)
|
||||||
|
self.assertIn("STATUS_POLL_FETCH_TIMEOUT", self.update_js)
|
||||||
|
|
||||||
|
def test_async_update_polls_cannot_overlap(self):
|
||||||
|
self.assertIn("_updatePollInFlight", self.update_js)
|
||||||
|
self.assertIn(
|
||||||
|
"if (_updateFinished || _updatePollInFlight) return;", self.update_js
|
||||||
|
)
|
||||||
|
self.assertIn("finally", self.update_js)
|
||||||
|
|
||||||
|
def test_connection_failure_has_explicit_non_running_ui(self):
|
||||||
|
self.assertIn("showUpdateStatusUnavailable", self.update_js)
|
||||||
|
self.assertIn("Update status unavailable", self.update_js)
|
||||||
|
self.assertIn("Retry Status", self.template)
|
||||||
|
self.assertIn("retryUpdateStatus", self.events_js)
|
||||||
|
|
||||||
|
def test_rdp_or_tab_resume_forces_reconciliation(self):
|
||||||
|
self.assertIn("resumeUpdateStatusAfterInterruption", self.events_js)
|
||||||
|
self.assertIn('window.addEventListener("focus"', self.events_js)
|
||||||
|
self.assertIn('document.addEventListener("visibilitychange"', self.events_js)
|
||||||
|
|
||||||
|
def test_verbose_log_rendering_is_bounded(self):
|
||||||
|
self.assertIn("UPDATE_VISIBLE_LOG_MAX_CHARS", self.update_js)
|
||||||
|
self.assertIn("document.createTextNode(text)", self.update_js)
|
||||||
|
self.assertNotIn("$modalLog.textContent += text", self.update_js)
|
||||||
|
|
||||||
|
def test_page_reload_restores_running_or_completed_update(self):
|
||||||
|
self.assertIn("restoreUpdateModalIfNeeded", self.update_js)
|
||||||
|
self.assertIn("await restoreUpdateModalIfNeeded();", self.events_js)
|
||||||
|
self.assertIn('current.result === "reboot_required"', self.update_js)
|
||||||
|
|
||||||
|
def test_all_javascript_remains_syntax_valid(self):
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
self.skipTest("node is not available in this test environment")
|
||||||
|
for script in (
|
||||||
|
_REPO_ROOT / "app" / "sovran_systemsos_web" / "static" / "js"
|
||||||
|
).glob("*.js"):
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, "--check", str(script)], capture_output=True, text=True
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Regression tests for the Hub Zeus Connect QR.
|
||||||
|
|
||||||
|
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
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
|
||||||
|
def _read(relpath: str) -> str:
|
||||||
|
with open(os.path.join(_REPO_ROOT, relpath), encoding="utf-8") as fh:
|
||||||
|
return fh.read()
|
||||||
|
|
||||||
|
|
||||||
|
class TestZeusConnectSetup(unittest.TestCase):
|
||||||
|
"""zeus-connect-setup must wait for the REST onion and validate the URI."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.src = _read("modules/wallet-autoconnect.nix")
|
||||||
|
|
||||||
|
def test_waits_for_onion_addresses(self):
|
||||||
|
self.assertIn("onion-addresses.service", self.src)
|
||||||
|
|
||||||
|
def test_rejects_non_lndconnect_output(self):
|
||||||
|
self.assertIn("^lndconnect://", self.src)
|
||||||
|
self.assertIn("\\.onion", self.src)
|
||||||
|
self.assertIn("macaroon=", self.src)
|
||||||
|
|
||||||
|
def test_no_clightning_fallback(self):
|
||||||
|
self.assertNotIn("lnconnect-clnrest", self.src)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHubQrFallback(unittest.TestCase):
|
||||||
|
"""Hub QR encoding must not die on a payload that is too large for ECC H."""
|
||||||
|
|
||||||
|
def test_qrencode_falls_back_to_lower_ecc(self):
|
||||||
|
src = _read("app/sovran_systemsos_web/server.py")
|
||||||
|
self.assertIn('for ecc in ("H", "Q", "L")', src)
|
||||||
|
|
||||||
|
def test_qronly_does_not_hide_uri_when_qr_fails(self):
|
||||||
|
src = _read("app/sovran_systemsos_web/server.py")
|
||||||
|
self.assertIn("Don't hide the URI if we could not render a scannable QR.", src)
|
||||||
|
|
||||||
|
def test_zeus_guide_mentions_use_tor(self):
|
||||||
|
src = _read("app/sovran_systemsos_web/static/js/service-detail.js")
|
||||||
|
self.assertIn("Use Tor", src)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user