Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
499676569e | ||
|
|
35dbd198b8 | ||
|
|
e3f8a2579a | ||
|
|
9919966070 | ||
|
|
859f25f0c1 | ||
|
|
4c22c2363b | ||
|
|
bbf53d089a | ||
|
|
4476a5e0e2 | ||
|
|
36b77e3f0a | ||
|
|
78ae9e78ed | ||
|
|
61ef286420 | ||
|
|
ca1a5c9eb8 | ||
|
|
6d32bebdc6 | ||
|
|
e6078b9c89 | ||
|
|
ea9d4f21d7 | ||
|
|
d164d423ad | ||
|
|
2013df55bd | ||
|
|
1d46d036c0 | ||
|
|
9ae4e34fe0 | ||
|
|
d1e226a687 | ||
|
|
49e41eeea9 | ||
|
|
88be5b99dd | ||
|
|
93492e88fb | ||
|
|
7da37cdb34 | ||
|
|
f34d1533c3 | ||
|
|
220d6dff2c | ||
|
|
81ab3b2280 | ||
|
|
d5d40a1697 | ||
|
|
3756a0058d | ||
|
|
be5d47cb9c | ||
|
|
ddf87a1c1c | ||
|
|
f0e4c33a5f | ||
|
|
a6ef13688e | ||
|
|
ef1c045e0e | ||
|
|
e27bf0ef1f | ||
|
|
362fa0b36c | ||
|
|
043981a34b | ||
|
|
5529fccab2 | ||
|
|
5f84154dca | ||
|
|
60b55f715e | ||
|
|
01037cd55c | ||
|
|
7980833fc2 | ||
|
|
837fffc8f9 | ||
|
|
cb4585ecf8 | ||
|
|
1de1bf95ad | ||
|
|
e9d6edca56 | ||
|
|
a7bfc9aa0d | ||
|
|
ed2e4059c1 | ||
|
|
f057e43ca0 | ||
|
|
d600198049 | ||
|
|
14db4282b7 |
@@ -7,6 +7,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [1.1.5] - 2026-09-09
|
||||
|
||||
### Added
|
||||
- Hub: don't claim the OS "keeps itself current" on the dashboard
|
||||
- Hub: point RTL credentials at /rtl/ and bump dev version to 0.15.12
|
||||
- Make dashboard cards uniform and symmetric; stop clipping update text
|
||||
- Add boot splash; separate Systems Operational and Security icons
|
||||
- Extend welcome dashboard background to the panel edges
|
||||
- Add welcome dashboard as default view
|
||||
- Monochrome updater glyph; fix oversized dialog header icon
|
||||
- Merge Bitcoin categories into one; rename Self-Hosted Apps to Personal Apps
|
||||
- Neutral graphite theme; branded updater icon
|
||||
- Simplify Systems Operational, reword Domain Status, blue restart buttons
|
||||
- Polish pass: sysops wording, QR size, NWC toolbar, logo, diagnostics placement
|
||||
- Rework Update System dialog to the approved design; match rebuild dialog
|
||||
- Redesign The Hub web UI: softer dark theme, sidebar nav, status widgets
|
||||
- Nixpkgs update
|
||||
- Switch LibreOffice from fresh to stable version
|
||||
- Ui: simplify element-calling port modal — trim verbose steps and remove extra verification task
|
||||
|
||||
### Changed
|
||||
- Updated nixpkgs
|
||||
- Updated Sovran-Bitcoin
|
||||
- Updated flake to build through the new sovran-bitcoin input
|
||||
|
||||
### Fixed
|
||||
- Fix role fit-and-finish: Desktop-only router note, narrow-viewport layout
|
||||
- Self-heal truncated/corrupt Nix downloads and keep failed updates retryable
|
||||
[1.1.5]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.5
|
||||
|
||||
|
||||
## [1.1.4] - 2026-09-02
|
||||
|
||||
### Added
|
||||
- Element-calling: fix Nix string interpolation of LAN_CIDR echo
|
||||
- Docs/hub: update Element Calling port guidance to the new port set
|
||||
- Element-calling: fix Wi-Fi calls and tighten media/TURN ports
|
||||
- Flake: pin LiveKit to 1.13.6 for rtc.advertise_internal_ip support
|
||||
|
||||
### Changed
|
||||
- Updated nixpkgs
|
||||
- Updated sovran-bitcon
|
||||
- Dedupe NWC tooling — use Sovran_Bitcoin's sovran-nwc
|
||||
- Update sovran-bitcoin flake input
|
||||
- Extract bitcoin stack into Sovran_Bitcoin flake input
|
||||
|
||||
### Fixed
|
||||
- Set lnurl domainFile for Hub-managed Lightning Address domain
|
||||
- Correct disablewallet casing
|
||||
- Fixed typo
|
||||
[1.1.4]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.4
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- Element Calling: pin LiveKit to 1.13.6 and advertise the LAN interface
|
||||
(rtc.advertise_internal_ip) so Wi-Fi callers get a local ICE candidate and
|
||||
calls no longer depend on router NAT hairpin
|
||||
- Element Calling: use a single UDP media mux (7882); move the TURN relay to
|
||||
40000-40099 and remove the unreachable 5349 TURN/TLS listener
|
||||
- Hub: update Element Calling router-port guidance to the new port set
|
||||
|
||||
### Fixed
|
||||
- Element Calling: TURN relay could not deliver media to the SFU on routers
|
||||
without NAT loopback (turn.allow_restricted_peer_cidrs now permits the LAN
|
||||
subnet derived from the primary interface)
|
||||
|
||||
## [1.1.3] - 2026-08-27
|
||||
|
||||
### Added
|
||||
- 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
|
||||
|
||||
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
|
||||
are ready.
|
||||
|
||||
[Visit the Website](https://sovransystems.com) ·
|
||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso) ·
|
||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.5.iso) ·
|
||||
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso.sha256) ·
|
||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.5.iso.sha256) ·
|
||||
[Build from Source](#build-from-source)
|
||||
|
||||
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||
@@ -345,8 +345,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
||||
|
||||
### 1. Download the ISO and checksum
|
||||
|
||||
- [Download Sovran_SystemsOS-1.1.2.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso)
|
||||
- [Download Sovran_SystemsOS-1.1.2.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso.sha256)
|
||||
- [Download Sovran_SystemsOS-1.1.5.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.5.iso)
|
||||
- [Download Sovran_SystemsOS-1.1.5.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.5.iso.sha256)
|
||||
|
||||
The download may take some time. Do not rename or modify the ISO before
|
||||
verifying it, and keep both files in the same folder.
|
||||
@@ -364,16 +364,16 @@ checksum exactly.
|
||||
Open a terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
sha256sum --check Sovran_SystemsOS-1.1.2.iso.sha256
|
||||
sha256sum --check Sovran_SystemsOS-1.1.5.iso.sha256
|
||||
```
|
||||
|
||||
A successful comparison reports:
|
||||
|
||||
```text
|
||||
Sovran_SystemsOS-1.1.2.iso: OK
|
||||
Sovran_SystemsOS-1.1.5.iso: OK
|
||||
```
|
||||
|
||||
You can also run `sha256sum Sovran_SystemsOS-1.1.2.iso` and compare the output
|
||||
You can also run `sha256sum Sovran_SystemsOS-1.1.5.iso` and compare the output
|
||||
against the checksum file manually.
|
||||
|
||||
</details>
|
||||
@@ -384,11 +384,11 @@ against the checksum file manually.
|
||||
Open Terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
shasum -a 256 Sovran_SystemsOS-1.1.2.iso
|
||||
shasum -a 256 Sovran_SystemsOS-1.1.5.iso
|
||||
```
|
||||
|
||||
Compare the value shown in Terminal with the value inside
|
||||
`Sovran_SystemsOS-1.1.2.iso.sha256`.
|
||||
`Sovran_SystemsOS-1.1.5.iso.sha256`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -398,7 +398,7 @@ Compare the value shown in Terminal with the value inside
|
||||
Open PowerShell in the download folder and run:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\Sovran_SystemsOS-1.1.2.iso -Algorithm SHA256
|
||||
Get-FileHash .\Sovran_SystemsOS-1.1.5.iso -Algorithm SHA256
|
||||
```
|
||||
|
||||
Compare the value under `Hash` with the published checksum.
|
||||
@@ -413,7 +413,7 @@ match exactly.
|
||||
|
||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||
connect the USB drive.
|
||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.2.iso`.
|
||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.5.iso`.
|
||||
3. Choose **Select target**, select the USB drive, and review your selection
|
||||
carefully.
|
||||
4. Choose **Flash** and wait for the writing and verification process to
|
||||
@@ -510,13 +510,15 @@ setup, supported hardware, and Royal Membership.
|
||||
|
||||
## For developers
|
||||
|
||||
Sovran_SystemsOS combines [NixOS](https://nixos.org), an in-repository
|
||||
Bitcoin and Lightning stack, desktop packages from
|
||||
Sovran_SystemsOS combines [NixOS](https://nixos.org), the standalone
|
||||
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) flake for
|
||||
Bitcoin and Lightning, desktop packages from
|
||||
[btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), and the
|
||||
Sovran Hub. The Bitcoin modules under `modules/bitcoin/` were adapted from
|
||||
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin) and are now maintained
|
||||
here. Builds no longer import or fetch nix-bitcoin. Legacy `nix-bitcoin.*`
|
||||
option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||
Sovran Hub. The Bitcoin and Lightning modules live in the Sovran_Bitcoin
|
||||
repository and are consumed as a flake input. OS-specific Bitcoin customizations
|
||||
(Second_Drive paths, operator user, Hub integration) are bridged by
|
||||
`modules/sovran-bitcoin-integration.nix`. Legacy `nix-bitcoin.*` option names
|
||||
and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||
|
||||
### Development workflow
|
||||
|
||||
@@ -536,8 +538,9 @@ option names and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||
|
||||
- [NixOS](https://nixos.org) and [Nix flakes](https://nixos.wiki/wiki/Flakes)
|
||||
for declarative, pinned system configuration
|
||||
- `modules/bitcoin/` for the in-repository Bitcoin and Lightning stack
|
||||
- `packages/` for Sovran-maintained package definitions and patches
|
||||
- [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) for the
|
||||
Bitcoin and Lightning stack (consumed as a flake input)
|
||||
- `modules/sovran-bitcoin-integration.nix` for OS-specific Bitcoin overrides
|
||||
- [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix) for the
|
||||
Sparrow, Bisq, and Bisq 2 packages
|
||||
- [Python](https://www.python.org) and [FastAPI](https://fastapi.tiangolo.com)
|
||||
@@ -628,12 +631,11 @@ sudo nixos-rebuild switch --rollback
|
||||
| `flake.lock` | Pins dependencies for reproducible builds |
|
||||
| `configuration.nix` | Base host, boot, desktop, user, security, backup, and system configuration |
|
||||
| `modules/` | Core modules, self-hosted services, and optional features |
|
||||
| `modules/bitcoin/` | In-repository Bitcoin and Lightning service modules |
|
||||
| `modules/sovran-bitcoin-integration.nix` | OS-specific Bitcoin overrides (Second_Drive, operator, Hub wiring) |
|
||||
| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior |
|
||||
| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface |
|
||||
| `scripts/` | Automated release, build, and CDN upload utility scripts |
|
||||
| `iso/` | Installer configuration, installer code, and installer assets |
|
||||
| `packages/` | Sovran-maintained package definitions and patches |
|
||||
| `tests/` | Security and Nix integration checks |
|
||||
| `assets/` | Documentation images |
|
||||
| `custom.template.nix` | Template for local features and service overrides |
|
||||
@@ -689,14 +691,12 @@ rebuilds the machine into the selected declarative state.
|
||||
|---|---|
|
||||
| Core platform: roles, Hub, desktop integration, Caddy, domains, support, remote deployment | `modules/core/` |
|
||||
| Shared credentials | `modules/credentials.nix` |
|
||||
| Bitcoin and Lightning stack | `modules/bitcoinecosystem.nix` |
|
||||
| Bitcoin and Lightning stack | [Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) flake input + `modules/sovran-bitcoin-integration.nix` |
|
||||
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
||||
| Alby Hub and Nostr Wallet Connect (NWC) on LND | `modules/nwc-wallets.nix`, `packages/albyhub/` |
|
||||
| Matrix Synapse | `modules/synapse.nix` |
|
||||
| Optional Element audio and video calling via LiveKit | `modules/element-calling.nix` |
|
||||
| Optional Haven Nostr relay | `modules/haven.nix` |
|
||||
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
|
||||
| Optional Mempool explorer | `modules/mempool.nix` |
|
||||
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
|
||||
|
||||
Feature availability and defaults may change as Sovran_SystemsOS develops.
|
||||
@@ -856,7 +856,7 @@ primary location for collaboration. Please read our
|
||||
## Privacy. Sovereignty. Bitcoin.
|
||||
|
||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.2.iso) ·
|
||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.5.iso) ·
|
||||
[View the License](LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
+7
-5
@@ -35,12 +35,14 @@ external networks and are outside a “fully offline” model.
|
||||
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
||||
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
||||
|
||||
### In-repository Bitcoin stack
|
||||
### Bitcoin stack
|
||||
|
||||
Bitcoin and Lightning modules are maintained under `modules/bitcoin/`. They were
|
||||
adapted from nix-bitcoin, but Sovran builds do not import or fetch nix-bitcoin.
|
||||
The `nix-bitcoin.*` option namespace and `/etc/nix-bitcoin-secrets` path remain
|
||||
only for upgrade compatibility.
|
||||
Bitcoin and Lightning modules are maintained in the standalone
|
||||
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||
and consumed as a flake input. OS-specific customizations (Second_Drive paths,
|
||||
operator user, Hub integration) are bridged by
|
||||
`modules/sovran-bitcoin-integration.nix`. The `nix-bitcoin.*` option namespace
|
||||
and `/etc/nix-bitcoin-secrets` path remain only for upgrade compatibility.
|
||||
|
||||
### Supply chain and integrity
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
## nix-bitcoin
|
||||
|
||||
Portions of `modules/bitcoin/` and selected package definitions were adapted
|
||||
from [fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
||||
The Bitcoin and Lightning modules were originally adapted from
|
||||
[fort-nix/nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), commit
|
||||
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||
The current Sovran build does not import or fetch nix-bitcoin.
|
||||
They now live in the standalone
|
||||
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||
and are consumed by Sovran_SystemsOS as a flake input. The current Sovran build
|
||||
does not import or fetch nix-bitcoin.
|
||||
|
||||
MIT License
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"""
|
||||
Structured audit logging for NWC wallet operations.
|
||||
|
||||
Writes append-only JSON lines to /var/log/sovran-nwc-audit.log.
|
||||
Log file is owned by albyhub:albyhub with mode 0600.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AUDIT_LOG_PATH = "/var/log/sovran-nwc-audit.log"
|
||||
_AUDIT_LOCK = threading.Lock()
|
||||
_initialized = False
|
||||
|
||||
|
||||
def _ensure_log_file() -> None:
|
||||
"""Ensure audit log file exists with correct permissions."""
|
||||
global _initialized
|
||||
if _initialized:
|
||||
return
|
||||
with _AUDIT_LOCK:
|
||||
if _initialized:
|
||||
return
|
||||
try:
|
||||
# Create directory if needed
|
||||
os.makedirs(os.path.dirname(AUDIT_LOG_PATH), exist_ok=True)
|
||||
# Create file if it doesn't exist
|
||||
if not os.path.exists(AUDIT_LOG_PATH):
|
||||
with open(AUDIT_LOG_PATH, "w") as f:
|
||||
pass
|
||||
# Set restrictive permissions
|
||||
os.chmod(AUDIT_LOG_PATH, 0o600)
|
||||
# Try to set ownership to albyhub user (best effort)
|
||||
try:
|
||||
import pwd
|
||||
import grp
|
||||
albyhub_uid = pwd.getpwnam("albyhub").pw_uid
|
||||
albyhub_gid = grp.getgrnam("albyhub").gr_gid
|
||||
os.chown(AUDIT_LOG_PATH, albyhub_uid, albyhub_gid)
|
||||
except Exception:
|
||||
pass # Best effort; may not have permissions
|
||||
_initialized = True
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to initialize audit log: %s", exc)
|
||||
|
||||
|
||||
def audit_log(event: str, **fields: Any) -> None:
|
||||
"""Write a structured audit log entry.
|
||||
|
||||
Args:
|
||||
event: Event type identifier (e.g., "wallet_created", "invoice_issued")
|
||||
**fields: Additional key-value fields to include in the log entry
|
||||
"""
|
||||
_ensure_log_file()
|
||||
|
||||
entry = {
|
||||
"ts": time.time(),
|
||||
"event": event,
|
||||
**fields,
|
||||
}
|
||||
|
||||
try:
|
||||
with _AUDIT_LOCK:
|
||||
with open(AUDIT_LOG_PATH, "a") as f:
|
||||
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to write audit log: %s", exc)
|
||||
@@ -1,881 +0,0 @@
|
||||
"""
|
||||
Alby Hub manager — shared backend for Lightning Wallet Connections API and recovery CLI.
|
||||
|
||||
Interfaces with the local Alby Hub instance at
|
||||
http://127.0.0.1:18080 by default (override with NWC_ALBY_HUB_API_BASE).
|
||||
All sensitive values (passwords, bearer tokens, pairing URIs, macaroon
|
||||
contents, Nostr private keys) are redacted from any exception messages
|
||||
or log output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from . import nwc_audit as _audit_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_API_BASE = os.environ.get(
|
||||
"NWC_ALBY_HUB_API_BASE",
|
||||
"http://127.0.0.1:18080",
|
||||
)
|
||||
DEFAULT_UNLOCK_PASSWORD_FILE = "/var/lib/albyhub/unlock-password"
|
||||
DEFAULT_MACAROON_FILE = os.environ.get(
|
||||
"NWC_LND_MACAROON_FILE", "/run/lnd/albyhub.macaroon"
|
||||
)
|
||||
DEFAULT_LND_ADDRESS = os.environ.get("NWC_LND_ADDRESS", "127.0.0.1:10009")
|
||||
DEFAULT_LND_CERT_FILE = os.environ.get("NWC_LND_CERT_FILE", "/var/lib/lnd/tls.cert")
|
||||
DEFAULT_LND_SOCKET = "/run/lnd/lnd.socket"
|
||||
|
||||
LNURL_DESCRIPTION_DEFAULT = "Pay via Lightning"
|
||||
NWC_MIN_SENDABLE_MSAT = 1000
|
||||
NWC_MAX_SENDABLE_MSAT = 1_000_000_000
|
||||
|
||||
# Metadata key used to mark managed isolated wallets
|
||||
_MANAGED_APP_STORE_ID = "uncle-jim"
|
||||
_MANAGED_META_KEY = "app_store_app_id"
|
||||
|
||||
RECEIVE_ONLY_SCOPES = [
|
||||
"get_info",
|
||||
"get_balance",
|
||||
"make_invoice",
|
||||
"lookup_invoice",
|
||||
"list_transactions",
|
||||
"notifications",
|
||||
]
|
||||
|
||||
LIMITED_SEND_SCOPES = RECEIVE_ONLY_SCOPES + ["pay_invoice"]
|
||||
|
||||
# ── Exceptions ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AlbyHubError(Exception):
|
||||
"""Base error from the Alby Hub manager.
|
||||
|
||||
The message string is safe to surface to the user — it never
|
||||
contains raw secret material.
|
||||
"""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.code}] {self.args[0]}"
|
||||
|
||||
|
||||
class AlbyHubHttpError(AlbyHubError):
|
||||
def __init__(self, status_code: int, message: str) -> None:
|
||||
super().__init__(f"http_{status_code}", message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
# ── Manager class ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AlbyHubManager:
|
||||
"""Thread-safe manager for Alby Hub API operations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_base: str = DEFAULT_API_BASE,
|
||||
unlock_password_file: str = DEFAULT_UNLOCK_PASSWORD_FILE,
|
||||
macaroon_file: str = DEFAULT_MACAROON_FILE,
|
||||
lnd_address: str = DEFAULT_LND_ADDRESS,
|
||||
lnd_cert_file: str = DEFAULT_LND_CERT_FILE,
|
||||
) -> None:
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.unlock_password_file = unlock_password_file
|
||||
self.macaroon_file = macaroon_file
|
||||
self.lnd_address = lnd_address
|
||||
self.lnd_cert_file = lnd_cert_file
|
||||
self._lock = threading.Lock()
|
||||
self._token: str | None = None
|
||||
|
||||
# ── Low-level HTTP ─────────────────────────────────────────────
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict | None = None,
|
||||
token: str | None = None,
|
||||
timeout: int = 30,
|
||||
) -> dict:
|
||||
"""Make a raw HTTP request to the local Alby Hub API.
|
||||
|
||||
Returns the parsed JSON response body.
|
||||
Raises AlbyHubHttpError on non-2xx responses.
|
||||
Secrets in response bodies are never included in raised exceptions.
|
||||
"""
|
||||
url = f"{self.api_base}{path}"
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
headers: dict[str, str] = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read()
|
||||
if not raw:
|
||||
return {}
|
||||
return json.loads(raw)
|
||||
except urllib.error.HTTPError as exc:
|
||||
code = exc.code
|
||||
# Read and discard the body — we do NOT include it in the exception
|
||||
try:
|
||||
exc.read()
|
||||
except Exception:
|
||||
pass
|
||||
raise AlbyHubHttpError(code, f"Hub API {method} {path} returned HTTP {code}") from None
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
raise AlbyHubError(
|
||||
"hub_unreachable",
|
||||
f"Hub API {method} {path} is unreachable",
|
||||
) from None
|
||||
|
||||
def _authenticated_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict | None = None,
|
||||
timeout: int = 30,
|
||||
) -> dict:
|
||||
"""Make an authenticated request; retry once with a fresh token on 401/403."""
|
||||
token = self.ensure_ready()
|
||||
try:
|
||||
return self._request(method, path, body=body, token=token, timeout=timeout)
|
||||
except AlbyHubHttpError as exc:
|
||||
if exc.status_code in (401, 403):
|
||||
with self._lock:
|
||||
self._token = None
|
||||
token = self.ensure_ready()
|
||||
return self._request(method, path, body=body, token=token, timeout=timeout)
|
||||
raise
|
||||
|
||||
def _paginate(self, path_template: str, page_size: int = 100) -> list[dict]:
|
||||
"""Paginate a list API completely, collecting all items.
|
||||
|
||||
``path_template`` must contain ``{limit}`` and ``{offset}`` placeholders.
|
||||
"""
|
||||
token = self.ensure_ready()
|
||||
offset = 0
|
||||
results: list[dict] = []
|
||||
while True:
|
||||
path = path_template.format(limit=page_size, offset=offset)
|
||||
page = self._request("GET", path, token=token)
|
||||
# Alby Hub returns apps at the top level or under "apps"/"transactions"
|
||||
total_count: int | None = None
|
||||
if isinstance(page, list):
|
||||
items = page
|
||||
elif isinstance(page, dict):
|
||||
items = page.get("apps") or page.get("transactions") or []
|
||||
if page.get("totalCount") is not None:
|
||||
total_count = int(page.get("totalCount"))
|
||||
else:
|
||||
items = []
|
||||
if not isinstance(items, list):
|
||||
break
|
||||
results.extend(items)
|
||||
if total_count is not None:
|
||||
if len(results) >= total_count:
|
||||
break
|
||||
elif len(items) < page_size:
|
||||
break
|
||||
offset += page_size
|
||||
return results
|
||||
|
||||
# ── Audit log helper ───────────────────────────────────────────
|
||||
|
||||
def _audit(self, event: str, **fields: Any) -> None:
|
||||
"""Emit structured audit log entry."""
|
||||
_audit_mod.audit_log(event, **fields)
|
||||
|
||||
# ── Startup / Auth ─────────────────────────────────────────────
|
||||
|
||||
def _read_unlock_password(self) -> str:
|
||||
try:
|
||||
with open(self.unlock_password_file, "r") as fh:
|
||||
return fh.read().strip()
|
||||
except OSError as exc:
|
||||
raise AlbyHubError(
|
||||
"unlock_password_unavailable",
|
||||
"Cannot read Alby Hub unlock password",
|
||||
) from exc
|
||||
|
||||
def _wait_for_file(self, path: str, timeout: int = 120) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if os.path.exists(path):
|
||||
return
|
||||
time.sleep(2)
|
||||
raise AlbyHubError(
|
||||
"dependency_unavailable",
|
||||
f"Timed out waiting for required file: {path}",
|
||||
)
|
||||
|
||||
def _wait_for_hub_api(self, timeout: int = 120) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
self._request("GET", "/api/info", timeout=5)
|
||||
return
|
||||
except AlbyHubError:
|
||||
pass
|
||||
time.sleep(3)
|
||||
raise AlbyHubError("hub_unavailable", "Timed out waiting for Alby Hub API")
|
||||
|
||||
def _hub_setup(self, password: str) -> None:
|
||||
"""Perform /api/setup idempotently."""
|
||||
try:
|
||||
info = self._request("GET", "/api/info", timeout=10)
|
||||
if info.get("setupCompleted"):
|
||||
return
|
||||
except AlbyHubError:
|
||||
pass
|
||||
|
||||
setup_body = {
|
||||
"backendType": "LND",
|
||||
"unlockPassword": password,
|
||||
"lndAddress": self.lnd_address,
|
||||
"lndCertFile": self.lnd_cert_file,
|
||||
"lndMacaroonFile": self.macaroon_file,
|
||||
}
|
||||
try:
|
||||
self._request("POST", "/api/setup", body=setup_body, timeout=30)
|
||||
except AlbyHubHttpError as exc:
|
||||
if exc.status_code == 409:
|
||||
return # already setup
|
||||
raise
|
||||
|
||||
def _obtain_token(self, password: str) -> str:
|
||||
info = self._request("GET", "/api/info", timeout=10)
|
||||
if info.get("running"):
|
||||
resp = self._request(
|
||||
"POST",
|
||||
"/api/unlock",
|
||||
body={
|
||||
"unlockPassword": password,
|
||||
"permission": "full",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
else:
|
||||
resp = self._request(
|
||||
"POST",
|
||||
"/api/start",
|
||||
body={"unlockPassword": password},
|
||||
timeout=30,
|
||||
)
|
||||
token = (
|
||||
resp.get("token")
|
||||
or resp.get("accessToken")
|
||||
or resp.get("access_token")
|
||||
)
|
||||
if not token or not isinstance(token, str):
|
||||
raise AlbyHubError("auth_failed", "Alby Hub auth response missing token")
|
||||
return token
|
||||
|
||||
def _wait_for_node_ready(self, token: str, timeout: int = 120) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status = self._request(
|
||||
"GET", "/api/node/status", token=token, timeout=10
|
||||
)
|
||||
if status.get("isReady") or status.get("running") or status.get("online"):
|
||||
return
|
||||
except AlbyHubError:
|
||||
pass
|
||||
time.sleep(3)
|
||||
raise AlbyHubError("node_not_ready", "Timed out waiting for Alby Hub node to be ready")
|
||||
|
||||
def ensure_ready(self) -> str:
|
||||
"""Ensure Alby Hub is set up, unlocked, and authenticated.
|
||||
|
||||
Returns a valid bearer token. Caches it and uses a lock to
|
||||
prevent concurrent setup races.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._token:
|
||||
return self._token
|
||||
|
||||
password = self._read_unlock_password()
|
||||
self._wait_for_file(self.macaroon_file, timeout=120)
|
||||
self._wait_for_hub_api(timeout=120)
|
||||
self._hub_setup(password)
|
||||
token = self._obtain_token(password)
|
||||
self._wait_for_node_ready(token, timeout=120)
|
||||
self._token = token
|
||||
return token
|
||||
|
||||
# ── App isolation helpers ──────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _parse_metadata(raw: Any) -> dict:
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _is_managed_app(self, app: dict) -> bool:
|
||||
meta = self._parse_metadata(app.get("metadata"))
|
||||
alias = str(meta.get("lnurl_alias", "")).strip().lower()
|
||||
return (
|
||||
meta.get(_MANAGED_META_KEY) == _MANAGED_APP_STORE_ID
|
||||
and bool(alias)
|
||||
)
|
||||
|
||||
def _app_to_wallet_meta(self, app: dict, domain: str | None) -> dict:
|
||||
meta = self._parse_metadata(app.get("metadata"))
|
||||
alias = meta.get("lnurl_alias", "")
|
||||
address = f"{alias}@{domain}" if alias and domain else None
|
||||
|
||||
scopes = app.get("scopes") or []
|
||||
access_preset = (
|
||||
"send_receive_limited" if "pay_invoice" in scopes else "receive_only"
|
||||
)
|
||||
|
||||
balance_msat = int(app.get("balanceMsat", 0) or 0)
|
||||
balance_sats = balance_msat // 1000
|
||||
dust_msat = balance_msat % 1000
|
||||
|
||||
spending_limit_sats: int | None = None
|
||||
max_amount = app.get("maxAmountSat") or 0
|
||||
if max_amount:
|
||||
spending_limit_sats = int(max_amount)
|
||||
|
||||
# Count pending transactions from the budget or transactions list
|
||||
pending_txs = int(app.get("pendingTransactionsCount", 0) or 0)
|
||||
|
||||
return {
|
||||
"id": str(app.get("id", "")),
|
||||
"pubkey": app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or "",
|
||||
"name": app.get("name", ""),
|
||||
"alias": alias,
|
||||
"lightning_address": address,
|
||||
"access_preset": access_preset,
|
||||
"spending_limit_sats": spending_limit_sats,
|
||||
"balance_sats": balance_sats,
|
||||
"dust_msat": dust_msat,
|
||||
"pending_transactions": pending_txs,
|
||||
"created_at": app.get("createdAt") or app.get("created_at"),
|
||||
"min_sendable_msat": int(
|
||||
meta.get("lnurl_min_sendable_msat", NWC_MIN_SENDABLE_MSAT)
|
||||
),
|
||||
"max_sendable_msat": int(
|
||||
meta.get("lnurl_max_sendable_msat", NWC_MAX_SENDABLE_MSAT)
|
||||
),
|
||||
}
|
||||
|
||||
def _all_managed_apps(self) -> list[dict]:
|
||||
apps = self._paginate("/api/apps?limit={limit}&offset={offset}&order_by=created_at")
|
||||
return [a for a in apps if a.get("isolated") and self._is_managed_app(a)]
|
||||
|
||||
def _find_managed_app(self, identifier: str) -> dict | None:
|
||||
needle = identifier.strip().lower()
|
||||
for app in self._all_managed_apps():
|
||||
if str(app.get("id", "")).lower() == needle:
|
||||
return app
|
||||
pubkey = (
|
||||
app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
||||
).lower()
|
||||
if pubkey == needle:
|
||||
return app
|
||||
return None
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────
|
||||
|
||||
def list_wallets(self, domain: str | None = None) -> list[dict]:
|
||||
"""Return all managed isolated app wallets (no secrets)."""
|
||||
wallets = []
|
||||
for app in self._all_managed_apps():
|
||||
app_copy = dict(app)
|
||||
app_copy["pendingTransactionsCount"] = len(self._get_app_pending_txs(int(app["id"])))
|
||||
wallets.append(self._app_to_wallet_meta(app_copy, domain))
|
||||
return wallets
|
||||
|
||||
def create_wallet(
|
||||
self,
|
||||
name: str,
|
||||
alias: str,
|
||||
access_preset: str,
|
||||
spending_limit_sats: int | None,
|
||||
domain: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a new isolated Alby Hub app (wallet connection).
|
||||
|
||||
Returns a dict containing:
|
||||
``wallet`` — safe metadata (no secrets)
|
||||
``pairing_uri`` — real Alby Hub pairingUri (returned ONCE)
|
||||
``result`` — creation status report
|
||||
"""
|
||||
# Validate uniqueness
|
||||
managed = self._all_managed_apps()
|
||||
for a in managed:
|
||||
meta = self._parse_metadata(a.get("metadata"))
|
||||
if meta.get("lnurl_alias", "").lower() == alias.lower():
|
||||
raise AlbyHubError(
|
||||
"alias_exists", "That Lightning Address alias is already in use."
|
||||
)
|
||||
if (a.get("name") or "").lower() == name.lower():
|
||||
raise AlbyHubError(
|
||||
"wallet_name_exists",
|
||||
"That Wallet Connection name already exists.",
|
||||
)
|
||||
|
||||
scopes = (
|
||||
LIMITED_SEND_SCOPES
|
||||
if access_preset == "send_receive_limited"
|
||||
else RECEIVE_ONLY_SCOPES
|
||||
)
|
||||
max_amount = (
|
||||
spending_limit_sats
|
||||
if access_preset == "send_receive_limited" and spending_limit_sats
|
||||
else 0
|
||||
)
|
||||
|
||||
create_body: dict = {
|
||||
"name": name,
|
||||
"scopes": scopes,
|
||||
"isolated": True,
|
||||
"budgetRenewal": "never",
|
||||
"maxAmountSat": max_amount,
|
||||
"metadata": {
|
||||
_MANAGED_META_KEY: _MANAGED_APP_STORE_ID,
|
||||
"lnurl_alias": alias,
|
||||
"lnurl_description": LNURL_DESCRIPTION_DEFAULT,
|
||||
"lnurl_min_sendable_msat": NWC_MIN_SENDABLE_MSAT,
|
||||
"lnurl_max_sendable_msat": NWC_MAX_SENDABLE_MSAT,
|
||||
},
|
||||
}
|
||||
|
||||
resp = self._authenticated_request("POST", "/api/apps", body=create_body)
|
||||
pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or ""
|
||||
app_id = resp.get("id")
|
||||
|
||||
# Fetch full app details for accurate metadata
|
||||
app_detail: dict | None = None
|
||||
if app_id is not None:
|
||||
try:
|
||||
app_detail = self._authenticated_request(
|
||||
"GET", f"/api/v2/apps/{app_id}"
|
||||
)
|
||||
except AlbyHubError:
|
||||
pass
|
||||
|
||||
if app_detail is None:
|
||||
# Fallback: search recent apps for the one we just created
|
||||
updated = self._all_managed_apps()
|
||||
for a in updated:
|
||||
if str(a.get("id", "")) == str(app_id):
|
||||
app_detail = a
|
||||
break
|
||||
|
||||
wallet_meta = self._app_to_wallet_meta(app_detail or resp, domain)
|
||||
|
||||
# Initial internal transfer for limited wallets
|
||||
funding_result: dict = {"attempted": False, "success": False}
|
||||
if (
|
||||
access_preset == "send_receive_limited"
|
||||
and spending_limit_sats
|
||||
and app_id is not None
|
||||
):
|
||||
funding_result["attempted"] = True
|
||||
try:
|
||||
self._authenticated_request(
|
||||
"POST",
|
||||
"/api/transfers",
|
||||
body={
|
||||
"toAppId": int(app_id),
|
||||
"amountSat": spending_limit_sats,
|
||||
"description": f"Initial funding for {name}",
|
||||
},
|
||||
)
|
||||
funding_result["success"] = True
|
||||
except AlbyHubError as exc:
|
||||
funding_result["error"] = exc.code
|
||||
funding_result["message"] = (
|
||||
"The wallet was created successfully and the NWC connection secret is shown "
|
||||
"above, but initial funding failed. Save the NWC secret now. "
|
||||
"Do not recreate this wallet."
|
||||
)
|
||||
|
||||
# Audit log: wallet created
|
||||
self._audit(
|
||||
"wallet_created",
|
||||
wallet_id=str(app_id) if app_id else "unknown",
|
||||
name=name,
|
||||
alias=alias,
|
||||
access_preset=access_preset,
|
||||
spending_limit_sats=spending_limit_sats,
|
||||
lightning_address=wallet_meta.get("lightning_address"),
|
||||
funding_attempted=funding_result["attempted"],
|
||||
funding_success=funding_result["success"],
|
||||
)
|
||||
|
||||
return {
|
||||
"wallet": wallet_meta,
|
||||
"pairing_uri": pairing_uri, # returned once on create only
|
||||
"result": {
|
||||
"wallet_created": True,
|
||||
"secret_created": bool(pairing_uri),
|
||||
"lightning_address_registered": bool(alias and domain),
|
||||
"funding": funding_result,
|
||||
},
|
||||
}
|
||||
|
||||
def _get_app_balance_msat(self, app: dict) -> int:
|
||||
return int(app.get("balanceMsat", 0) or 0)
|
||||
|
||||
def _get_app_pending_txs(self, app_id: int) -> list[dict]:
|
||||
txs = self._paginate(
|
||||
f"/api/transactions?appId={app_id}&limit={{limit}}&offset={{offset}}"
|
||||
)
|
||||
return [
|
||||
t for t in txs if str(t.get("state", "")).lower() == "pending"
|
||||
]
|
||||
|
||||
def drain_wallet(self, identifier: str) -> dict:
|
||||
"""Drain all whole-satoshi funds from an isolated app to the primary wallet.
|
||||
|
||||
Returns ``{"ok": True, "drained_sats": N, "dust_msat": M}``.
|
||||
Raises AlbyHubError on rejection or failure.
|
||||
"""
|
||||
app = self._find_managed_app(identifier)
|
||||
if app is None:
|
||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
||||
|
||||
app_id = int(app["id"])
|
||||
balance_msat = self._get_app_balance_msat(app)
|
||||
|
||||
if balance_msat < 0:
|
||||
raise AlbyHubError("negative_balance", "Wallet has a negative balance.")
|
||||
|
||||
pending = self._get_app_pending_txs(app_id)
|
||||
if pending:
|
||||
raise AlbyHubError(
|
||||
"pending_transactions",
|
||||
"Wallet has pending transactions and cannot be drained.",
|
||||
)
|
||||
|
||||
transferable_msat = (balance_msat // 1000) * 1000
|
||||
expected_dust_msat = balance_msat - transferable_msat
|
||||
|
||||
if transferable_msat == 0:
|
||||
return {"ok": True, "drained_sats": 0, "dust_msat": expected_dust_msat}
|
||||
|
||||
# Save original permissions
|
||||
original_scopes = list(app.get("scopes") or [])
|
||||
original_max = app.get("maxAmountSat") or 0
|
||||
original_renewal = app.get("budgetRenewal") or "never"
|
||||
|
||||
# Temporarily grant pay_invoice scope with sufficient budget
|
||||
app_pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
||||
if not app_pubkey:
|
||||
raise AlbyHubError(
|
||||
"app_pubkey_missing",
|
||||
"Cannot drain app: app public key not available.",
|
||||
)
|
||||
|
||||
patch_body = {
|
||||
"scopes": sorted(set(original_scopes) | {"pay_invoice"}),
|
||||
"maxAmountSat": 0,
|
||||
"budgetRenewal": "never",
|
||||
}
|
||||
self._authenticated_request("PATCH", f"/api/apps/{app_pubkey}", body=patch_body)
|
||||
|
||||
drain_error: AlbyHubError | None = None
|
||||
drained_sats = 0
|
||||
try:
|
||||
self._authenticated_request(
|
||||
"POST",
|
||||
"/api/transfers",
|
||||
body={
|
||||
"fromAppId": app_id,
|
||||
"amountMsat": transferable_msat,
|
||||
"description": f"Drain isolated subwallet {app.get('name', '')}",
|
||||
},
|
||||
)
|
||||
drained_sats = transferable_msat // 1000
|
||||
except AlbyHubError as exc:
|
||||
drain_error = exc
|
||||
finally:
|
||||
# Restore original permissions whether drain succeeded or not
|
||||
restore_body = {
|
||||
"scopes": original_scopes,
|
||||
"maxAmountSat": original_max,
|
||||
"budgetRenewal": original_renewal,
|
||||
}
|
||||
try:
|
||||
self._authenticated_request(
|
||||
"PATCH", f"/api/apps/{app_pubkey}", body=restore_body
|
||||
)
|
||||
except AlbyHubError:
|
||||
pass # best-effort restore; don't mask the original error
|
||||
|
||||
if drain_error is not None:
|
||||
raise drain_error
|
||||
|
||||
# Verify remaining balance equals expected dust
|
||||
refreshed = self._authenticated_request("GET", f"/api/v2/apps/{app_id}")
|
||||
remaining_msat = self._get_app_balance_msat(refreshed)
|
||||
if remaining_msat != expected_dust_msat:
|
||||
raise AlbyHubError(
|
||||
"drain_incomplete",
|
||||
"Drain verification failed: final balance does not match expected dust.",
|
||||
)
|
||||
|
||||
# Audit log: wallet drained
|
||||
self._audit(
|
||||
"wallet_drained",
|
||||
wallet_id=str(app_id),
|
||||
name=app.get("name", ""),
|
||||
alias=app.get("alias", ""),
|
||||
drained_sats=drained_sats,
|
||||
dust_msat=expected_dust_msat,
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"drained_sats": drained_sats,
|
||||
"dust_msat": expected_dust_msat,
|
||||
"remaining_msat": remaining_msat,
|
||||
}
|
||||
|
||||
def delete_wallet(self, identifier: str) -> dict:
|
||||
"""Safely drain and delete an isolated app.
|
||||
|
||||
Returns ``{"ok": True, "drained_sats": N}``.
|
||||
"""
|
||||
app = self._find_managed_app(identifier)
|
||||
if app is None:
|
||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
||||
|
||||
app_id = int(app["id"])
|
||||
|
||||
pending = self._get_app_pending_txs(app_id)
|
||||
if pending:
|
||||
raise AlbyHubError(
|
||||
"pending_transactions",
|
||||
"Wallet has pending transactions and cannot be deleted.",
|
||||
)
|
||||
|
||||
drain_result = self.drain_wallet(identifier)
|
||||
|
||||
# Verify no transferable balance remains
|
||||
refreshed = self._authenticated_request("GET", f"/api/v2/apps/{app_id}")
|
||||
remaining_msat = self._get_app_balance_msat(refreshed)
|
||||
if remaining_msat < 0:
|
||||
raise AlbyHubError(
|
||||
"negative_balance",
|
||||
"Wallet has a negative final balance and cannot be deleted.",
|
||||
)
|
||||
if remaining_msat >= 1000:
|
||||
raise AlbyHubError(
|
||||
"drain_incomplete",
|
||||
f"Drain verification failed: funds still remain.",
|
||||
)
|
||||
|
||||
# Delete by app pubkey
|
||||
pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
||||
if not pubkey:
|
||||
raise AlbyHubError(
|
||||
"app_pubkey_missing",
|
||||
"Cannot delete app: nostr pubkey not available.",
|
||||
)
|
||||
self._authenticated_request(
|
||||
"DELETE",
|
||||
f"/api/apps/{urllib.parse.quote(pubkey, safe='')}",
|
||||
)
|
||||
|
||||
# Audit log: wallet deleted
|
||||
self._audit(
|
||||
"wallet_deleted",
|
||||
wallet_id=str(app_id),
|
||||
name=app.get("name", ""),
|
||||
alias=app.get("alias", ""),
|
||||
drained_sats=drain_result.get("drained_sats", 0),
|
||||
dust_msat=remaining_msat,
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"drained_sats": drain_result.get("drained_sats", 0),
|
||||
"dust_msat": remaining_msat,
|
||||
}
|
||||
|
||||
def issue_invoice(
|
||||
self, app_id: int, amount_msat: int, description: str = ""
|
||||
) -> str:
|
||||
"""Create an LND invoice attributed to a specific isolated app.
|
||||
|
||||
Returns a valid BOLT11 invoice string.
|
||||
Raises AlbyHubError if the Hub returns an invalid or misattributed invoice.
|
||||
"""
|
||||
resp = self._authenticated_request(
|
||||
"POST",
|
||||
"/api/invoices",
|
||||
body={
|
||||
"amountMsat": amount_msat,
|
||||
"description": description or LNURL_DESCRIPTION_DEFAULT,
|
||||
"appId": app_id,
|
||||
},
|
||||
)
|
||||
invoice: str = resp.get("invoice") or ""
|
||||
returned_app_id = resp.get("appId")
|
||||
|
||||
if not invoice:
|
||||
raise AlbyHubError("invoice_creation_failed", "Hub returned empty invoice.")
|
||||
|
||||
# Require a valid BOLT11 prefix (mainnet, testnet, signet, regtest)
|
||||
if not re.match(r"^ln", invoice, re.IGNORECASE):
|
||||
raise AlbyHubError(
|
||||
"invalid_invoice", "Hub returned a non-BOLT11 invoice string."
|
||||
)
|
||||
|
||||
if returned_app_id is None or int(returned_app_id) != app_id:
|
||||
raise AlbyHubError(
|
||||
"invoice_attribution_failed",
|
||||
"Invoice attribution mismatch: returned appId does not match.",
|
||||
)
|
||||
|
||||
# Audit log: invoice issued via API
|
||||
self._audit(
|
||||
"invoice_issued",
|
||||
app_id=app_id,
|
||||
amount_msat=amount_msat,
|
||||
amount_sat=amount_msat // 1000,
|
||||
invoice_prefix=invoice[:50] + "..." if len(invoice) > 50 else invoice,
|
||||
)
|
||||
|
||||
return invoice
|
||||
|
||||
def find_app_by_alias(self, alias: str) -> dict | None:
|
||||
"""Find a managed isolated app by its ``lnurl_alias`` metadata field."""
|
||||
alias_lower = alias.strip().lower()
|
||||
for a in self._all_managed_apps():
|
||||
meta = self._parse_metadata(a.get("metadata"))
|
||||
if meta.get("lnurl_alias", "").lower() == alias_lower:
|
||||
return a
|
||||
return None
|
||||
|
||||
def rotate_wallet_secret(self, identifier: str) -> dict:
|
||||
"""Rotate the NWC pairing secret for a wallet connection.
|
||||
|
||||
Revokes the old Nostr key and generates a new pairing URI.
|
||||
Returns the new pairing URI (shown ONCE).
|
||||
"""
|
||||
app = self._find_managed_app(identifier)
|
||||
if app is None:
|
||||
raise AlbyHubError("wallet_not_found", "Wallet connection not found.")
|
||||
|
||||
app_id = int(app["id"])
|
||||
app_pubkey = app.get("appPubkey") or app.get("nostrPubkey") or app.get("pubkey") or ""
|
||||
if not app_pubkey:
|
||||
raise AlbyHubError(
|
||||
"app_pubkey_missing",
|
||||
"Cannot rotate secret: app public key not available.",
|
||||
)
|
||||
|
||||
# Call Alby Hub's rotate secret endpoint (if available)
|
||||
# Alby Hub may not have this endpoint yet; fall back to re-creating the app
|
||||
# For now, we'll delete and re-create with same metadata
|
||||
# This is a safe operation since we drain first
|
||||
name = app.get("name", "")
|
||||
alias = app.get("alias", "")
|
||||
scopes = app.get("scopes") or []
|
||||
max_amount = app.get("maxAmountSat") or 0
|
||||
metadata = app.get("metadata") or {}
|
||||
|
||||
# Drain first
|
||||
self.drain_wallet(identifier)
|
||||
|
||||
# Delete old app
|
||||
self._authenticated_request(
|
||||
"DELETE",
|
||||
f"/api/apps/{urllib.parse.quote(app_pubkey, safe='')}",
|
||||
)
|
||||
|
||||
# Create new app with same parameters
|
||||
create_body: dict = {
|
||||
"name": name,
|
||||
"scopes": scopes,
|
||||
"isolated": True,
|
||||
"budgetRenewal": "never",
|
||||
"maxAmountSat": max_amount,
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
resp = self._authenticated_request("POST", "/api/apps", body=create_body)
|
||||
new_pairing_uri: str = resp.get("pairingUri") or resp.get("pairing_uri") or ""
|
||||
new_app_id = resp.get("id")
|
||||
|
||||
# Audit log: secret rotated
|
||||
self._audit(
|
||||
"wallet_secret_rotated",
|
||||
old_wallet_id=str(app_id),
|
||||
new_wallet_id=str(new_app_id) if new_app_id else "unknown",
|
||||
name=name,
|
||||
alias=alias,
|
||||
)
|
||||
|
||||
return {
|
||||
"wallet_id": str(new_app_id) if new_app_id else "",
|
||||
"pairing_uri": new_pairing_uri,
|
||||
"message": "New NWC connection secret generated. Save it now — it will not be shown again.",
|
||||
}
|
||||
|
||||
def health(self) -> dict:
|
||||
"""Return a basic health summary."""
|
||||
try:
|
||||
token = self.ensure_ready()
|
||||
status = self._request(
|
||||
"GET", "/api/node/status", token=token, timeout=10
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"hub_ready": bool(
|
||||
status.get("isReady") or status.get("running")
|
||||
),
|
||||
}
|
||||
except AlbyHubError as exc:
|
||||
return {"ok": False, "error": exc.code, "message": str(exc)}
|
||||
|
||||
|
||||
# ── Module-level singleton ──────────────────────────────────────────
|
||||
|
||||
_manager: AlbyHubManager | None = None
|
||||
_manager_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_manager() -> AlbyHubManager:
|
||||
"""Return the module-level singleton AlbyHubManager."""
|
||||
global _manager
|
||||
if _manager is None:
|
||||
with _manager_lock:
|
||||
if _manager is None:
|
||||
_manager = AlbyHubManager()
|
||||
return _manager
|
||||
@@ -1,307 +0,0 @@
|
||||
"""
|
||||
Dedicated LNURL service for Lightning Wallet Connections.
|
||||
|
||||
Runs as ``nwc-lnurl.service`` on 127.0.0.1:8181 (loopback only).
|
||||
Caddy proxies the public Lightning Address domain's LNURL routes to this port.
|
||||
|
||||
Routes:
|
||||
GET /.well-known/lnurlp/{alias}
|
||||
GET /lnurlp/{alias}/callback?amount=<msat>
|
||||
|
||||
All error responses are safe for public consumption — raw Alby Hub bodies
|
||||
and internal credentials are never returned to callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
from collections import defaultdict
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from . import nwc_hub_manager as _mgr_mod
|
||||
from . import nwc_audit as _audit_mod
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .nwc_hub_manager import AlbyHubManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────
|
||||
|
||||
LNURL_BIND_HOST = "127.0.0.1"
|
||||
LNURL_PORT = int(os.environ.get("NWC_LNURL_PORT", "8181"))
|
||||
DOMAIN_FILE = "/var/lib/domains/lightning"
|
||||
|
||||
NWC_ALIAS_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
||||
|
||||
# Rate limiting configuration
|
||||
RATE_LIMIT_WINDOW_SEC = 60
|
||||
RATE_LIMIT_MAX_REQUESTS = 30
|
||||
_rate_limit_buckets: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_domain() -> str | None:
|
||||
try:
|
||||
with open(DOMAIN_FILE, "r") as fh:
|
||||
raw = fh.read(256).strip().lower()
|
||||
# Strict FQDN validation: must be a valid hostname with at least one dot
|
||||
# Reject localhost, IP addresses, and single-label names
|
||||
if not re.match(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$", raw):
|
||||
return None
|
||||
# Explicitly reject local/reserved names
|
||||
if raw in {"localhost", "localhost.localdomain", "local"}:
|
||||
return None
|
||||
return raw
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _check_rate_limit(client_ip: str) -> bool:
|
||||
"""Check and update rate limit bucket for client IP. Returns True if allowed."""
|
||||
now = time.monotonic()
|
||||
bucket = _rate_limit_buckets[client_ip]
|
||||
# Prune old entries
|
||||
cutoff = now - RATE_LIMIT_WINDOW_SEC
|
||||
while bucket and bucket[0] < cutoff:
|
||||
bucket.pop(0)
|
||||
if len(bucket) >= RATE_LIMIT_MAX_REQUESTS:
|
||||
return False
|
||||
bucket.append(now)
|
||||
return True
|
||||
|
||||
|
||||
def _lnurl_discovery(alias: str, manager: "AlbyHubManager", client_ip: str = "") -> tuple[dict, int]:
|
||||
alias = alias.strip().lower()
|
||||
if not NWC_ALIAS_RE.match(alias):
|
||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
||||
|
||||
domain = _read_domain()
|
||||
if not domain:
|
||||
return {"status": "ERROR", "reason": "Lightning domain is not configured"}, 503
|
||||
|
||||
try:
|
||||
app = manager.find_app_by_alias(alias)
|
||||
except _mgr_mod.AlbyHubError:
|
||||
return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503
|
||||
|
||||
if app is None:
|
||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
||||
|
||||
meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata"))
|
||||
min_sendable = int(
|
||||
meta.get("lnurl_min_sendable_msat", _mgr_mod.NWC_MIN_SENDABLE_MSAT)
|
||||
)
|
||||
max_sendable = int(
|
||||
meta.get("lnurl_max_sendable_msat", _mgr_mod.NWC_MAX_SENDABLE_MSAT)
|
||||
)
|
||||
|
||||
callback_alias = urllib.parse.quote(alias, safe="")
|
||||
callback = f"https://{domain}/lnurlp/{callback_alias}/callback"
|
||||
description = meta.get("lnurl_description") or f"Pay {alias}"
|
||||
metadata = json.dumps([["text/plain", description]], separators=(",", ":"))
|
||||
|
||||
# Audit log: LNURL discovery
|
||||
_audit_mod.audit_log(
|
||||
"lnurl_discovery",
|
||||
alias=alias,
|
||||
domain=domain,
|
||||
client_ip=client_ip,
|
||||
min_sendable_msat=min_sendable,
|
||||
max_sendable_msat=max_sendable,
|
||||
)
|
||||
|
||||
return {
|
||||
"tag": "payRequest",
|
||||
"callback": callback,
|
||||
"minSendable": min_sendable,
|
||||
"maxSendable": max_sendable,
|
||||
"metadata": metadata,
|
||||
"commentAllowed": 0,
|
||||
}, 200
|
||||
|
||||
|
||||
def _lnurl_callback(
|
||||
alias: str, amount_str: str | None, manager: "AlbyHubManager", client_ip: str = ""
|
||||
) -> tuple[dict, int]:
|
||||
payload, status_code = _lnurl_discovery(alias, manager, client_ip)
|
||||
if status_code != 200:
|
||||
return payload, status_code
|
||||
|
||||
if amount_str is None:
|
||||
return {"status": "ERROR", "reason": "Missing amount parameter"}, 400
|
||||
if not re.match(r"^\d+$", amount_str):
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": "Amount must be an integer millisatoshi value",
|
||||
}, 400
|
||||
|
||||
amount_msat = int(amount_str)
|
||||
min_sendable = int(payload["minSendable"])
|
||||
max_sendable = int(payload["maxSendable"])
|
||||
|
||||
if amount_msat < min_sendable:
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": "Amount is below the minimum sendable value",
|
||||
}, 400
|
||||
if amount_msat > max_sendable:
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": "Amount is above the maximum sendable value",
|
||||
}, 400
|
||||
if amount_msat % 1000 != 0:
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": "Amount must be a whole-satoshi value",
|
||||
}, 400
|
||||
|
||||
try:
|
||||
app = manager.find_app_by_alias(alias)
|
||||
except _mgr_mod.AlbyHubError:
|
||||
return {"status": "ERROR", "reason": "Service temporarily unavailable"}, 503
|
||||
|
||||
if app is None:
|
||||
return {"status": "ERROR", "reason": "Unknown Lightning Address alias"}, 404
|
||||
|
||||
meta = _mgr_mod.AlbyHubManager._parse_metadata(app.get("metadata"))
|
||||
description = meta.get("lnurl_description") or f"Pay {alias}"
|
||||
|
||||
try:
|
||||
app_id = int(app["id"])
|
||||
invoice = manager.issue_invoice(app_id, amount_msat, description)
|
||||
except _mgr_mod.AlbyHubError:
|
||||
return {"status": "ERROR", "reason": "Invoice creation failed"}, 502
|
||||
|
||||
# Audit log: Invoice generated via LNURL
|
||||
_audit_mod.audit_log(
|
||||
"lnurl_invoice_created",
|
||||
alias=alias,
|
||||
amount_msat=amount_msat,
|
||||
amount_sat=amount_msat // 1000,
|
||||
client_ip=client_ip,
|
||||
invoice_prefix=invoice[:50] + "..." if len(invoice) > 50 else invoice,
|
||||
)
|
||||
|
||||
return {"pr": invoice, "routes": []}, 200
|
||||
|
||||
|
||||
# ── HTTP server ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_handler(manager: "AlbyHubManager") -> type:
|
||||
"""Return a handler class bound to the given manager."""
|
||||
|
||||
class LnurlHandler(BaseHTTPRequestHandler):
|
||||
_manager = manager
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
logger.debug(f"LNURL {self.address_string()} {fmt % args}")
|
||||
|
||||
def _send_json(self, status: int, body: dict) -> None:
|
||||
raw = json.dumps(body, separators=(",", ":")).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def _get_client_ip(self) -> str:
|
||||
# Check X-Forwarded-For header (set by Caddy)
|
||||
forwarded = self.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
# Take the first IP in the chain
|
||||
return forwarded.split(",")[0].strip()
|
||||
# Fallback to direct connection IP
|
||||
return self.client_address[0]
|
||||
|
||||
def _check_rate_limit(self) -> bool:
|
||||
client_ip = self._get_client_ip()
|
||||
if not _check_rate_limit(client_ip):
|
||||
self._send_json(429, {
|
||||
"status": "ERROR",
|
||||
"reason": "Rate limit exceeded. Please slow down."
|
||||
})
|
||||
_audit_mod.audit_log(
|
||||
"rate_limit_exceeded",
|
||||
client_ip=client_ip,
|
||||
path=self.path,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if not self._check_rate_limit():
|
||||
return
|
||||
|
||||
parsed = urllib.parse.urlparse(self.path)
|
||||
path = parsed.path
|
||||
qs = urllib.parse.parse_qs(parsed.query)
|
||||
client_ip = self._get_client_ip()
|
||||
|
||||
# /.well-known/lnurlp/{alias}
|
||||
m = re.fullmatch(
|
||||
r"/.well-known/lnurlp/([^/]+)", path
|
||||
)
|
||||
if m:
|
||||
alias = urllib.parse.unquote(m.group(1))
|
||||
payload, code = _lnurl_discovery(alias, self._manager, client_ip)
|
||||
self._send_json(code, payload)
|
||||
return
|
||||
|
||||
# /lnurlp/{alias}/callback
|
||||
m = re.fullmatch(r"/lnurlp/([^/]+)/callback", path)
|
||||
if m:
|
||||
alias = urllib.parse.unquote(m.group(1))
|
||||
amount_values = qs.get("amount")
|
||||
if not amount_values:
|
||||
amount_str = None
|
||||
elif len(amount_values) != 1:
|
||||
self._send_json(
|
||||
400,
|
||||
{
|
||||
"status": "ERROR",
|
||||
"reason": "A single amount parameter is required",
|
||||
},
|
||||
)
|
||||
return
|
||||
else:
|
||||
amount_str = amount_values[0]
|
||||
payload, code = _lnurl_callback(alias, amount_str, self._manager, client_ip)
|
||||
self._send_json(code, payload)
|
||||
return
|
||||
|
||||
self._send_json(404, {"status": "ERROR", "reason": "Not found"})
|
||||
|
||||
return LnurlHandler
|
||||
|
||||
|
||||
def run(
|
||||
host: str = LNURL_BIND_HOST,
|
||||
port: int = LNURL_PORT,
|
||||
manager: "AlbyHubManager | None" = None,
|
||||
) -> None:
|
||||
"""Start the blocking LNURL HTTP server."""
|
||||
if manager is None:
|
||||
manager = _mgr_mod.get_manager()
|
||||
handler_class = _make_handler(manager)
|
||||
server = HTTPServer((host, port), handler_class)
|
||||
logger.info("nwc-lnurl service listening on %s:%d", host, port)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,131 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from . import nwc_hub_manager as _mgr_mod
|
||||
from .server import _nwc_domain, _nwc_validate_alias, _nwc_test_address
|
||||
|
||||
|
||||
def _print(data) -> None:
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="nwc-wallet")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
create = sub.add_parser("create")
|
||||
create.add_argument("name")
|
||||
create.add_argument("alias")
|
||||
preset_group = create.add_mutually_exclusive_group()
|
||||
preset_group.add_argument("--receive-only", action="store_true")
|
||||
preset_group.add_argument("--limit-sats", type=int)
|
||||
|
||||
sub.add_parser("list")
|
||||
|
||||
drain = sub.add_parser("drain")
|
||||
drain.add_argument("wallet")
|
||||
|
||||
delete = sub.add_parser("delete")
|
||||
delete.add_argument("wallet")
|
||||
|
||||
addr = sub.add_parser("address")
|
||||
addr_sub = addr.add_subparsers(dest="address_cmd", required=True)
|
||||
addr_show = addr_sub.add_parser("show")
|
||||
addr_show.add_argument("alias")
|
||||
|
||||
rotate = sub.add_parser("rotate")
|
||||
rotate.add_argument("wallet")
|
||||
|
||||
sub.add_parser("health")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
manager = _mgr_mod.get_manager()
|
||||
domain = _nwc_domain()
|
||||
|
||||
if args.cmd == "list":
|
||||
try:
|
||||
wallets = manager.list_wallets(domain)
|
||||
except _mgr_mod.AlbyHubError as exc:
|
||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
||||
return 1
|
||||
_print({"wallets": wallets})
|
||||
return 0
|
||||
|
||||
if args.cmd == "health":
|
||||
result = manager.health()
|
||||
_print(result)
|
||||
return 0 if result.get("ok") else 1
|
||||
|
||||
if args.cmd == "address" and args.address_cmd == "show":
|
||||
alias = args.alias.strip().lower()
|
||||
test = _nwc_test_address(alias)
|
||||
_print(test)
|
||||
return 0 if test.get("ok") else 1
|
||||
|
||||
if args.cmd == "drain":
|
||||
try:
|
||||
result = manager.drain_wallet(args.wallet)
|
||||
except _mgr_mod.AlbyHubError as exc:
|
||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
||||
return 1
|
||||
_print(result)
|
||||
return 0
|
||||
|
||||
if args.cmd == "delete":
|
||||
try:
|
||||
result = manager.delete_wallet(args.wallet)
|
||||
except _mgr_mod.AlbyHubError as exc:
|
||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
||||
return 1
|
||||
_print(result)
|
||||
return 0
|
||||
|
||||
if args.cmd == "rotate":
|
||||
try:
|
||||
result = manager.rotate_wallet_secret(args.wallet)
|
||||
except _mgr_mod.AlbyHubError as exc:
|
||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
||||
return 1
|
||||
_print({
|
||||
"wallet_id": result.get("wallet_id", ""),
|
||||
"pairing_uri": result.get("pairing_uri", ""),
|
||||
"message": result.get("message", "New NWC connection secret generated. Save it now — it will not be shown again."),
|
||||
})
|
||||
return 0
|
||||
|
||||
if args.cmd == "create":
|
||||
alias = args.alias.strip().lower()
|
||||
if not _nwc_validate_alias(alias):
|
||||
print("Error: alias_invalid - Alias must be lowercase letters, digits, '_' or '-'.", file=sys.stderr)
|
||||
return 1
|
||||
access_preset = "send_receive_limited" if args.limit_sats is not None else "receive_only"
|
||||
try:
|
||||
result = manager.create_wallet(
|
||||
args.name.strip(),
|
||||
alias,
|
||||
access_preset,
|
||||
args.limit_sats if access_preset == "send_receive_limited" else None,
|
||||
domain,
|
||||
)
|
||||
except _mgr_mod.AlbyHubError as exc:
|
||||
print(f"Error: {exc.code} - {exc}", file=sys.stderr)
|
||||
return 1
|
||||
# Print the pairing URI once — this is the only time it is shown
|
||||
_print(
|
||||
{
|
||||
"wallet": result["wallet"],
|
||||
"pairing_uri": result.get("pairing_uri", ""),
|
||||
"message": "Keep the NWC connection secret private. It cannot be displayed again.",
|
||||
"result": result.get("result", {}),
|
||||
}
|
||||
)
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -39,7 +39,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .config import load_config, load_versions
|
||||
from . import systemctl as sysctl
|
||||
from . import nwc_hub_manager as _nwc_mgr
|
||||
from sovran_nwc import nwc_hub_manager as _nwc_mgr
|
||||
from . import support_ops as _support_ops
|
||||
from .security_helpers import (
|
||||
_nix_escape,
|
||||
@@ -234,10 +234,9 @@ _support_expiry_timer_lock = Lock()
|
||||
|
||||
CATEGORY_ORDER = [
|
||||
("infrastructure", "Infrastructure"),
|
||||
("bitcoin-base", "Bitcoin Base"),
|
||||
("bitcoin-apps", "Bitcoin Apps"),
|
||||
("bitcoin", "Bitcoin"),
|
||||
("communication", "Communication"),
|
||||
("apps", "Self-Hosted Apps"),
|
||||
("apps", "Personal Apps"),
|
||||
("nostr", "Nostr"),
|
||||
("support", "Support"),
|
||||
("feature-manager", "Feature Manager"),
|
||||
@@ -246,8 +245,8 @@ CATEGORY_ORDER = [
|
||||
FEATURE_REGISTRY = [
|
||||
{
|
||||
"id": "rdp",
|
||||
"name": "Remote Desktop (RDP)",
|
||||
"description": "Access your desktop remotely via RDP client",
|
||||
"name": "Remote Desktop",
|
||||
"description": "Access your desktop from a device on your home network — no router setup needed",
|
||||
"category": "infrastructure",
|
||||
"needs_domain": False,
|
||||
"domain_name": None,
|
||||
@@ -291,11 +290,10 @@ FEATURE_REGISTRY = [
|
||||
"port_requirements": [
|
||||
{"port": "80", "protocol": "TCP", "description": "HTTP (redirect to HTTPS)"},
|
||||
{"port": "443", "protocol": "TCP", "description": "HTTPS (domain)"},
|
||||
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
|
||||
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
|
||||
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
|
||||
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
|
||||
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
|
||||
{"port": "7881", "protocol": "TCP", "description": "WebRTC media (TCP fallback)"},
|
||||
{"port": "7882", "protocol": "UDP", "description": "WebRTC media (UDP)"},
|
||||
{"port": "3478", "protocol": "UDP", "description": "TURN relay + STUN"},
|
||||
{"port": "40000-40099", "protocol": "UDP", "description": "TURN relay (WebRTC media)"},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -362,8 +360,8 @@ FEATURE_REGISTRY = [
|
||||
},
|
||||
{
|
||||
"id": "btcpay-web",
|
||||
"name": "BTCPay Server Web Access",
|
||||
"description": "Expose BTCPay Server to the internet via your domain. When disabled, BTCPay Server still runs locally but is not accessible from the web.",
|
||||
"name": "Put BTCPay Server Online",
|
||||
"description": "Let people donate Bitcoin, pay you, or visit your online store. BTCPay Server stays private when this is turned off.",
|
||||
"category": "bitcoin",
|
||||
"needs_domain": True,
|
||||
"domain_name": "btcpayserver",
|
||||
@@ -395,11 +393,10 @@ FEATURE_SERVICE_MAP = {
|
||||
|
||||
# Port requirements for service tiles (keyed by unit name or icon)
|
||||
_PORTS_ELEMENT_CALLING = [
|
||||
{"port": "7881", "protocol": "TCP", "description": "LiveKit WebRTC signalling"},
|
||||
{"port": "7882", "protocol": "UDP", "description": "LiveKit media (UDP mux)"},
|
||||
{"port": "5349", "protocol": "TCP", "description": "TURN over TLS"},
|
||||
{"port": "3478", "protocol": "UDP", "description": "TURN (STUN/relay)"},
|
||||
{"port": "30000-40000", "protocol": "TCP/UDP", "description": "TURN relay (WebRTC)"},
|
||||
{"port": "7881", "protocol": "TCP", "description": "WebRTC media (TCP fallback)"},
|
||||
{"port": "7882", "protocol": "UDP", "description": "WebRTC media (UDP)"},
|
||||
{"port": "3478", "protocol": "UDP", "description": "TURN relay + STUN"},
|
||||
{"port": "40000-40099", "protocol": "UDP", "description": "TURN relay (WebRTC media)"},
|
||||
]
|
||||
|
||||
# Units whose port requirements exist purely so the user can forward them in
|
||||
@@ -454,7 +451,7 @@ ROLE_LABELS = {
|
||||
ROLE_CATEGORIES: dict[str, set[str] | None] = {
|
||||
"server_plus_desktop": None,
|
||||
"desktop": {"infrastructure", "support", "feature-manager"},
|
||||
"node": {"infrastructure", "bitcoin-base", "bitcoin-apps", "support", "feature-manager"},
|
||||
"node": {"infrastructure", "bitcoin", "support", "feature-manager"},
|
||||
}
|
||||
|
||||
# Features shown per role (None = show all)
|
||||
@@ -545,9 +542,9 @@ SERVICE_DESCRIPTIONS: dict[str, str] = {
|
||||
"Addresses on your Sovran_SystemsOS node."
|
||||
),
|
||||
"gnome-remote-desktop.service": (
|
||||
"Access your server's full desktop environment from anywhere using any RDP client. "
|
||||
"Manage your system visually without being physically present. "
|
||||
"Sovran_SystemsOS sets up secure remote access with generated credentials — connect and go."
|
||||
"Access your desktop from a device on your home network — no router setup needed. "
|
||||
"Sovran_SystemsOS creates the address, username, and password for you: "
|
||||
"just open an RDP app and connect."
|
||||
),
|
||||
"sshd.service": (
|
||||
"Secure Shell (SSH) remote access. When enabled, authorized users can connect "
|
||||
@@ -910,11 +907,68 @@ def _get_remote_rev(branch=None):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_version(text):
|
||||
"""Return a (major, minor, patch) tuple from a VERSION string, or None."""
|
||||
try:
|
||||
match = re.search(r"(\d+)\.(\d+)\.(\d+)", str(text))
|
||||
if match:
|
||||
return tuple(int(g) for g in match.groups())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_remote_version(branch=None):
|
||||
"""Read VERSION on the tracked branch, e.g. the stable release version."""
|
||||
try:
|
||||
ref = branch or "stable"
|
||||
url = (
|
||||
"https://git.sovransystems.com/api/v1/repos/"
|
||||
"Sovran_Systems/Sovran_SystemsOS/raw/VERSION?ref="
|
||||
+ urllib.parse.quote(ref)
|
||||
)
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return _parse_version(resp.read().decode())
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_for_updates() -> bool | None:
|
||||
"""Whether an update is available.
|
||||
|
||||
Primary signal: the flake lock's pinned Sovran_Systems rev differs from
|
||||
the remote branch head. BUT a failed update rewrites ``flake.lock`` (the
|
||||
``nix flake update`` step) without staging a generation (the
|
||||
``nixos-rebuild boot`` step failed), so after a failure the lock and the
|
||||
remote agree while the *running* system is still on the old version. In
|
||||
that case the rev comparison alone reports a false "up to date" and hides
|
||||
the failed update from the dashboard.
|
||||
|
||||
Backstop: compare the running Hub version against the branch VERSION.
|
||||
A newer released version while running an older one means the update did
|
||||
not apply (build failed, reboot skipped, generation rolled back) and must
|
||||
be offered again.
|
||||
"""
|
||||
locked_rev, branch = _get_locked_info()
|
||||
remote_rev = _get_remote_rev(branch)
|
||||
if locked_rev and remote_rev:
|
||||
return locked_rev != remote_rev
|
||||
rev_differs = locked_rev != remote_rev
|
||||
if rev_differs:
|
||||
return True
|
||||
# Revs match — make sure the pinned (failed) rev isn't masking an
|
||||
# older *running* system.
|
||||
running_ver = _parse_version(_get_sovran_version())
|
||||
remote_ver = _get_remote_version(branch)
|
||||
if running_ver and remote_ver and remote_ver > running_ver:
|
||||
return True
|
||||
return False
|
||||
# Couldn't compare revs — fall back to the version backstop.
|
||||
running_ver = _parse_version(_get_sovran_version())
|
||||
remote_ver = _get_remote_version(branch)
|
||||
if running_ver and remote_ver:
|
||||
return remote_ver > running_ver
|
||||
return None # inconclusive — couldn't read lock or reach remote
|
||||
|
||||
|
||||
@@ -1114,7 +1168,7 @@ def _get_firewall_allowed_ports() -> dict[str, set[int]]:
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
for line in proc.stdout.splitlines():
|
||||
# e.g. ACCEPT tcp -- ... dpt:443 or dpts:7882:7894
|
||||
# e.g. ACCEPT tcp -- ... dpt:443 or dpts:40000:40099
|
||||
m = re.search(r'(tcp|udp).*dpts?:(\d+)(?::(\d+))?', line)
|
||||
if m:
|
||||
proto_match = m.group(1)
|
||||
@@ -1172,7 +1226,7 @@ def _resolve_all_addresses_cached(domain: str) -> list[str]:
|
||||
|
||||
|
||||
def _port_range_to_ints(port_str: str) -> list[int]:
|
||||
"""Convert a port string like ``"443"``, ``"30000-40000"`` to a list of ints."""
|
||||
"""Convert a port string like ``"443"``, ``"40000-40099"`` to a list of ints."""
|
||||
port_str = port_str.strip()
|
||||
if re.match(r'^\d+$', port_str):
|
||||
return [int(port_str)]
|
||||
@@ -3887,6 +3941,11 @@ async def api_updates_check():
|
||||
# Avoid a slow remote update check when there is already an operation
|
||||
# the dashboard needs to surface.
|
||||
return {"available": True, "status": status.lower()}
|
||||
if status == "FAILED":
|
||||
# The last update did not complete (build failed). Keep offering the
|
||||
# update so the user can re-run it rather than silently landing on a
|
||||
# false "up to date".
|
||||
return {"available": True, "status": "failed"}
|
||||
|
||||
available = await loop.run_in_executor(None, check_for_updates)
|
||||
# None means inconclusive (check failed) — report as available so the UI doesn't block
|
||||
@@ -3964,8 +4023,15 @@ async def api_updates_run():
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Re-read status: a prior failed update leaves flake.lock advanced even
|
||||
# though no generation was staged, so the rev-based check below can say
|
||||
# "no updates" even though the system is still old. A failed update must
|
||||
# always be re-runnable to recover.
|
||||
persisted_status = await loop.run_in_executor(None, _read_update_status)
|
||||
last_failed = persisted_status == "FAILED"
|
||||
|
||||
available = await loop.run_in_executor(None, check_for_updates)
|
||||
if available is False: # only block when positively confirmed no updates
|
||||
if available is False and not last_failed: # only block when positively confirmed no updates
|
||||
# Clear stale status/log so they don't contaminate future modal opens.
|
||||
_write_update_status("IDLE")
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
/* Sovran_SystemsOS Hub — Web UI Stylesheet
|
||||
Dark theme — near-black with green accents matching the Sovran Hub icon
|
||||
v8 — Black-forward, green used for accents/borders/highlights only */
|
||||
The Hub redesign — softer dark theme, GNOME 50 / libadwaita surfaces,
|
||||
Sovran green reserved for status and actions.
|
||||
|
||||
Design tokens are defined once here. Legacy variable names from the
|
||||
previous theme are aliased to the new values so every stylesheet
|
||||
(support, security, domain-setup, onboarding, …) re-skins without
|
||||
needing per-rule edits. */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
@@ -9,22 +14,48 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-color: #080a09;
|
||||
--surface-color: rgba(14, 16, 15, 0.7);
|
||||
--card-color: rgba(20, 22, 21, 0.6);
|
||||
--border-color: rgba(255, 255, 255, 0.06);
|
||||
--text-primary: #ecf3ef;
|
||||
--text-secondary: #8aaa9a;
|
||||
--text-dim: #4a6658;
|
||||
--accent-color: #5EAD8A;
|
||||
--green: #6DBF8B;
|
||||
--yellow: #e5a50a;
|
||||
--red: #e01b24;
|
||||
--grey: #5E7A6A;
|
||||
--radius-card: 18px;
|
||||
--radius-btn: 8px;
|
||||
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
--shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
color-scheme: dark;
|
||||
|
||||
/* ── The Hub palette ─────────────────────────────────────────── */
|
||||
--bg: #17191d;
|
||||
--surface: #1c1f24;
|
||||
--card: #23272c;
|
||||
--card-hover: #292e34;
|
||||
--elevated: #26292e;
|
||||
--inset: #121417;
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--text: #e9edec;
|
||||
--text-2: #a9b0b3;
|
||||
--text-3: #7a8388;
|
||||
--accent: #3ecf8e;
|
||||
--accent-strong: #42f39a;
|
||||
--accent-deep: #1aa45d;
|
||||
--accent-dim: rgba(66, 243, 154, 0.12);
|
||||
--amber: #e9b64a;
|
||||
--red: #f66151;
|
||||
--blue: #78aeed;
|
||||
--mono: 'JetBrains Mono', 'Fira Code', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--radius-card: 20px;
|
||||
--radius-dialog: 26px;
|
||||
--shadow-card: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
--shadow-hover: 0 16px 36px rgba(0, 0, 0, 0.38);
|
||||
--shadow-pop: 0 30px 80px rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* ── Legacy aliases (previous theme) — keep every old sheet working */
|
||||
--bg-color: var(--bg);
|
||||
--surface-color: var(--surface);
|
||||
--card-color: var(--card);
|
||||
--border-color: var(--border);
|
||||
--text-primary: var(--text);
|
||||
--text-secondary: var(--text-2);
|
||||
--text-dim: var(--text-3);
|
||||
--accent-color: var(--accent);
|
||||
--green: var(--accent);
|
||||
--yellow: var(--amber);
|
||||
--red: var(--red);
|
||||
--grey: var(--text-3);
|
||||
--radius-btn: 12px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
@@ -32,19 +63,27 @@ html, body {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background:
|
||||
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
|
||||
var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
font-family: Inter, Cantarell, 'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Login page ──────────────────────────────────────────────────── */
|
||||
button { font-family: inherit; }
|
||||
|
||||
::selection { background: rgba(66, 243, 154, 0.25); }
|
||||
|
||||
/* ── Scrollbars ─────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.10); border-radius: 99px; border: 2px solid transparent; background-clip: padding-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.18); background-clip: padding-box; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* ── Login page ─────────────────────────────────────────────────── */
|
||||
|
||||
.login-wrapper {
|
||||
display: flex;
|
||||
@@ -52,86 +91,103 @@ body {
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background-color: rgba(14, 16, 15, 0.75);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
padding: 48px 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
max-width: 402px;
|
||||
text-align: center;
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 28px;
|
||||
padding: 46px 42px 38px;
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
margin-bottom: 18px;
|
||||
filter: drop-shadow(0 8px 24px rgba(28, 196, 120, 0.3));
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.login-title em { font-style: normal; color: var(--accent); }
|
||||
|
||||
.login-sub {
|
||||
color: var(--text-2);
|
||||
font-size: 0.85rem;
|
||||
margin: 7px 0 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--card-color);
|
||||
color: var(--text-primary);
|
||||
padding: 11px 16px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 14px;
|
||||
background: var(--inset);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.92rem;
|
||||
outline: 0;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
border-color: rgba(66, 243, 154, 0.55);
|
||||
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.13);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
font-size: 0.95rem;
|
||||
border-radius: 99px;
|
||||
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||
color: #04220f;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
opacity: 0.88;
|
||||
.btn-login:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background-color: rgba(224, 27, 36, 0.12);
|
||||
border: 1px solid var(--red);
|
||||
color: #f87171;
|
||||
background: rgba(246, 97, 81, 0.10);
|
||||
border: 1px solid rgba(246, 97, 81, 0.35);
|
||||
color: #f8a39b;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -5,34 +5,134 @@ button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: opacity 0.15s, box-shadow 0.15s, background-color 0.15s;
|
||||
transition: background 0.16s, border-color 0.16s, color 0.16s, transform 0.16s, filter 0.16s, opacity 0.15s;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-radius: 99px;
|
||||
padding: 8px 18px;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn svg { width: 15px; height: 15px; }
|
||||
.btn:active { transform: scale(0.97); }
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||
color: #04220f;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Update System button: uses accent green by default */
|
||||
.btn-primary:active:not(:disabled) { transform: scale(0.97); }
|
||||
|
||||
/* Ghost — the default secondary action (legacy: btn-close-modal / btn-save) */
|
||||
.btn-ghost,
|
||||
.btn-close-modal,
|
||||
.btn-save {
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled),
|
||||
.btn-close-modal:hover:not(:disabled),
|
||||
.btn-save:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Blue — restart / retry actions (amber reads as an error) */
|
||||
.btn-amber,
|
||||
.btn-reboot,
|
||||
.btn-restart-amber,
|
||||
.btn-warning {
|
||||
background: rgba(120, 174, 237, 0.10);
|
||||
border-color: rgba(120, 174, 237, 0.35);
|
||||
color: #a5cbf2;
|
||||
}
|
||||
|
||||
.btn-amber:hover:not(:disabled),
|
||||
.btn-reboot:hover:not(:disabled),
|
||||
.btn-restart-amber:hover:not(:disabled),
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: rgba(120, 174, 237, 0.18);
|
||||
border-color: rgba(120, 174, 237, 0.55);
|
||||
color: #c2dcf8;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(246, 97, 81, 0.10);
|
||||
border-color: rgba(246, 97, 81, 0.35);
|
||||
color: #f8a39b;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(246, 97, 81, 0.18);
|
||||
border-color: rgba(246, 97, 81, 0.5);
|
||||
}
|
||||
|
||||
.btn-sm { padding: 6px 14px; font-size: 0.77rem; }
|
||||
|
||||
/* ── Header / topbar buttons ────────────────────────────────────── */
|
||||
|
||||
.btn-header-reboot {
|
||||
background: rgba(120, 174, 237, 0.10);
|
||||
border: 1px solid rgba(120, 174, 237, 0.35);
|
||||
color: #a5cbf2;
|
||||
font-size: 0.77rem;
|
||||
font-weight: 700;
|
||||
padding: 6px 14px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.btn-header-reboot:hover:not(:disabled) {
|
||||
background: rgba(120, 174, 237, 0.18);
|
||||
border-color: rgba(120, 174, 237, 0.55);
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-2);
|
||||
font-size: 0.77rem;
|
||||
font-weight: 700;
|
||||
padding: 6px 14px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* ── Update System button (sidebar) ─────────────────────────────── */
|
||||
|
||||
.btn-update {
|
||||
background-color: #4A9474;
|
||||
color: #E0F2EA;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-2);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -40,24 +140,22 @@ button:disabled {
|
||||
}
|
||||
|
||||
.btn-update:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Update System button: brighter green when updates are available */
|
||||
.btn-update.has-updates {
|
||||
background-color: #5EAD8A;
|
||||
color: #0A1A10;
|
||||
}
|
||||
|
||||
.btn-update.has-updates:hover:not(:disabled) {
|
||||
background-color: #78C8A2;
|
||||
background: rgba(66, 243, 154, 0.12);
|
||||
border-color: rgba(66, 243, 154, 0.4);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.update-badge {
|
||||
display: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: var(--yellow);
|
||||
background-color: var(--amber);
|
||||
border-radius: 50%;
|
||||
animation: pulse-badge 1.4s ease-in-out infinite;
|
||||
}
|
||||
@@ -71,9 +169,11 @@ button:disabled {
|
||||
50% { opacity: 0.5; transform: scale(1.35); }
|
||||
}
|
||||
|
||||
/* ── Icon buttons ───────────────────────────────────────────────── */
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-2);
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.1rem;
|
||||
@@ -81,6 +181,6 @@ button:disabled {
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ domain-field-label {
|
||||
|
||||
domain-field-input {
|
||||
width: 100%;
|
||||
background-color: #0c0f0e;
|
||||
background-color: var(--inset);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
@@ -75,7 +75,7 @@ domain-field-actions {
|
||||
|
||||
.domain-field-input {
|
||||
width: 100%;
|
||||
background-color: #0c0f0e;
|
||||
background-color: var(--inset);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
@@ -198,21 +198,21 @@ domain-field-actions {
|
||||
}
|
||||
|
||||
.port-proto-badge--tcp {
|
||||
color: #6dbf8b;
|
||||
background: rgba(109, 191, 139, 0.12);
|
||||
border: 1px solid rgba(109, 191, 139, 0.4);
|
||||
color: var(--accent);
|
||||
background: rgba(66, 243, 154, 0.12);
|
||||
border: 1px solid rgba(66, 243, 154, 0.4);
|
||||
}
|
||||
|
||||
.port-proto-badge--udp {
|
||||
color: #6aa9e0;
|
||||
color: var(--blue);
|
||||
background: rgba(106, 169, 224, 0.12);
|
||||
border: 1px solid rgba(106, 169, 224, 0.4);
|
||||
}
|
||||
|
||||
.port-proto-badge--both {
|
||||
color: #e5a50a;
|
||||
background: rgba(229, 165, 10, 0.12);
|
||||
border: 1px solid rgba(229, 165, 10, 0.45);
|
||||
color: var(--amber);
|
||||
background: rgba(233, 182, 74, 0.12);
|
||||
border: 1px solid rgba(233, 182, 74, 0.45);
|
||||
}
|
||||
|
||||
.port-proto-note {
|
||||
@@ -230,7 +230,7 @@ domain-field-actions {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255, 180, 0, 0.10);
|
||||
border: 1px solid var(--warning-color, #f59e0b);
|
||||
border: 1px solid var(--warning-color, var(--amber));
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
|
||||
@@ -1,143 +1,196 @@
|
||||
/* ── Feature Manager styles ──────────────────────────────────────── */
|
||||
/* ── Sidebar: Feature Manager + Preferences ─────────────────────── */
|
||||
|
||||
.feature-manager-section {
|
||||
margin-bottom: 32px;
|
||||
.category-section.autolaunch-section,
|
||||
.category-section.feature-manager-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.feature-subcategory {
|
||||
margin-bottom: 16px;
|
||||
/* The old "Preferences" header + hr becomes a small sidebar label.
|
||||
features.js renders: .section-header + hr.section-divider + cards */
|
||||
.autolaunch-section .section-header,
|
||||
.feature-manager-section .section-header {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
margin: 0;
|
||||
padding: 14px 10px 7px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.autolaunch-section .section-divider,
|
||||
.feature-manager-section .section-divider {
|
||||
background: none;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feature-cards-wrap { display: flex; flex-direction: column; }
|
||||
|
||||
.feature-subcategory { display: flex; flex-direction: column; }
|
||||
|
||||
.feature-subcategory-header {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.feature-cards-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--text-3);
|
||||
padding: 12px 10px 6px;
|
||||
}
|
||||
|
||||
/* Feature card — sidebar preference row with a switch */
|
||||
.feature-card {
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.feature-card:hover { background: rgba(255, 255, 255, 0.05); }
|
||||
|
||||
.feature-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.feature-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.feature-card-info { min-width: 0; flex: 1; }
|
||||
|
||||
.feature-card-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.feature-card-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-top: 2px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-3);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.feature-card-status {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.feature-card-status.status-on {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
border-color: rgba(66, 243, 154, 0.3);
|
||||
}
|
||||
|
||||
.feature-conflict-warning {
|
||||
margin-top: 8px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--amber);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Domain badges inside feature cards */
|
||||
.feature-domain-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.feature-domain-badge.configured {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.feature-domain-badge.not-configured {
|
||||
color: var(--amber);
|
||||
background: rgba(233, 182, 74, 0.10);
|
||||
}
|
||||
|
||||
.feature-domain-icon { font-size: 0.8rem; }
|
||||
|
||||
.feature-domain-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 650;
|
||||
margin-top: 5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.feature-domain-label--ok { color: var(--accent); }
|
||||
.feature-domain-label--warn { color: var(--amber); }
|
||||
.feature-domain-label--error { color: var(--red); }
|
||||
.feature-domain-label--checking { color: var(--blue); }
|
||||
|
||||
/* ── Toggle switch (libadwaita style) ───────────────────────────── */
|
||||
|
||||
.feature-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
margin-top: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feature-toggle-input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feature-toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 24px;
|
||||
transition: background-color 0.2s;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid var(--border-strong);
|
||||
transition: 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.feature-toggle-slider::before {
|
||||
.feature-toggle-slider::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background-color: #fff;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s;
|
||||
background: #cfd8d2;
|
||||
transition: 0.2s cubic-bezier(0.3, 0.8, 0.4, 1.2);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.feature-toggle.active .feature-toggle-slider {
|
||||
background-color: var(--green);
|
||||
.feature-toggle-input:checked + .feature-toggle-slider {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.feature-toggle.active .feature-toggle-slider::before {
|
||||
transform: translateX(20px);
|
||||
.feature-toggle-input:checked + .feature-toggle-slider::after {
|
||||
left: 23px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.feature-domain-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.feature-domain-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-domain-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.feature-domain-label--checking {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.feature-domain-label--ok {
|
||||
color: var(--green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-domain-label--warn {
|
||||
color: var(--yellow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-domain-label--error {
|
||||
color: var(--red);
|
||||
font-weight: 600;
|
||||
.feature-toggle-input:focus-visible + .feature-toggle-slider {
|
||||
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.3);
|
||||
}
|
||||
|
||||
@@ -1,69 +1,93 @@
|
||||
/* ── Header bar ─────────────────────────────────────────────────── */
|
||||
/* ── Topbar (replaces the old header-bar + ip-bar) ──────────────── */
|
||||
|
||||
.header-bar {
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
background-color: rgba(10, 12, 11, 0.82);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 8px 24px;
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 80px;
|
||||
width: auto;
|
||||
display: block;
|
||||
gap: 14px;
|
||||
padding: 13px 26px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.header-bar .title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
.page-title {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
/* ── Search ─────────────────────────────────────────────────────── */
|
||||
|
||||
.header-buttons {
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 9px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 99px;
|
||||
padding: 8px 16px;
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
min-width: 140px;
|
||||
margin-left: 18px;
|
||||
color: var(--text-3);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.search-box:focus-within {
|
||||
border-color: rgba(66, 243, 154, 0.35);
|
||||
box-shadow: 0 0 0 3px rgba(66, 243, 154, 0.09);
|
||||
}
|
||||
|
||||
.search-box svg { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
|
||||
.search-box input {
|
||||
background: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 0.87rem;
|
||||
}
|
||||
|
||||
.search-box input::placeholder { color: var(--text-3); }
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
margin-left: auto;
|
||||
order: 5;
|
||||
}
|
||||
|
||||
/* ── Legacy badges (still used inside dialogs) ──────────────────── */
|
||||
|
||||
.role-badge {
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-2);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.03em;
|
||||
font-weight: 750;
|
||||
padding: 6px 12px;
|
||||
border-radius: 99px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── OS Version Badge — identical styling to the version badge ────
|
||||
── shown next to titles in the service modal windows ──────────── */
|
||||
.os-version-badge {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-2);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 2px 9px;
|
||||
border-radius: 99px;
|
||||
border: 1px solid var(--border);
|
||||
letter-spacing: 0.02em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -71,76 +95,13 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── IP bar ─────────────────────────────────────────────────────── */
|
||||
|
||||
.ip-bar {
|
||||
background-color: rgba(10, 12, 11, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ip-bar .ip-label {
|
||||
color: var(--text-dim);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.ip-bar .ip-value {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ip-separator {
|
||||
color: var(--border-color);
|
||||
}
|
||||
.btn-logout {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* ── Header reboot button ───────────────────────────────────────── */
|
||||
|
||||
.btn-header-reboot {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(184, 125, 0, 0.35);
|
||||
color: #c98d08;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-header-reboot:hover {
|
||||
border-color: #b87d00;
|
||||
color: #e0a010;
|
||||
background-color: rgba(184, 125, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.btn-header-reboot {
|
||||
padding: 4px 8px;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.btn-logout {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,138 +1,533 @@
|
||||
/* ── Main content ───────────────────────────────────────────────── */
|
||||
/* ── App shell: sidebar + main column ───────────────────────────── */
|
||||
|
||||
.main-content {
|
||||
.app {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 26px 30px 70px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────────── */
|
||||
|
||||
.sidebar {
|
||||
width: 270px;
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background-color: rgba(12, 14, 13, 0.65);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
padding: 20px 14px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 20px 14px 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── Sidebar: Tech Support button ───────────────────────────────── */
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 2px 8px 20px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 4px 14px rgba(28, 196, 120, 0.25));
|
||||
}
|
||||
|
||||
.brand-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-title em { font-style: normal; color: var(--accent); }
|
||||
|
||||
.brand-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-3);
|
||||
font-family: var(--mono);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-3);
|
||||
padding: 14px 10px 7px;
|
||||
}
|
||||
|
||||
/* Category nav (dashboard.js) */
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; }
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text);
|
||||
box-shadow: inset 2.5px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nav-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.67rem;
|
||||
font-weight: 750;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-count {
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* System items (tiles.js renders these as .sidebar-support-btn) */
|
||||
.sidebar-support-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-style 0.15s, border-color 0.15s, background-color 0.15s;
|
||||
text-align: left;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-support-btn:hover {
|
||||
border-color: var(--accent-color);
|
||||
border-style: solid;
|
||||
background-color: #162320;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-support-btn + .sidebar-support-btn {
|
||||
margin-top: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sidebar-support-icon {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1rem;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-support-icon svg { width: 17px; height: 17px; }
|
||||
|
||||
.sidebar-support-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-support-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-support-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 16px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
/* ── Upgrade modal ──────────────────────────────────────────────── */
|
||||
.sidebar-spacer { flex: 1; min-height: 18px; }
|
||||
|
||||
.upgrade-dialog {
|
||||
max-width: 480px;
|
||||
/* ── Welcome dashboard (default view) ───────────────────────────── */
|
||||
|
||||
.welcome {
|
||||
position: relative;
|
||||
/* Full-bleed: cancel .content's padding so the glow reaches
|
||||
every edge of the panel (sidebar border, topbar, viewport). */
|
||||
margin: -26px -30px -70px;
|
||||
padding: 70px 38px 70px;
|
||||
overflow: hidden;
|
||||
min-height: calc(100% + 96px);
|
||||
}
|
||||
|
||||
.upgrade-info-box {
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 14px;
|
||||
/* Soft drifting glow — calm, GPU-cheap (transforms only) */
|
||||
.welcome-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.upgrade-info-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.orb-a {
|
||||
width: 560px; height: 560px;
|
||||
left: -140px; top: -180px;
|
||||
background: radial-gradient(circle, rgba(62, 207, 142, 0.10), transparent 70%);
|
||||
animation: orb-drift-a 26s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.orb-b {
|
||||
width: 680px; height: 680px;
|
||||
right: -200px; top: 20%;
|
||||
background: radial-gradient(circle, rgba(120, 174, 237, 0.07), transparent 70%);
|
||||
animation: orb-drift-b 34s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.orb-c {
|
||||
width: 460px; height: 460px;
|
||||
left: 34%; bottom: -200px;
|
||||
background: radial-gradient(circle, rgba(66, 243, 154, 0.06), transparent 70%);
|
||||
animation: orb-drift-c 42s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes orb-drift-a { to { transform: translate(70px, 50px) scale(1.14); } }
|
||||
@keyframes orb-drift-b { to { transform: translate(-60px, -70px) scale(1.10); } }
|
||||
@keyframes orb-drift-c { to { transform: translate(50px, -40px) scale(1.18); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.orb { animation: none; }
|
||||
.welcome-inner { animation: none; }
|
||||
}
|
||||
|
||||
/* ── Boot splash ────────────────────────────────────────────────── */
|
||||
/* Covers the shell while the first services/config data loads; the
|
||||
inline script in index.html lifts it (window.__liftAppSplash). */
|
||||
#app-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.45s ease;
|
||||
}
|
||||
|
||||
#app-splash.done { opacity: 0; pointer-events: none; }
|
||||
|
||||
.splash-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.splash-ring {
|
||||
position: relative;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upgrade-info-list {
|
||||
padding-left: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
.splash-ring img { width: 60px; height: 60px; opacity: 0.95; }
|
||||
|
||||
.splash-ring-arc {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(255, 255, 255, 0.10);
|
||||
border-top-color: var(--accent);
|
||||
animation: splash-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.upgrade-info-list a {
|
||||
color: var(--accent-color);
|
||||
@keyframes splash-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.splash-title {
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.upgrade-rebuild-note {
|
||||
font-style: italic;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.82rem;
|
||||
.splash-sub {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.splash-ring-arc { animation-duration: 2.5s; }
|
||||
}
|
||||
|
||||
.welcome-inner {
|
||||
position: relative;
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
animation: welcome-in 0.5s ease both;
|
||||
}
|
||||
|
||||
@keyframes welcome-in {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
}
|
||||
|
||||
.welcome-greeting {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
margin-top: 6px;
|
||||
font-size: clamp(1.7rem, 2.8vw, 2.25rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.welcome-title em { font-style: normal; color: var(--accent); }
|
||||
|
||||
.welcome-meta {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-3);
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.welcome-meta-sep { margin: 0 8px; opacity: 0.6; }
|
||||
|
||||
.welcome-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-auto-rows: 1fr; /* both rows share the taller height → uniform cards */
|
||||
gap: 16px;
|
||||
margin-top: 34px;
|
||||
}
|
||||
|
||||
/* Three cards (Desktop-only role, no Bitcoin card) → one symmetric row */
|
||||
@media (min-width: 681px) {
|
||||
.welcome-cards:not(:has(#w-btc)) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* wrappers whose children become grid items */
|
||||
.welcome-dyn { display: contents; }
|
||||
|
||||
.w-network .net-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.w-network .net-k {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-3);
|
||||
width: 30px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.w-network .ip-value {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.w-network .sub { margin-top: 8px; }
|
||||
|
||||
.welcome-browse {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.widget {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 17px 19px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03), var(--shadow-card);
|
||||
transition: border-color 0.18s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 88px; /* uniform card height everywhere */
|
||||
}
|
||||
|
||||
.widget:hover { border-color: var(--border-strong); }
|
||||
.widget.clickable:hover { transform: translateY(-1px); }
|
||||
|
||||
.widget.clickable { cursor: pointer; }
|
||||
.widget.clickable:focus-visible {
|
||||
outline: 2px solid rgba(66, 243, 154, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.widget-chip {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 14px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 6px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.widget-chip svg { width: 22px; height: 22px; }
|
||||
.widget-chip img { width: 46px; height: 46px; display: block; object-fit: contain; }
|
||||
|
||||
.widget-chip.chip-green {
|
||||
background: linear-gradient(160deg, #2f9f6b, #17683f);
|
||||
}
|
||||
|
||||
.widget-chip.chip-btc {
|
||||
background: linear-gradient(160deg, #f7931a, #b96a0a);
|
||||
}
|
||||
|
||||
.widget-chip.chip-amber {
|
||||
background: linear-gradient(160deg, #b98426, #8a5f16);
|
||||
}
|
||||
|
||||
.widget-chip.chip-sovran {
|
||||
background: linear-gradient(160deg, #42f39a, #1aa45d);
|
||||
}
|
||||
|
||||
.widget-chip.chip-neutral {
|
||||
background: linear-gradient(160deg, #2e333a, #23272c);
|
||||
}
|
||||
|
||||
.w-body { min-width: 0; }
|
||||
|
||||
.widget h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.005em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.widget .sub {
|
||||
margin-top: 4px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.widget .sub b { color: var(--text); font-weight: 650; }
|
||||
.widget .sub .good { color: var(--accent); font-weight: 650; }
|
||||
.widget .sub .warn { color: var(--amber); font-weight: 650; }
|
||||
|
||||
.w-bar {
|
||||
height: 7px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
overflow: hidden;
|
||||
margin: 8px 0 7px;
|
||||
}
|
||||
|
||||
.w-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 99px;
|
||||
background: linear-gradient(90deg, #42f39a, #1aa45d);
|
||||
transition: width 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
|
||||
}
|
||||
|
||||
|
||||
/* ── Tiles area ─────────────────────────────────────────────────── */
|
||||
|
||||
#tiles-area {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 24px 20px 48px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 72px 24px;
|
||||
color: var(--text-3);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2.5px solid rgba(255, 255, 255, 0.12);
|
||||
border-top-color: var(--accent);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Category sections ──────────────────────────────────────────── */
|
||||
|
||||
.category-section {
|
||||
@@ -140,25 +535,30 @@
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
padding-left: 4px;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-bottom: 16px;
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--border), transparent);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tiles-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
/* ── Empty state ────────────────────────────────────────────────── */
|
||||
@@ -166,10 +566,103 @@
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-dim);
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Upgrade modal (kept from previous layout) ──────────────────── */
|
||||
|
||||
.upgrade-dialog {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.upgrade-info-box {
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.upgrade-info-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upgrade-info-list {
|
||||
padding-left: 20px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upgrade-info-list a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.upgrade-rebuild-note {
|
||||
font-style: italic;
|
||||
color: var(--text-3);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* ── First-login security banner (inside .content) ──────────────── */
|
||||
|
||||
.security-first-login-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(233, 182, 74, 0.08);
|
||||
border: 1px solid rgba(233, 182, 74, 0.30);
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-2);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* ── Narrow viewports (phone / half-screen RDP) ──────────────────── */
|
||||
/* The sidebar becomes an icon rail so the content keeps room, and the
|
||||
topbar wraps its search onto a second row instead of overflowing. */
|
||||
@media (max-width: 920px) {
|
||||
.sidebar {
|
||||
width: 76px;
|
||||
padding: 16px 8px 12px;
|
||||
}
|
||||
|
||||
.brand { justify-content: center; padding: 2px 0 18px; gap: 0; }
|
||||
.brand-text,
|
||||
.nav-label,
|
||||
.nav-text,
|
||||
.nav-count,
|
||||
.sidebar-support-text,
|
||||
.sidebar-divider,
|
||||
#sidebar-features { display: none; }
|
||||
|
||||
.nav-item { justify-content: center; gap: 0; padding: 10px 0; }
|
||||
.sidebar-support-btn { justify-content: center; gap: 0; padding: 10px 0; }
|
||||
|
||||
.content { padding: 18px 18px 40px; }
|
||||
|
||||
/* keep the welcome background full-bleed against the new padding */
|
||||
.welcome {
|
||||
margin: -18px -18px -40px;
|
||||
padding: 48px 20px 40px;
|
||||
min-height: calc(100% + 58px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar { flex-wrap: wrap; row-gap: 10px; padding: 12px 16px; }
|
||||
.search-box { order: 5; flex-basis: 100%; }
|
||||
|
||||
.welcome { padding: 40px 16px 36px; }
|
||||
.welcome-cards { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
||||
justify-content: flex-start;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(ellipse at top, rgba(94, 173, 138, 0.04) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at top, rgba(62, 207, 142, 0.04) 0%, transparent 50%),
|
||||
var(--bg-color);
|
||||
padding: 24px 16px 48px;
|
||||
overflow-y: auto;
|
||||
@@ -70,13 +70,13 @@
|
||||
.onboarding-step-dot.active {
|
||||
background-color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
color: #04220f;
|
||||
}
|
||||
|
||||
.onboarding-step-dot.completed {
|
||||
background-color: var(--green);
|
||||
border-color: var(--green);
|
||||
color: #0A1A10;
|
||||
color: #04220f;
|
||||
}
|
||||
|
||||
.onboarding-step-connector {
|
||||
@@ -139,7 +139,7 @@
|
||||
/* Cards */
|
||||
|
||||
.onboarding-card {
|
||||
background-color: rgba(14, 16, 15, 0.65);
|
||||
background-color: rgba(18, 24, 20, 0.65);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
@@ -184,10 +184,10 @@
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color);
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(94, 173, 138, 0.25);
|
||||
border: 1px solid rgba(62, 207, 142, 0.25);
|
||||
}
|
||||
|
||||
/* Step header */
|
||||
@@ -371,8 +371,8 @@
|
||||
|
||||
.onboarding-port-warn {
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(229, 165, 10, 0.1);
|
||||
border: 1px solid rgba(229, 165, 10, 0.35);
|
||||
background-color: rgba(233, 182, 74, 0.1);
|
||||
border: 1px solid rgba(233, 182, 74, 0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--yellow);
|
||||
@@ -409,8 +409,8 @@
|
||||
|
||||
.onboarding-creds-notice {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(94, 173, 138, 0.08);
|
||||
border: 1px solid rgba(94, 173, 138, 0.20);
|
||||
background-color: rgba(62, 207, 142, 0.08);
|
||||
border: 1px solid rgba(62, 207, 142, 0.20);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
@@ -503,8 +503,8 @@
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(94, 173, 138, 0.08);
|
||||
border: 1px solid rgba(94, 173, 138, 0.20);
|
||||
background-color: rgba(62, 207, 142, 0.08);
|
||||
border: 1px solid rgba(62, 207, 142, 0.20);
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@@ -512,7 +512,7 @@
|
||||
}
|
||||
|
||||
.onboarding-cred-reveal-btn:hover {
|
||||
background-color: rgba(94, 173, 138, 0.15);
|
||||
background-color: rgba(62, 207, 142, 0.15);
|
||||
}
|
||||
|
||||
/* Completion checklist (Step 5) */
|
||||
@@ -671,7 +671,7 @@
|
||||
}
|
||||
|
||||
.onboarding-password-toggle:hover {
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
@@ -683,8 +683,8 @@
|
||||
|
||||
.onboarding-password-warning {
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(229, 165, 10, 0.1);
|
||||
border: 1px solid rgba(229, 165, 10, 0.35);
|
||||
background-color: rgba(233, 182, 74, 0.1);
|
||||
border: 1px solid rgba(233, 182, 74, 0.35);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--yellow);
|
||||
@@ -694,8 +694,8 @@
|
||||
|
||||
.onboarding-password-success {
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
border: 1px solid rgba(94, 173, 138, 0.30);
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
border: 1px solid rgba(62, 207, 142, 0.30);
|
||||
border-radius: 8px;
|
||||
font-size: 0.92rem;
|
||||
color: var(--green);
|
||||
@@ -748,7 +748,7 @@
|
||||
}
|
||||
|
||||
.reboot-card {
|
||||
background-color: rgba(14, 16, 15, 0.8);
|
||||
background-color: rgba(18, 24, 20, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
@@ -823,57 +823,8 @@
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
overflow: auto;
|
||||
}
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
}
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 14px 12px;
|
||||
}
|
||||
#tiles-area {
|
||||
height: auto;
|
||||
overflow-y: visible;
|
||||
padding: 16px 12px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.header-bar {
|
||||
padding: 10px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-bar .title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.ip-bar {
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
.tiles-grid {
|
||||
justify-content: center;
|
||||
}
|
||||
.service-tile {
|
||||
width: 140px;
|
||||
min-height: 130px;
|
||||
}
|
||||
.reboot-card {
|
||||
padding: 36px 28px;
|
||||
margin: 0 16px;
|
||||
}
|
||||
.creds-dialog {
|
||||
margin: 0 12px;
|
||||
}
|
||||
.creds-qr-img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
/* Legacy pre-redesign narrow-screen rules removed: they targeted the old
|
||||
DOM (.main-content, .header-bar, .ip-bar) and their .sidebar /
|
||||
#tiles-area / .service-tile / .creds-qr-img overrides fought the new
|
||||
responsive layout in layout.css (sidebar icon rail, wrapping topbar).
|
||||
Narrow-viewport behavior now lives in layout.css + header.css. */
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
.security-warning-box {
|
||||
background-color: rgba(180, 40, 40, 0.10);
|
||||
border-left: 3px solid #c94040;
|
||||
border-left: 3px solid var(--red);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
@@ -78,7 +78,7 @@
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #c94040;
|
||||
background-color: var(--red);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
@@ -90,7 +90,7 @@
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background-color: #a83030;
|
||||
background-color: var(--red);
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
|
||||
.security-status-info { color: var(--text-secondary); }
|
||||
.security-status-ok { color: #6DBF8B; }
|
||||
.security-status-error { color: #e05252; }
|
||||
.security-status-ok { color: var(--accent); }
|
||||
.security-status-error { color: var(--red); }
|
||||
|
||||
/* ── Verify System Integrity ─────────────────────────────────────── */
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
.security-verify-link {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent-color, #6DBF8B);
|
||||
color: var(--accent-color, var(--accent));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -174,13 +174,13 @@
|
||||
}
|
||||
|
||||
.security-verify-pass {
|
||||
background-color: rgba(109, 191, 139, 0.15);
|
||||
color: #6DBF8B;
|
||||
background-color: rgba(66, 243, 154, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.security-verify-fail {
|
||||
background-color: rgba(224, 82, 82, 0.15);
|
||||
color: #e05252;
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.security-verify-errors {
|
||||
@@ -221,12 +221,11 @@
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(6, 8, 7, 0.94);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
background: radial-gradient(1000px 640px at 50% 18%, #202429, #0f1113 72%);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 26px;
|
||||
animation: security-reset-fade-in 0.35s ease-out;
|
||||
}
|
||||
|
||||
@@ -253,13 +252,13 @@
|
||||
}
|
||||
|
||||
.security-reset-password-box {
|
||||
font-family: monospace;
|
||||
font-family: var(--mono);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
background: rgba(109, 191, 139, 0.10);
|
||||
border: 1.5px solid rgba(109, 191, 139, 0.35);
|
||||
border-radius: 8px;
|
||||
background: var(--accent-dim);
|
||||
border: 1.5px solid rgba(66, 243, 154, 0.35);
|
||||
border-radius: 14px;
|
||||
padding: 14px 24px;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
@@ -277,10 +276,11 @@
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn {
|
||||
background-color: #6DBF8B;
|
||||
color: #0a0c0b;
|
||||
background: linear-gradient(180deg, #3fd68e, #1ea263);
|
||||
color: #04220f;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
border-radius: 99px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28), 0 6px 18px rgba(35, 199, 124, 0.22);
|
||||
padding: 11px 22px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
@@ -290,7 +290,7 @@
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn:hover:not(:disabled) {
|
||||
background-color: #5aab78;
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
.security-reset-reboot-btn:disabled {
|
||||
@@ -303,11 +303,12 @@
|
||||
.security-first-login-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 18px;
|
||||
background-color: rgba(94, 173, 138, 0.08);
|
||||
border-bottom: 2px solid rgba(94, 173, 138, 0.25);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 24px;
|
||||
background: rgba(233, 182, 74, 0.08);
|
||||
border: 1px solid rgba(233, 182, 74, 0.30);
|
||||
border-radius: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -333,19 +334,21 @@
|
||||
|
||||
.security-banner-dismiss {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 99px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 7px;
|
||||
padding: 4px 10px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
transition: background-color 0.15s;
|
||||
transition: 0.15s;
|
||||
}
|
||||
|
||||
.security-banner-dismiss:hover {
|
||||
background-color: rgba(0,0,0,0.08);
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.26);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* ── Legacy security inline warning banner ───────────────────────── */
|
||||
@@ -357,14 +360,14 @@
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background-color: rgba(180, 100, 0, 0.12);
|
||||
border-left: 3px solid #c97a00;
|
||||
border-left: 3px solid #c98d08;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.security-inline-icon {
|
||||
font-size: 1rem;
|
||||
color: #e69000;
|
||||
color: #e0a010;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -378,9 +381,9 @@
|
||||
display: inline-block;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #e69000;
|
||||
color: #e0a010;
|
||||
text-decoration: none;
|
||||
border: 1px solid #c97a00;
|
||||
border: 1px solid #c98d08;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
align-self: flex-start;
|
||||
@@ -447,9 +450,9 @@
|
||||
|
||||
.pw-credentials-note {
|
||||
font-size: 0.78rem;
|
||||
color: #c97a00;
|
||||
color: #c98d08;
|
||||
background-color: rgba(180, 100, 0, 0.10);
|
||||
border-left: 2px solid #c97a00;
|
||||
border-left: 2px solid #c98d08;
|
||||
border-radius: 4px;
|
||||
padding: 7px 10px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
|
||||
.support-steps code {
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
background-color: rgba(62, 207, 142, 0.10);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
@@ -116,7 +116,7 @@
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
color: #04220f;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
@@ -146,7 +146,7 @@
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
color: #04220f;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-top: 16px;
|
||||
@@ -168,7 +168,7 @@
|
||||
}
|
||||
|
||||
.support-btn-auditlog:hover:not(:disabled) {
|
||||
background-color: #1c2a24;
|
||||
background-color: var(--card-hover);
|
||||
}
|
||||
|
||||
.support-fine-print {
|
||||
@@ -219,13 +219,13 @@
|
||||
}
|
||||
|
||||
.support-wallet-protected {
|
||||
background-color: rgba(109, 191, 139, 0.06);
|
||||
border-color: rgba(109, 191, 139, 0.3);
|
||||
background-color: rgba(66, 243, 154, 0.06);
|
||||
border-color: rgba(66, 243, 154, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-unlocked {
|
||||
background-color: rgba(229, 165, 10, 0.06);
|
||||
border-color: rgba(229, 165, 10, 0.3);
|
||||
background-color: rgba(233, 182, 74, 0.06);
|
||||
border-color: rgba(233, 182, 74, 0.3);
|
||||
}
|
||||
|
||||
.support-wallet-warning {
|
||||
@@ -290,7 +290,7 @@
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-btn);
|
||||
background-color: var(--yellow);
|
||||
color: #0A1A10;
|
||||
color: #04220f;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -310,7 +310,7 @@
|
||||
}
|
||||
|
||||
.support-btn-wallet-lock:hover:not(:disabled) {
|
||||
background-color: #529E7E;
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Audit log ───────────────────────────────────────────────────── */
|
||||
@@ -324,7 +324,7 @@
|
||||
.support-audit-log {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: #0c0f0e;
|
||||
background-color: var(--inset);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
@@ -1,473 +1,178 @@
|
||||
/* ── Service tile card (status-only) ─────────────────────────────── */
|
||||
|
||||
.dashboard-loading {
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
animation: dashboard-loading-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes dashboard-loading-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
/* ── Service tiles ──────────────────────────────────────────────── */
|
||||
|
||||
.service-tile {
|
||||
width: 160px;
|
||||
min-height: 130px;
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
padding: 21px 12px 17px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 12px 18px;
|
||||
gap: 0;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: transform 0.18s, border-color 0.18s, background 0.18s, box-shadow 0.18s, opacity 0.18s;
|
||||
animation: tileIn 0.45s cubic-bezier(0.2, 0.8, 0.3, 1) backwards;
|
||||
}
|
||||
|
||||
@keyframes tileIn {
|
||||
from { opacity: 0; transform: translateY(10px) scale(0.96); }
|
||||
}
|
||||
|
||||
.service-tile:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--card-hover);
|
||||
box-shadow: var(--shadow-hover);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.service-tile.disabled {
|
||||
opacity: 0.45;
|
||||
.service-tile:active { transform: translateY(-1px) scale(0.985); }
|
||||
|
||||
.service-tile:focus-visible {
|
||||
outline: 2px solid rgba(66, 243, 154, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.service-tile.disabled { opacity: 0.55; }
|
||||
|
||||
.tile-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tile-icon-fallback {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 12px;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-3);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tile-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
margin-top: 13px;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 650;
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
max-width: 140px;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
min-height: 1.3em;
|
||||
line-height: 1.25;
|
||||
min-height: 2.5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Status pill — dot + label inside a rounded pill */
|
||||
.tile-status {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
margin-top: 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--text-secondary);
|
||||
gap: 6px;
|
||||
font-size: 0.69rem;
|
||||
font-weight: 700;
|
||||
padding: 3px 10px;
|
||||
border-radius: 99px;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.tile-version {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
text-align: center;
|
||||
}
|
||||
.status-text { line-height: 1; }
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
background-color: var(--grey);
|
||||
}
|
||||
|
||||
.status-dot.active { background-color: var(--green); }
|
||||
.status-dot.inactive { background-color: var(--red); }
|
||||
.status-dot.loading { background-color: var(--yellow); animation: pulse-badge 1s infinite; }
|
||||
.status-dot.failed { background-color: var(--red); }
|
||||
.status-dot.disabled { background-color: var(--grey); }
|
||||
.status-dot.needs-attention { background-color: var(--yellow); }
|
||||
.status-dot.syncing { background-color: #f5a623; animation: pulse-badge 1.5s infinite; }
|
||||
.status-dot.checking-reachability { background-color: var(--accent-color); animation: pulse-badge 1s infinite; }
|
||||
.status-dot.active { background: var(--accent); }
|
||||
.status-dot.needs-attention { background: var(--amber); }
|
||||
.status-dot.failed { background: var(--red); }
|
||||
.status-dot.inactive,
|
||||
.status-dot.disabled { background: var(--text-3); }
|
||||
.status-dot.syncing,
|
||||
.status-dot.loading,
|
||||
.status-dot.checking-reachability { background: var(--blue); animation: tile-dot-pulse 1.4s ease-in-out infinite; }
|
||||
.status-dot.unknown { background: var(--text-3); }
|
||||
|
||||
/* ── Bitcoin IBD sync progress bar ──────────────────────────────── */
|
||||
@keyframes tile-dot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* Progressive enhancement: tint the whole pill on modern browsers */
|
||||
.tile-status:has(.status-dot.active) { background: var(--accent-dim); color: var(--accent); }
|
||||
.tile-status:has(.status-dot.needs-attention) { background: rgba(233, 182, 74, 0.10); color: var(--amber); }
|
||||
.tile-status:has(.status-dot.failed) { background: rgba(246, 97, 81, 0.10); color: var(--red); }
|
||||
.tile-status:has(.status-dot.syncing),
|
||||
.tile-status:has(.status-dot.loading),
|
||||
.tile-status:has(.status-dot.checking-reachability) { background: rgba(120, 174, 237, 0.10); color: var(--blue); }
|
||||
|
||||
.support-status-label {
|
||||
font-size: 0.69rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* ── Bitcoin IBD sync tile ──────────────────────────────────────── */
|
||||
|
||||
.tile-sync-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.tile-sync-label {
|
||||
font-size: 0.72rem;
|
||||
color: #f5a623;
|
||||
font-weight: 600;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tile-sync-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.tile-sync-bar-track {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 3px;
|
||||
height: 7px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tile-sync-bar-fill {
|
||||
height: 100%;
|
||||
background-color: #f5a623;
|
||||
border-radius: 3px;
|
||||
transition: width 0.6s ease;
|
||||
min-width: 2px;
|
||||
border-radius: 99px;
|
||||
background: linear-gradient(90deg, #42f39a, #1aa45d);
|
||||
transition: width 0.6s cubic-bezier(0.3, 0.7, 0.3, 1);
|
||||
}
|
||||
|
||||
.tile-sync-percent {
|
||||
font-size: 0.72rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: #f5a623;
|
||||
white-space: nowrap;
|
||||
min-width: 2.5em;
|
||||
text-align: right;
|
||||
color: var(--amber);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tile-sync-eta {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* ── Service detail modal sections ───────────────────────────────── */
|
||||
|
||||
.svc-detail-section {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.svc-detail-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.svc-detail-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.svc-detail-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.svc-detail-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Service detail: Domain ──────────────────────────────────────── */
|
||||
|
||||
.svc-detail-domain-value {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-domain-label--ok {
|
||||
color: var(--green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-domain-label--warn {
|
||||
color: var(--yellow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-domain-label--error {
|
||||
color: var(--red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Service detail: Port table ──────────────────────────────────── */
|
||||
|
||||
.svc-detail-port-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.svc-detail-port-table th {
|
||||
text-align: left;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.svc-detail-port-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(30, 45, 39, 0.6);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.svc-detail-port-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.svc-detail-port-table-port {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.svc-detail-port-table-proto {
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.svc-detail-port-table-desc {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.svc-detail-port-table-status {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.port-status-listening { color: var(--green); }
|
||||
.port-status-open { color: var(--yellow); }
|
||||
.port-status-closed { color: var(--red); }
|
||||
.port-status-unknown { color: var(--text-dim); }
|
||||
|
||||
/* ── Service detail: Troubleshoot box ────────────────────────────── */
|
||||
|
||||
.svc-detail-troubleshoot {
|
||||
margin-top: 12px;
|
||||
padding: 14px 16px;
|
||||
background-color: rgba(229, 165, 10, 0.08);
|
||||
border: 1px solid rgba(229, 165, 10, 0.3);
|
||||
border-radius: 10px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot strong {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot ol {
|
||||
margin-top: 8px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot code {
|
||||
background-color: rgba(94, 173, 138, 0.10);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot a {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.svc-detail-troubleshoot a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Service detail: Domain configure button ─────────────────────── */
|
||||
|
||||
.svc-detail-domain-btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ── Service detail: Addon feature toggle ────────────────────────── */
|
||||
|
||||
.svc-detail-addon-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.svc-detail-addon-status {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.addon-status--on {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.addon-status--off {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.svc-detail-option-card {
|
||||
padding: 16px;
|
||||
background: rgba(94, 173, 138, 0.06);
|
||||
border: 1px solid rgba(94, 173, 138, 0.24);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.svc-detail-option-list {
|
||||
margin: 10px 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.svc-detail-option-list li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.svc-detail-option-privacy {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid var(--accent-color);
|
||||
background: rgba(94, 173, 138, 0.08);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.svc-detail-option-privacy strong {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.svc-detail-related-feature-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.feature-conflict-warning {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 10px 14px;
|
||||
background-color: rgba(229, 165, 10, 0.1);
|
||||
border: 1px solid rgba(229, 165, 10, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--yellow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: #d97706;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background-color: #b45309;
|
||||
}
|
||||
|
||||
.svc-detail-restart-section {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.svc-detail-restart-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.svc-detail-restart-result {
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.svc-detail-restart-result.success {
|
||||
background-color: rgba(109, 191, 139, 0.12);
|
||||
border: 1px solid var(--green);
|
||||
color: var(--green);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.svc-detail-restart-result.error {
|
||||
background-color: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid #ef4444;
|
||||
color: #f87171;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
/* ── Desktop launch buttons ──────────────────────────────────────── */
|
||||
|
||||
.svc-detail-launch-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.svc-detail-launch-btn {
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -23,13 +23,19 @@ const SUPPORT_TIMER_INTERVAL = 1000;
|
||||
|
||||
const CATEGORY_ORDER = [
|
||||
"infrastructure",
|
||||
"bitcoin-base",
|
||||
"bitcoin-apps",
|
||||
"bitcoin",
|
||||
"communication",
|
||||
"apps",
|
||||
"nostr",
|
||||
];
|
||||
|
||||
/* The service catalog still distinguishes the Bitcoin foundation services
|
||||
from the Bitcoin apps; the Hub shows them as one "Bitcoin" section. */
|
||||
const CATEGORY_ALIASES = {
|
||||
"bitcoin-base": "bitcoin",
|
||||
"bitcoin-apps": "bitcoin",
|
||||
};
|
||||
|
||||
const FEATURE_SUBCATEGORY_LABELS = {
|
||||
"infrastructure": "🔧 Infrastructure",
|
||||
"bitcoin": "₿ Bitcoin",
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"use strict";
|
||||
|
||||
/* ── The Hub dashboard chrome ──────────────────────────────────────
|
||||
Welcome dashboard (default view), category navigation, service
|
||||
search, status cards, and the Systems Operational modal.
|
||||
|
||||
Everything lives in an IIFE so no globals leak into the other Hub
|
||||
scripts; tiles.js calls window.dashboardServicesUpdated() whenever
|
||||
the service list refreshes. */
|
||||
|
||||
(function () {
|
||||
|
||||
var $nav = document.getElementById("sidebar-nav");
|
||||
var $pageTitle = document.getElementById("page-title");
|
||||
var $search = document.getElementById("search-input");
|
||||
var $sysModal = document.getElementById("systems-modal");
|
||||
var $sysBody = document.getElementById("systems-body");
|
||||
var $welcome = document.getElementById("welcome-view");
|
||||
var $tilesArea = document.getElementById("tiles-area");
|
||||
var $wcSystems = document.getElementById("wc-systems");
|
||||
var $wcMore = document.getElementById("wc-more");
|
||||
var $greeting = document.getElementById("welcome-greeting");
|
||||
var $welcomeRole = document.getElementById("welcome-role");
|
||||
var $browseBtn = document.getElementById("welcome-browse-btn");
|
||||
|
||||
var _view = "dashboard"; // "dashboard" | "services"
|
||||
var _cat = "all";
|
||||
var _query = "";
|
||||
|
||||
var CAT_ICONS = {
|
||||
"all": "g-grid",
|
||||
"infrastructure": "g-server",
|
||||
"bitcoin": "g-btc-sym",
|
||||
"communication": "g-chat",
|
||||
"apps": "g-dots",
|
||||
"nostr": "g-antenna",
|
||||
"other": "g-dots"
|
||||
};
|
||||
|
||||
var CAT_FALLBACK_LABELS = {
|
||||
"infrastructure": "Infrastructure",
|
||||
"bitcoin": "Bitcoin",
|
||||
"communication": "Communication",
|
||||
"apps": "Personal Apps",
|
||||
"nostr": "Nostr",
|
||||
"other": "Other"
|
||||
};
|
||||
|
||||
/* Units that serve a domain — used to find a live diagnostics
|
||||
checklist for the Systems Operational modal (order matters only
|
||||
for which service is polled first). */
|
||||
var DOMAIN_UNITS = [
|
||||
"matrix-synapse.service",
|
||||
"btcpayserver.service",
|
||||
"vaultwarden.service",
|
||||
"phpfpm-nextcloud.service",
|
||||
"phpfpm-wordpress.service",
|
||||
"haven-relay.service",
|
||||
"livekit.service",
|
||||
"albyhub.service"
|
||||
];
|
||||
|
||||
function icon(id) {
|
||||
return '<svg><use href="#' + id + '"/></svg>';
|
||||
}
|
||||
|
||||
function visibleServices() {
|
||||
var services = (typeof _servicesCache !== "undefined" && _servicesCache) ? _servicesCache : [];
|
||||
return services.filter(function (s) {
|
||||
return s.category !== "support" && s.type !== "support";
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Views ────────────────────────────────────────────────────── */
|
||||
|
||||
function setTitle(t) {
|
||||
if ($pageTitle) $pageTitle.textContent = t;
|
||||
}
|
||||
|
||||
function syncNav() {
|
||||
if (!$nav) return;
|
||||
$nav.querySelectorAll(".nav-item").forEach(function (b) {
|
||||
var isActive;
|
||||
if (b.dataset.cat === "__dash") isActive = (_view === "dashboard");
|
||||
else isActive = (_view === "services" && b.dataset.cat === _cat);
|
||||
b.classList.toggle("active", isActive);
|
||||
});
|
||||
}
|
||||
|
||||
function showDashboard() {
|
||||
_view = "dashboard";
|
||||
_cat = "all";
|
||||
if ($welcome) $welcome.style.display = "";
|
||||
if ($tilesArea) $tilesArea.style.display = "none";
|
||||
setTitle("Dashboard");
|
||||
syncNav();
|
||||
}
|
||||
|
||||
function showServices(cat) {
|
||||
_view = "services";
|
||||
if (cat) _cat = cat;
|
||||
if ($welcome) $welcome.style.display = "none";
|
||||
if ($tilesArea) $tilesArea.style.display = "";
|
||||
setTitle(_cat === "all" ? "All services" : catLabel(_cat));
|
||||
syncNav();
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
/* ── Category navigation ──────────────────────────────────────── */
|
||||
|
||||
function renderNav() {
|
||||
if (!$nav) return;
|
||||
var counts = {};
|
||||
var order = [];
|
||||
visibleServices().forEach(function (s) {
|
||||
var cat = s.category || "other";
|
||||
if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
|
||||
if (!counts[cat]) { counts[cat] = 0; order.push(cat); }
|
||||
counts[cat]++;
|
||||
});
|
||||
var total = visibleServices().length;
|
||||
|
||||
var html = '<div class="nav-label">Menu</div>';
|
||||
html += '<button class="nav-item' + (_view === "dashboard" ? " active" : "") + '" data-cat="__dash" type="button">' +
|
||||
icon("g-home") +
|
||||
'<span class="nav-text">Dashboard</span>' +
|
||||
'</button>';
|
||||
html += '<div class="nav-label">Services</div>';
|
||||
html += navItem("all", "All services", total);
|
||||
order.forEach(function (cat) {
|
||||
html += navItem(cat, catLabel(cat), counts[cat]);
|
||||
});
|
||||
$nav.innerHTML = html;
|
||||
|
||||
$nav.querySelectorAll(".nav-item").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
if (btn.dataset.cat === "__dash") showDashboard();
|
||||
else showServices(btn.dataset.cat);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function navItem(cat, label, count) {
|
||||
return '<button class="nav-item' + (_view === "services" && cat === _cat ? " active" : "") + '" data-cat="' + escHtml(cat) + '" type="button">' +
|
||||
icon(CAT_ICONS[cat] || "g-dots") +
|
||||
'<span class="nav-text">' + escHtml(label) + '</span>' +
|
||||
'<span class="nav-count">' + count + '</span>' +
|
||||
'</button>';
|
||||
}
|
||||
|
||||
function catLabel(cat) {
|
||||
if (typeof _categoryLabels !== "undefined" && _categoryLabels[cat]) return _categoryLabels[cat];
|
||||
return CAT_FALLBACK_LABELS[cat] || cat;
|
||||
}
|
||||
|
||||
/* ── Filtering (applies in the services view) ─────────────────── */
|
||||
|
||||
function applyFilter() {
|
||||
if (_view !== "services") return;
|
||||
var area = $tilesArea;
|
||||
if (!area) return;
|
||||
var q = _query.trim().toLowerCase();
|
||||
area.querySelectorAll(".category-section").forEach(function (section) {
|
||||
var catMatch = (_cat === "all") || (section.dataset.category === _cat);
|
||||
var anyVisible = false;
|
||||
section.querySelectorAll(".service-tile").forEach(function (tile) {
|
||||
var nameEl = tile.querySelector(".tile-name");
|
||||
var name = nameEl ? nameEl.textContent.toLowerCase() : "";
|
||||
var match = catMatch && (!q || name.indexOf(q) !== -1);
|
||||
tile.style.display = match ? "" : "none";
|
||||
if (match) anyVisible = true;
|
||||
});
|
||||
section.style.display = (catMatch && (anyVisible || !q)) ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$search.addEventListener("input", function () {
|
||||
// Searching implies browsing services — leave the welcome view.
|
||||
if (_view === "dashboard" && $search.value) showServices("all");
|
||||
_query = $search.value;
|
||||
applyFilter();
|
||||
});
|
||||
}
|
||||
|
||||
if ($browseBtn) {
|
||||
$browseBtn.addEventListener("click", function () { showServices("all"); });
|
||||
}
|
||||
|
||||
/* ── Welcome header ───────────────────────────────────────────── */
|
||||
|
||||
function updateWelcomeMeta() {
|
||||
if ($greeting) {
|
||||
var h = new Date().getHours();
|
||||
var g;
|
||||
if (h >= 5 && h < 12) g = "Good morning";
|
||||
else if (h >= 12 && h < 17) g = "Good afternoon";
|
||||
else g = "Good evening";
|
||||
$greeting.textContent = g;
|
||||
}
|
||||
if ($welcomeRole) {
|
||||
$welcomeRole.textContent = (typeof window._roleLabel !== "undefined" && window._roleLabel) ? window._roleLabel : "";
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Status cards ─────────────────────────────────────────────── */
|
||||
|
||||
function serviceCounts() {
|
||||
var services = visibleServices();
|
||||
var running = 0, attention = 0, off = 0;
|
||||
var attentionNames = [], offNames = [];
|
||||
services.forEach(function (s) {
|
||||
if (!s.enabled) { off++; offNames.push(s.name); return; }
|
||||
var h = s.health || s.status;
|
||||
if (h === "needs_attention" || h === "failed") { attention++; attentionNames.push(s.name); }
|
||||
else running++;
|
||||
});
|
||||
return { services: services, running: running, attention: attention, off: off, attentionNames: attentionNames, offNames: offNames };
|
||||
}
|
||||
|
||||
function renderWidgets() {
|
||||
if (!$wcSystems) return;
|
||||
var c = serviceCounts();
|
||||
if (!c.services.length) { $wcSystems.innerHTML = ""; if ($wcMore) $wcMore.innerHTML = ""; return; }
|
||||
|
||||
/* Systems operational */
|
||||
var sub = '<b>' + c.running + '</b> running';
|
||||
if (c.attention) sub += ' · <span class="warn">' + c.attention + ' needs attention</span>';
|
||||
if (c.off) sub += ' · ' + c.off + ' off';
|
||||
var attentionTitle = c.attention ? "Systems need attention" : "Systems operational";
|
||||
$wcSystems.innerHTML =
|
||||
'<div class="widget clickable" id="w-systems" role="button" tabindex="0" title="System status and router setup">' +
|
||||
'<div class="widget-chip chip-green">' + icon("g-pulse") + '</div>' +
|
||||
'<div class="w-body"><h3>' + attentionTitle + '</h3><div class="sub">' + sub + '</div></div>' +
|
||||
'</div>';
|
||||
|
||||
var wSys = document.getElementById("w-systems");
|
||||
if (wSys) {
|
||||
wSys.addEventListener("click", openSystemsModal);
|
||||
wSys.addEventListener("keydown", function (e) { if (e.key === "Enter") openSystemsModal(); });
|
||||
}
|
||||
|
||||
if (!$wcMore) return;
|
||||
var more = "";
|
||||
|
||||
/* Bitcoin Core sync */
|
||||
var btc = null;
|
||||
c.services.forEach(function (s) {
|
||||
if (s.sync_ibd && s.enabled) btc = s;
|
||||
});
|
||||
if (btc) {
|
||||
var pct = Math.round((btc.sync_progress || 0) * 100);
|
||||
var blocks = btc.sync_blocks ? btc.sync_blocks.toLocaleString() : "—";
|
||||
var eta = (typeof _calcBtcEta === "function") ? _calcBtcEta(btc.unit + "::" + btc.name, btc.sync_progress || 0) : "";
|
||||
more +=
|
||||
'<div class="widget clickable" id="w-btc" role="button" tabindex="0" title="' + escHtml(btc.name) + ' details">' +
|
||||
'<div class="widget-chip chip-btc"><img src="/static/icons/' + escHtml(btc.icon) + '.svg" alt="" style="width:46px;height:46px;display:block;object-fit:contain"/></div>' +
|
||||
'<div class="w-body"><h3>' + escHtml(btc.name) + ' — syncing timechain</h3>' +
|
||||
'<div class="w-bar"><div class="w-bar-fill" style="width:' + pct + '%"></div></div>' +
|
||||
'<div class="sub">Block <b>' + blocks + '</b> · ' + pct + '% · <span class="warn">' + escHtml(eta) + '</span></div></div>' +
|
||||
'</div>';
|
||||
} else {
|
||||
var btcDone = null;
|
||||
c.services.forEach(function (s) { if (s.unit === "bitcoind.service" && s.enabled) btcDone = s; });
|
||||
if (btcDone) {
|
||||
var blk = btcDone.sync_blocks ? btcDone.sync_blocks.toLocaleString() : "";
|
||||
more +=
|
||||
'<div class="widget clickable" id="w-btc" role="button" tabindex="0" title="' + escHtml(btcDone.name) + ' details">' +
|
||||
'<div class="widget-chip chip-btc"><img src="/static/icons/' + escHtml(btcDone.icon) + '.svg" alt="" style="width:46px;height:46px;display:block;object-fit:contain"/></div>' +
|
||||
'<div class="w-body"><h3>' + escHtml(btcDone.name) + '</h3><div class="sub"><span class="good">Fully synced</span>' + (blk ? ' · Block <b>' + blk + '</b>' : '') + '</div></div>' +
|
||||
'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* Updates — mirror the sidebar's read of the update state, so the card
|
||||
never claims "up to date" while an update failed or needs a restart */
|
||||
var upd = (typeof window._lastUpdateCheck === "object" && window._lastUpdateCheck) ? window._lastUpdateCheck : null;
|
||||
var updStatus = (upd && upd.status) || "idle";
|
||||
var updTitle, updSub, updChip;
|
||||
if (updStatus === "failed") {
|
||||
updTitle = "Update failed"; updSub = "Click to retry the update"; updChip = "chip-amber";
|
||||
} else if (updStatus === "reboot_required") {
|
||||
updTitle = "Restart required"; updSub = "Click to restart and finish the update"; updChip = "chip-amber";
|
||||
} else if (updStatus === "running") {
|
||||
updTitle = "Update in progress"; updSub = "Installing the new system generation"; updChip = "chip-amber";
|
||||
} else if (upd && upd.available) {
|
||||
updTitle = "Updates available"; updSub = "Click to review and update"; updChip = "chip-amber";
|
||||
} else if (!upd) {
|
||||
/* First check hasn't returned yet — never claim "up to date" before
|
||||
a check has actually completed */
|
||||
updTitle = "Checking for updates"; updSub = "Comparing with the latest Sovran_SystemsOS release"; updChip = "chip-green";
|
||||
} else {
|
||||
/* Updates are applied by the user, not automatically — the card must
|
||||
not imply the OS updates itself */
|
||||
updTitle = "System is up to date"; updSub = "Last check found no updates · Click to check again"; updChip = "chip-green";
|
||||
}
|
||||
more +=
|
||||
'<div class="widget clickable" id="w-updates" role="button" tabindex="0" title="Check for system updates">' +
|
||||
'<div class="widget-chip ' + updChip + '">' + icon("g-update") + '</div>' +
|
||||
'<div class="w-body"><h3>' + updTitle + '</h3>' +
|
||||
'<div class="sub">' + updSub + '</div></div>' +
|
||||
'</div>';
|
||||
|
||||
$wcMore.innerHTML = more;
|
||||
|
||||
var wBtc = document.getElementById("w-btc");
|
||||
if (wBtc) {
|
||||
var svc = btc || btcDone;
|
||||
if (svc) {
|
||||
wBtc.addEventListener("click", function () { openServiceDetailModal(svc.unit, svc.name, svc.icon); });
|
||||
wBtc.addEventListener("keydown", function (e) { if (e.key === "Enter") openServiceDetailModal(svc.unit, svc.name, svc.icon); });
|
||||
}
|
||||
}
|
||||
var wUpd = document.getElementById("w-updates");
|
||||
if (wUpd) {
|
||||
wUpd.addEventListener("click", function () { openUpdateModal(); });
|
||||
wUpd.addEventListener("keydown", function (e) { if (e.key === "Enter") openUpdateModal(); });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Systems Operational modal ────────────────────────────────── */
|
||||
|
||||
function isNodeRole() {
|
||||
return (typeof _currentRole !== "undefined" && _currentRole === "node");
|
||||
}
|
||||
|
||||
function hasEnabledDomainService(services) {
|
||||
return DOMAIN_UNITS.some(function (u) {
|
||||
return services.some(function (s) { return s.unit === u && s.enabled; });
|
||||
});
|
||||
}
|
||||
|
||||
function whoUsesPorts() {
|
||||
if (isNodeRole()) {
|
||||
return 'On this <strong>Bitcoin Node</strong> install, <strong>BTCPay Server</strong> and <strong>Lightning Wallet Connections (LNURL)</strong> are the domain services that use these ports.';
|
||||
}
|
||||
return 'All your domain services share ports 80 and 443 — Matrix, BTCPay Server, VaultWarden, Nextcloud, WordPress, Haven Relay, Lightning Wallet Connections, and Element Calling.';
|
||||
}
|
||||
|
||||
function step(n, title, sub, value) {
|
||||
return '<div class="sysstep"><div class="sysnum">' + n + '</div><div class="sysstep-x">' +
|
||||
'<div class="sysstep-t">' + title + (sub ? ' <span class="sysstep-sub">· ' + sub + '</span>' : '') + '</div>' +
|
||||
(value ? '<div class="sysval"><span class="sysval-text">' + value + '</span></div>' : '') +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function openSystemsModal() {
|
||||
if (!$sysModal || !$sysBody) return;
|
||||
var c = serviceCounts();
|
||||
|
||||
var html = "";
|
||||
|
||||
/* System status */
|
||||
html += '<div class="sysmodal-card">' +
|
||||
'<div class="sysmodal-card-title">' + icon("g-pulse") + 'System Status</div>' +
|
||||
step(1, "Services running", "", String(c.running)) +
|
||||
step(2, "Needs attention", "", c.attention ? escHtml(c.attentionNames.join(", ")) : "None") +
|
||||
step(3, "Turned off", "", c.off ? escHtml(c.offNames.join(", ")) : "None") +
|
||||
'</div>';
|
||||
|
||||
/* Router — a simple open / not-open verdict. How to open the ports is
|
||||
covered during onboarding, so the modal does not repeat instructions.
|
||||
When no domain service is enabled (a fresh Desktop-only install, a
|
||||
Node with BTCPay/LNURL off, or everything turned off), there is
|
||||
nothing to check on the router — say so instead of listing services
|
||||
this machine does not have. */
|
||||
if (!hasEnabledDomainService(c.services)) {
|
||||
var noRouterDesc = isNodeRole()
|
||||
? 'Ports 80 and 443 only need to be forwarded on your router if you turn on <strong>BTCPay Server</strong> or <strong>Lightning Wallet Connections (LNURL)</strong>. If you enable one of them, come back here to check your ports.'
|
||||
: 'None of your services need ports forwarded from your router right now. If you turn on a service that uses a domain, come back here to check your ports.';
|
||||
html += '<div class="sysmodal-card">' +
|
||||
'<div class="sysmodal-card-title">' + icon("g-wifi") + 'Router</div>' +
|
||||
'<div class="sysnote"><div class="sysnote-title">' + icon("g-check") + 'No router setup needed yet</div>' +
|
||||
'<div class="sysnote-desc">' + noRouterDesc + '</div></div>' +
|
||||
'</div>';
|
||||
} else {
|
||||
html += '<div class="sysmodal-card" id="sys-ports-card" style="display:none">' +
|
||||
'<div class="sysmodal-card-title">' + icon("g-wifi") + 'Router</div>' +
|
||||
'<div id="sys-ports-status"><div class="sysfineprint">Checking…</div></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/* Who uses these ports (only meaningful when a domain service is
|
||||
actually enabled — otherwise the router note above covers it) */
|
||||
if (hasEnabledDomainService(c.services)) {
|
||||
html += '<div class="sysnote" style="margin-top:14px">' +
|
||||
'<div class="sysnote-title">' + icon("g-antenna") + 'Who uses these ports</div>' +
|
||||
'<div class="sysnote-desc">' + whoUsesPorts() + '</div></div>';
|
||||
}
|
||||
|
||||
$sysBody.innerHTML = html;
|
||||
$sysModal.classList.add("open");
|
||||
|
||||
/* Poll the first configured domain service and reduce its diagnostics
|
||||
to one verdict: the ports are open or they are not. */
|
||||
var portsCard = document.getElementById("sys-ports-card");
|
||||
var portsEl = document.getElementById("sys-ports-status");
|
||||
var units = DOMAIN_UNITS.filter(function (u) {
|
||||
return c.services.some(function (s) { return s.unit === u && s.enabled; });
|
||||
});
|
||||
if (!units.length || !portsCard || !portsEl) return;
|
||||
portsCard.style.display = "";
|
||||
|
||||
apiFetch("/api/service-detail/" + encodeURIComponent(units[0]))
|
||||
.then(function (data) {
|
||||
var steps = (data && data.domain_check_steps) || [];
|
||||
var portsStep = null;
|
||||
steps.forEach(function (s) {
|
||||
if (Number(s.step) === 3 || /ports?\s*80/i.test(s.label || "")) portsStep = s;
|
||||
});
|
||||
if (!portsStep) { portsCard.style.display = "none"; return; }
|
||||
if (portsStep.status === "ok") {
|
||||
portsEl.innerHTML = '<div class="svc-detail-status" style="font-size:0.92rem"><span class="status-dot active"></span>Ports 80 and 443 are open</div>';
|
||||
} else {
|
||||
portsEl.innerHTML = '<div class="svc-detail-status" style="font-size:0.92rem"><span class="status-dot failed"></span>Ports 80 and 443 are not open</div>';
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
portsCard.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function closeSystemsModal() {
|
||||
if ($sysModal) $sysModal.classList.remove("open");
|
||||
}
|
||||
|
||||
var sysClose = document.getElementById("systems-close-btn");
|
||||
if (sysClose) sysClose.addEventListener("click", closeSystemsModal);
|
||||
if ($sysModal) {
|
||||
$sysModal.addEventListener("click", function (e) {
|
||||
if (e.target === $sysModal) closeSystemsModal();
|
||||
});
|
||||
$sysModal.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") closeSystemsModal();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Public hook for tiles.js ────────────────────────────────── */
|
||||
|
||||
window.dashboardServicesUpdated = function () {
|
||||
renderNav();
|
||||
updateWelcomeMeta();
|
||||
renderWidgets();
|
||||
applyFilter();
|
||||
};
|
||||
|
||||
// Initial view
|
||||
showDashboard();
|
||||
updateWelcomeMeta();
|
||||
|
||||
})();
|
||||
@@ -1,22 +1,14 @@
|
||||
/* Sovran_SystemsOS Hub — Shared domain-prerequisite instructions.
|
||||
/* Sovran_SystemsOS Hub — Shared domain and router instructions.
|
||||
|
||||
SINGLE SOURCE OF TRUTH for the "you need a Njal.la domain + router port
|
||||
forwarding" guidance that must read identically everywhere it appears:
|
||||
This is the single source of truth for the simple setup guidance shown in:
|
||||
|
||||
• First-boot onboarding wizard (Server + Desktop role)
|
||||
— onboarding.js step 3
|
||||
• Feature-enable domain modal (Node role, and any role)
|
||||
— features.js openDomainSetupModal()
|
||||
(Lightning Wallet Connections / NWC, BTCPay Server, Haven, …)
|
||||
• Domain reconfigure / troubleshooting modal
|
||||
— features.js openDomainReconfigureModal()
|
||||
• First-boot onboarding for Server + Desktop
|
||||
• Feature setup in the Hub
|
||||
• Domain reconfiguration and troubleshooting
|
||||
|
||||
Keep these three surfaces word-for-word consistent: always edit this
|
||||
file, never fork the wording inline. Plain classic script (no modules) —
|
||||
both templates load it with a plain <script> tag. */
|
||||
Keep the wording consistent by editing it here. */
|
||||
"use strict";
|
||||
|
||||
/* Escape helper local to this file so it is self-contained on both pages. */
|
||||
function dpEsc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
@@ -26,105 +18,71 @@ function dpEsc(str) {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/* ── "What you'll need" intro ──────────────────────────────────────
|
||||
opts.serviceName — e.g. "Lightning Wallet Connections", or null for
|
||||
the multi-service (onboarding) wording.
|
||||
opts.hostExample — e.g. "lightning" → lightning.yourdomain.com */
|
||||
/* Explain only what the user needs for the step in front of them. */
|
||||
function renderDomainNeedsHtml(opts) {
|
||||
opts = opts || {};
|
||||
var serviceName = opts.serviceName || null;
|
||||
var hostExample = dpEsc(opts.hostExample || "myservice");
|
||||
var purpose = opts.purpose || "";
|
||||
var html = "";
|
||||
|
||||
if (serviceName) {
|
||||
html += "<p>To enable <strong>" + dpEsc(serviceName) + "</strong>, you'll need two things first:</p>";
|
||||
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
||||
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
|
||||
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
|
||||
+ dpEsc(serviceName) + " gets its own hostname: a subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
|
||||
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many.</li>";
|
||||
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
|
||||
+ "to this computer once. All domain-based services share these two ports; "
|
||||
+ "they're required for HTTPS and SSL certificates.</li>";
|
||||
html += "</ol>";
|
||||
if (purpose === "btcpay") {
|
||||
html += "<p><strong>Put your BTCPay Server online</strong></p>";
|
||||
html += "<p>Let people donate Bitcoin, pay you, or visit your online store.</p>";
|
||||
html += "<p>BTCPay Server needs a domain so people can reach it. "
|
||||
+ "Sovran_SystemsOS walks you through getting your domain from "
|
||||
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||
+ " and connecting it. Just follow the steps below.</p>";
|
||||
} else if (serviceName) {
|
||||
html += "<p>To make <strong>" + dpEsc(serviceName) + "</strong> available outside your home, it needs a domain.</p>";
|
||||
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
|
||||
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||
+ " and connecting it. Just follow the steps below.</p>";
|
||||
} else {
|
||||
html += "<p><strong>Each service below needs two things set up:</strong></p>";
|
||||
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
||||
html += "<li><strong>A domain of your own from <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">Njal.la</a></strong> "
|
||||
+ "— privacy-friendly, no personal details required, accepts Bitcoin. "
|
||||
+ "Each service gets its own hostname: its own subdomain (e.g. <code>" + hostExample + ".yourdomain.com</code>) "
|
||||
+ "or a separate domain — your choice. Subdomains are free, and one domain can have many, "
|
||||
+ "so a single domain can serve every service.</li>";
|
||||
html += "<li><strong>Access to your router</strong> — you'll forward ports <strong>80</strong> and <strong>443</strong> (TCP) "
|
||||
+ "to this computer once. All domain-based services share these two ports; "
|
||||
+ "they're required for HTTPS and SSL certificates.</li>";
|
||||
html += "</ol>";
|
||||
html += "<p>Your domain is the address people use to reach your services on the internet.</p>";
|
||||
html += "<p>Sovran_SystemsOS walks you through getting your domain from "
|
||||
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||
+ " and connecting your services. Just follow the steps below.</p>";
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── "How to set it up at Njal.la" steps ───────────────────────────
|
||||
opts.hostExample — host part used in the examples (default "call").
|
||||
opts.pasteHint — where the curl command goes:
|
||||
"below" (single service) or "next to its service below"
|
||||
(onboarding, many services). */
|
||||
/* The shortest Njal.la steps that still tell the user exactly what to do. */
|
||||
function renderNjallaStepsHtml(opts) {
|
||||
var hostExample = dpEsc((opts && opts.hostExample) || "call");
|
||||
var pasteHint = (opts && opts.pasteHint) || "below";
|
||||
var html = "";
|
||||
|
||||
html += "<p style=\"margin-top:12px;\"><strong>How to set it up at Njal.la:</strong></p>";
|
||||
html += '<p style="margin-top:12px;"><strong>Get and connect your domain:</strong></p>';
|
||||
html += '<ol style="margin:8px 0 0 16px;padding:0;line-height:1.7;">';
|
||||
html += "<li>Create an account at <a href=\"https://njal.la\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"color:var(--accent-color);\">https://njal.la</a> and buy a domain.</li>";
|
||||
html += "<li>Add a <strong>Dynamic</strong> record for the hostname:"
|
||||
+ '<ul style="margin:4px 0 0 16px;padding:0;line-height:1.7;">'
|
||||
+ "<li>In the Njal.la <strong>Name</strong> field, type ONLY the host part — the word before your domain.<br>"
|
||||
+ "(For "" + hostExample + ".yourdomain.com" you'd type just: <code>" + hostExample + "</code>.)<br>"
|
||||
+ "⚠ Do NOT type the full domain here — Njal.la adds it automatically.</li>"
|
||||
+ "<li>Dedicating a whole separate domain to this service? Leave the Name field blank or use <code>@</code>.</li>"
|
||||
+ "</ul>"
|
||||
+ "</li>";
|
||||
html += "<li>A Dynamic record has <strong>NO IP field</strong> — you don'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 += '<li>Open <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>, create an account, and get a domain.</li>';
|
||||
html += "<li>Open your domain and add a <strong>Dynamic</strong> record for this service. "
|
||||
+ "In the <strong>Name</strong> box, enter only the first part of the address. "
|
||||
+ "For <code>" + hostExample + ".yourdomain.com</code>, enter <code>" + hostExample + "</code>. "
|
||||
+ "If you are using the whole domain, leave the box blank or enter <code>@</code>.</li>";
|
||||
html += "<li>Copy the update command Njal.la gives you and paste it " + pasteHint + ".</li>";
|
||||
html += "</ol>";
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── "One router task" port-forwarding box ─────────────────────────
|
||||
opts.internalIp — LAN IP string, or empty/null for generic wording.
|
||||
opts.plural — true for multi-service (onboarding) wording.
|
||||
opts.includeSsh — also mention port 22 for SSH (onboarding).
|
||||
opts.extraNote — extra sentence appended at the end (optional). */
|
||||
/* One router task shared by all normal domain-based services. */
|
||||
function renderRouterPortsHtml(opts) {
|
||||
opts = opts || {};
|
||||
var ipPart = opts.internalIp
|
||||
? " to this computer’s internal IP <strong>" + dpEsc(opts.internalIp) + "</strong>"
|
||||
: " to this computer’s internal IP";
|
||||
var serviceWord = opts.plural ? "services" : "service";
|
||||
? " to this computer at <strong>" + dpEsc(opts.internalIp) + "</strong>"
|
||||
: " to this computer";
|
||||
|
||||
var html = "";
|
||||
html += "🔌 <strong>One router task:</strong> in your router’s <strong>port forwarding</strong> settings, "
|
||||
+ "forward port <strong>80 (TCP)</strong> and port <strong>443 (TCP)</strong>"
|
||||
+ ipPart + ". Use the <strong>same number for the internal and external port</strong>. "
|
||||
+ "This only needs to be done once — all domain services share these ports — "
|
||||
+ "but HTTPS and SSL certificates won’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. ";
|
||||
}
|
||||
html += "🔌 <strong>One router task:</strong> in your router’s <strong>Port Forwarding</strong> settings, "
|
||||
+ "forward <strong>80 (TCP)</strong> and <strong>443 (TCP)</strong>"
|
||||
+ ipPart + ". If your router asks for outside and inside port numbers, use the same number for both. "
|
||||
+ "You only need to do this once. All your services share these two ports.";
|
||||
if (opts.extraNote) {
|
||||
html += dpEsc(opts.extraNote);
|
||||
html += " " + dpEsc(opts.extraNote);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/* ── Async helper: fill a router-box container with the internal IP ──
|
||||
Renders generic text immediately, then upgrades to the concrete LAN IP
|
||||
if /api/network provides one. Best-effort — never blocks the UI. */
|
||||
/* Show generic wording immediately, then add the computer's address when found. */
|
||||
function renderRouterPortsBox(elId, opts) {
|
||||
var el = document.getElementById(elId);
|
||||
if (!el) return;
|
||||
@@ -142,5 +100,5 @@ function renderRouterPortsBox(elId, opts) {
|
||||
target.innerHTML = renderRouterPortsHtml(next);
|
||||
}
|
||||
})
|
||||
.catch(function() { /* generic wording already shown — fine */ });
|
||||
.catch(function() { /* Generic wording is already shown. */ });
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
|
||||
// if ($updateBtn) $updateBtn.addEventListener("click", openUpdateModal); // moved to sidebar in tiles.js
|
||||
if ($btnCloseModal) $btnCloseModal.addEventListener("click", closeUpdateModal);
|
||||
if ($btnCheckAgain) $btnCheckAgain.addEventListener("click", function() { openUpdateModal(); });
|
||||
if ($updateCloseBtn) $updateCloseBtn.addEventListener("click", closeUpdateModal);
|
||||
if ($btnReboot) $btnReboot.addEventListener("click", doReboot);
|
||||
if ($btnSave) $btnSave.addEventListener("click", saveErrorReport);
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.addEventListener("click", retryUpdateStatus);
|
||||
if ($btnRetryRun) $btnRetryRun.addEventListener("click", retryUpdateRun);
|
||||
|
||||
// Browser timers and requests may be suspended while an RDP session/tab is in
|
||||
// the background. Reconcile immediately when the user returns instead of
|
||||
@@ -27,6 +30,7 @@ if ($logoutBtn) $logoutBtn.addEventListener("click", function () {
|
||||
|
||||
// Rebuild modal
|
||||
if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal);
|
||||
if ($rebuildCloseHdr) $rebuildCloseHdr.addEventListener("click", closeRebuildModal);
|
||||
if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot);
|
||||
if ($rebuildSave) $rebuildSave.addEventListener("click", saveRebuildErrorReport);
|
||||
if ($rebuildModal) $rebuildModal.addEventListener("click", function(e) { if (e.target === $rebuildModal) closeRebuildModal(); });
|
||||
@@ -174,9 +178,9 @@ function showSecurityBanner() {
|
||||
'</div>' +
|
||||
'<button class="security-banner-dismiss" id="security-banner-dismiss-btn" title="Dismiss">\u2715</button>';
|
||||
|
||||
var mainContent = document.querySelector(".main-content");
|
||||
if (mainContent) {
|
||||
mainContent.insertAdjacentElement("beforebegin", banner);
|
||||
var contentArea = document.querySelector(".content");
|
||||
if (contentArea) {
|
||||
contentArea.insertAdjacentElement("afterbegin", banner);
|
||||
} else {
|
||||
document.body.insertAdjacentElement("afterbegin", banner);
|
||||
}
|
||||
@@ -238,10 +242,11 @@ async function init() {
|
||||
}
|
||||
var badge = document.getElementById("role-badge");
|
||||
if (badge && cfg.role_label) badge.textContent = cfg.role_label;
|
||||
window._roleLabel = cfg.role_label || "";
|
||||
|
||||
await refreshServices();
|
||||
loadNetwork();
|
||||
checkUpdates();
|
||||
await refreshServices();
|
||||
|
||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||
@@ -251,9 +256,9 @@ async function init() {
|
||||
}
|
||||
loadAutolaunchToggle();
|
||||
} catch (_) {
|
||||
await refreshServices();
|
||||
loadNetwork();
|
||||
checkUpdates();
|
||||
await refreshServices();
|
||||
setInterval(refreshServices, POLL_INTERVAL_SERVICES);
|
||||
setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
|
||||
loadAutolaunchToggle();
|
||||
|
||||
@@ -60,6 +60,7 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
||||
|
||||
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
||||
var isBtcpay = feat.id === "btcpay-web";
|
||||
|
||||
var npubField = "";
|
||||
if (feat.id === "haven") {
|
||||
@@ -77,30 +78,29 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
|
||||
var nwcWarning = isWalletConnections
|
||||
? '<div class="domain-nwc-warning">' +
|
||||
'<strong>⚠ Lightning Wallet Connections requires its own unique hostname.</strong> ' +
|
||||
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
||||
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
||||
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
|
||||
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
|
||||
'</div>'
|
||||
: '';
|
||||
|
||||
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
|
||||
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
|
||||
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : (isBtcpay ? "btcpay.yourdomain.com" : "myservice.example.com");
|
||||
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : (isBtcpay ? "btcpay.yourdomain.com" : "call.yourdomain.com");
|
||||
|
||||
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
|
||||
// identical wording to the Server + Desktop onboarding wizard and every other
|
||||
// domain-based feature, regardless of role (Node / Desktop / Server+Desktop).
|
||||
var hostExample = isWalletConnections ? "lightning" : "call";
|
||||
var hostExample = isWalletConnections ? "lightning" : (isBtcpay ? "btcpay" : "call");
|
||||
var purpose = isBtcpay ? "btcpay" : "";
|
||||
|
||||
$domainSetupBody.innerHTML =
|
||||
'<div class="domain-setup-intro">' +
|
||||
nwcWarning +
|
||||
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample }) +
|
||||
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample, purpose: purpose }) +
|
||||
renderNjallaStepsHtml({ hostExample: hostExample, pasteHint: "below" }) +
|
||||
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></div>' +
|
||||
'<p style="margin-top:10px;">Below, enter the full domain for this service — a subdomain (e.g. ' + domainLabelExample + ') or a separate domain — and paste its curl command.</p>' +
|
||||
'<p style="margin-top:10px;">Enter the address for this service and paste the update command from Njal.la.</p>' +
|
||||
'</div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Service domain (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Dynamic DNS Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "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-subdomain-input">Service address (e.g. ' + domainLabelExample + '):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="' + domainPlaceholder + '" /></div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-ddns-input">Njal.la Update Command:</label><input class="domain-field-input" type="text" id="domain-ddns-input" placeholder="curl "https://njal.la/update/?h=' + domainPlaceholder + '&k=abc123&auto"" /><p class="domain-field-hint">Paste the update command from Njal.la</p></div>' +
|
||||
npubField +
|
||||
'<div class="domain-field-actions"><button class="btn btn-close-modal" id="domain-setup-cancel-btn">Cancel</button><button class="btn btn-primary" id="domain-setup-save-btn">Save & Enable</button></div>';
|
||||
|
||||
@@ -143,8 +143,6 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
|
||||
$domainSetupModal.classList.add("open");
|
||||
|
||||
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
|
||||
renderRouterPortsBox("domain-router-box");
|
||||
}
|
||||
|
||||
function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||
@@ -169,9 +167,8 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||
|
||||
var nwcWarning = isWalletConnections
|
||||
? '<div class="domain-nwc-warning">' +
|
||||
'<strong>⚠ Lightning Wallet Connections requires its own unique hostname.</strong> ' +
|
||||
'Use a new subdomain such as <code>lightning.yourdomain.com</code>, or a separate domain. ' +
|
||||
'Do not reuse a domain already assigned to Matrix, Nextcloud, WordPress, BTCPay Server, Vaultwarden, Haven, or another Caddy site.' +
|
||||
'<strong>Lightning Wallet Connections needs its own address.</strong> ' +
|
||||
'Use a new address such as <code>lightning.yourdomain.com</code>. Do not use an address already connected to another service.' +
|
||||
'</div>'
|
||||
: '';
|
||||
|
||||
@@ -193,9 +190,7 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||
'<span style="display:inline-block;margin-top:4px;padding:4px 10px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1em;font-weight:700;">' + escHtml(externalIp) + '</span></li>' +
|
||||
'<li>If the IP is wrong or the record is missing, update it</li>' +
|
||||
'<li>If you changed the DDNS curl command, paste the updated one below</li>' +
|
||||
'<li>Confirm ports <strong>80</strong> and <strong>443</strong> (TCP) are still forwarded on your router to this computer — see the reminder below:</li>' +
|
||||
'</ol>' +
|
||||
'<div class="onboarding-port-warn" id="domain-router-box" style="margin-top:12px;"></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 + '" value="' + escHtml(currentDomain) + '" /></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>' +
|
||||
@@ -241,8 +236,6 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||
|
||||
$domainSetupModal.classList.add("open");
|
||||
|
||||
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
|
||||
renderRouterPortsBox("domain-router-box");
|
||||
}
|
||||
|
||||
function closeDomainSetupModal() {
|
||||
@@ -635,16 +628,16 @@ async function loadAutolaunchToggle() {
|
||||
}
|
||||
|
||||
function renderAutolaunchToggle(enabled) {
|
||||
// Remove existing section if any
|
||||
var old = $sidebarFeatures.querySelector(".autolaunch-section");
|
||||
// The preference lives in the Systems Operational modal (moved from the sidebar)
|
||||
var slot = document.getElementById("autolaunch-slot");
|
||||
if (!slot) return;
|
||||
var old = slot.querySelector(".autolaunch-section");
|
||||
if (old) old.parentNode.removeChild(old);
|
||||
|
||||
var section = document.createElement("div");
|
||||
section.className = "category-section autolaunch-section";
|
||||
|
||||
section.innerHTML =
|
||||
'<div class="section-header">Preferences</div>' +
|
||||
'<hr class="section-divider" />' +
|
||||
'<div class="feature-card">' +
|
||||
'<div class="feature-card-top">' +
|
||||
'<div class="feature-card-info">' +
|
||||
@@ -658,7 +651,7 @@ function renderAutolaunchToggle(enabled) {
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
$sidebarFeatures.appendChild(section);
|
||||
slot.appendChild(section);
|
||||
|
||||
var input = document.getElementById("autolaunch-toggle-input");
|
||||
var label = document.getElementById("autolaunch-toggle-label");
|
||||
|
||||
@@ -67,7 +67,7 @@ function renderPortForwardGuideHtml(ports, opts) {
|
||||
var noteClass = opts.noteClass || "port-req-hint";
|
||||
var ipHtml = opts.internalIp
|
||||
? '<code class="port-req-internal-ip">' + escHtml(opts.internalIp) + '</code>'
|
||||
: 'this computer’s <strong>internal IP</strong> (shown as “Internal IP” at the top of the Hub dashboard)';
|
||||
: 'this computer’s <strong>internal IP</strong>';
|
||||
|
||||
var rows = (ports || []).map(function(p) {
|
||||
return '<tr>' +
|
||||
@@ -78,26 +78,17 @@ function renderPortForwardGuideHtml(ports, opts) {
|
||||
}).join("");
|
||||
|
||||
var forWhat = opts.serviceName
|
||||
? 'For <strong>' + escHtml(opts.serviceName) + '</strong> to be reachable from outside your home network, open'
|
||||
: 'Open';
|
||||
? 'To make <strong>' + escHtml(opts.serviceName) + '</strong> reachable from outside your home, forward these ports to ' + ipHtml + ':'
|
||||
: 'Forward these ports to ' + ipHtml + ':';
|
||||
|
||||
return '<p class="' + introClass + '">' +
|
||||
forWhat + ' the ports below in your router’s <strong>port forwarding</strong> settings ' +
|
||||
'and point them at ' + ipHtml + '.' +
|
||||
return '<p class="' + introClass + '">' + forWhat + '</p>' +
|
||||
'<p class="port-req-steps" style="margin-top:6px;margin-bottom:10px;font-size:0.92em;color:#555;">' +
|
||||
'Set the internal and external port to the <strong>same number</strong>. Match <strong>TCP</strong> or <strong>UDP</strong> exactly. For ranges like <strong>40000-40099</strong>, use your router’s range fields (start 40000, end 40099).' +
|
||||
'</p>' +
|
||||
'<ul class="port-req-steps">' +
|
||||
'<li>Set the <strong>internal (private) port</strong> and the <strong>external (public) port</strong> to the <strong>same number</strong>.</li>' +
|
||||
'<li>Match the <strong>protocol</strong> exactly — a rule set to TCP will not pass UDP traffic. Where the table says <strong>TCP + UDP</strong>, create both rules (or pick “Both”/“TCP/UDP” if your router offers it).</li>' +
|
||||
'<li>For a range such as <strong>30000-40000</strong>, use your router’s port-range fields — start 30000, end 40000 — rather than one rule per port.</li>' +
|
||||
'</ul>' +
|
||||
'<table class="' + tableClass + '">' +
|
||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Used for</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
'<p class="' + noteClass + '">' +
|
||||
'📱 <strong>How to confirm it worked:</strong> forwarding happens on your router, so it can only be verified from outside your network. ' +
|
||||
'Turn Wi-Fi off on your phone and open the service over mobile data — if it loads, your ports are open.' +
|
||||
'</p>';
|
||||
'</table>';
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
|
||||
@@ -2,6 +2,35 @@
|
||||
|
||||
// ── Rebuild modal ─────────────────────────────────────────────────
|
||||
|
||||
// Status line + header pill for the rebuild dialog (same presentation
|
||||
// contract as the update dialog's _setUpdateStatus).
|
||||
function _setRebuildStatus(text) {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = text;
|
||||
if ($rebuildPill) {
|
||||
var cls = "upd-pill";
|
||||
var html;
|
||||
if (text.charAt(0) === "✓") {
|
||||
if (text.indexOf("restart required") !== -1) {
|
||||
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
|
||||
} else {
|
||||
cls += " st-active"; html = '<span class="status-dot active"></span>Done';
|
||||
}
|
||||
} else if (text.charAt(0) === "✗") {
|
||||
cls += " st-failed"; html = '<span class="status-dot failed"></span>Failed';
|
||||
} else {
|
||||
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Applying…';
|
||||
}
|
||||
$rebuildPill.className = cls;
|
||||
$rebuildPill.innerHTML = html;
|
||||
}
|
||||
if ($rebuildStatus) {
|
||||
var msg = "update-status-msg";
|
||||
if (text.charAt(0) === "✓") $rebuildStatus.className = (text.indexOf("restart required") !== -1) ? msg + " st-warn" : msg + " st-ok";
|
||||
else if (text.charAt(0) === "✗") $rebuildStatus.className = msg + " st-err";
|
||||
else $rebuildStatus.className = msg;
|
||||
}
|
||||
}
|
||||
|
||||
function openRebuildModal() {
|
||||
if (!$rebuildModal) return;
|
||||
_rebuildLog = "";
|
||||
@@ -13,11 +42,12 @@ function openRebuildModal() {
|
||||
if ($rebuildLog) { $rebuildLog.textContent = ""; $rebuildLog.style.display = "none"; }
|
||||
var action = _rebuildIsEnabling ? "Enabling" : "Disabling";
|
||||
var label = _rebuildFeatureName || "feature";
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = action + " " + label + "…";
|
||||
_setRebuildStatus(action + " " + label + "…");
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.add("spinning");
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "none";
|
||||
if ($rebuildSave) $rebuildSave.style.display = "none";
|
||||
if ($rebuildClose) $rebuildClose.disabled = true;
|
||||
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = true;
|
||||
$rebuildModal.classList.add("open");
|
||||
// Delay first poll slightly to let the rebuild service start and clear stale log
|
||||
setTimeout(startRebuildPoll, 1500);
|
||||
@@ -76,7 +106,7 @@ async function pollRebuildStatus() {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (!_rebuildServerDown) { _rebuildServerDown = true; if ($rebuildStatus) $rebuildStatus.textContent = "Applying changes…"; }
|
||||
if (!_rebuildServerDown) { _rebuildServerDown = true; _setRebuildStatus("Applying changes…"); }
|
||||
} finally {
|
||||
_rebuildPollInFlight = false;
|
||||
}
|
||||
@@ -85,15 +115,16 @@ async function pollRebuildStatus() {
|
||||
function onRebuildDone(result) {
|
||||
if ($rebuildSpinner) $rebuildSpinner.classList.remove("spinning");
|
||||
if ($rebuildClose) $rebuildClose.disabled = false;
|
||||
if ($rebuildCloseHdr) $rebuildCloseHdr.disabled = false;
|
||||
if (result === true) {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done";
|
||||
_setRebuildStatus("✓ Done");
|
||||
// Auto-reload the page after a short delay so tiles and toggles reflect the new state
|
||||
setTimeout(function() { window.location.reload(); }, 1200);
|
||||
} else if (result === "reboot_required") {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — restart required";
|
||||
_setRebuildStatus("✓ Done — restart required");
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
||||
_setRebuildStatus("✗ Something went wrong");
|
||||
if ($rebuildSave) $rebuildSave.style.display = "inline-flex";
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
function openSecurityModal() {
|
||||
if ($supportModal) $supportModal.classList.add("open");
|
||||
var title = document.getElementById("support-modal-title");
|
||||
if (title) title.textContent = "\uD83D\uDEE1 Security";
|
||||
if (title) title.textContent = "Security";
|
||||
var chip = document.getElementById("support-modal-chip");
|
||||
if (chip) {
|
||||
var use = chip.querySelector("use");
|
||||
if (use) use.setAttribute("href", "#g-shield-check");
|
||||
}
|
||||
|
||||
if ($supportBody) {
|
||||
$supportBody.innerHTML =
|
||||
|
||||
@@ -696,6 +696,17 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
: (data.health || data.status);
|
||||
var sc = statusClass(effectiveHealth);
|
||||
var st = statusText(effectiveHealth, effectiveEnabled);
|
||||
if ($credsTitle) {
|
||||
var existingPill = $credsTitle.querySelector(".creds-title-status-pill");
|
||||
if (!existingPill) {
|
||||
var pill = document.createElement("span");
|
||||
pill.className = "creds-title-status-pill";
|
||||
$credsTitle.appendChild(pill);
|
||||
existingPill = pill;
|
||||
}
|
||||
existingPill.className = "creds-title-status-pill st-" + sc;
|
||||
existingPill.textContent = st;
|
||||
}
|
||||
addSetup('<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Status</div>' +
|
||||
'<div class="svc-detail-status">' +
|
||||
@@ -707,19 +718,27 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
|
||||
// Section C: Domain diagnostics (domain services)
|
||||
if (data.needs_domain) {
|
||||
var steps = data.domain_check_steps || [];
|
||||
// The Hub shows only the domain-active step here; the full DNS and
|
||||
// port diagnostics live in the Systems Operational modal.
|
||||
var steps = (data.domain_check_steps || []).filter(function (s) {
|
||||
return Number(s.step) === 1;
|
||||
});
|
||||
var stepsHtml = "";
|
||||
steps.forEach(function(step) {
|
||||
var iconLabel = "—";
|
||||
if (step.status === "ok") iconLabel = "✅";
|
||||
else if (step.status === "error") iconLabel = "❌";
|
||||
else if (step.status === "warning") iconLabel = "⚠️";
|
||||
else if (step.status === "skipped") iconLabel = "⏭️";
|
||||
var detail = escHtml(step.detail || "").replace(/\n/g, "<br>");
|
||||
stepsHtml += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px">' +
|
||||
'<strong>' + iconLabel + ' Step ' + escHtml(String(step.step)) + ': ' + escHtml(step.label || "") + '</strong>' +
|
||||
(detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') +
|
||||
'</div>';
|
||||
if (step.status === "ok") {
|
||||
// Not a checklist anymore — just note that the domain works.
|
||||
stepsHtml += '<div class="svc-detail-status"><span class="status-dot active"></span>Domain is active</div>' +
|
||||
(detail ? '<div class="svc-detail-desc" style="margin-top:6px">' + detail + '</div>' : '');
|
||||
} else {
|
||||
var iconLabel = "❌";
|
||||
if (step.status === "warning") iconLabel = "⚠️";
|
||||
else if (step.status === "skipped") iconLabel = "⏭️";
|
||||
stepsHtml += '<div class="svc-detail-troubleshoot" style="margin-bottom:10px">' +
|
||||
'<strong>' + iconLabel + ' ' + escHtml(step.label || "Domain not configured") + '</strong>' +
|
||||
(detail ? '<div style="margin-top:6px">' + detail + '</div>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
});
|
||||
|
||||
var domainActionHtml = "";
|
||||
@@ -731,11 +750,22 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
}
|
||||
|
||||
addSetup('<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Domain Diagnostic Checklist</div>' +
|
||||
'<div class="svc-detail-section-title">Domain Status</div>' +
|
||||
stepsHtml +
|
||||
domainActionHtml +
|
||||
'</div>');
|
||||
|
||||
// Node-only role: BTCPay Server and Lightning Wallet Connections are
|
||||
// the domain services — surface the router task here. (Desktop + Server
|
||||
// set this up during onboarding; Systems Operational shows it too.)
|
||||
if (typeof _currentRole !== "undefined" && _currentRole === "node" &&
|
||||
(unit === "btcpayserver.service" || unit === "albyhub.service")) {
|
||||
addSetup('<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
|
||||
'<div class="onboarding-port-warn" id="svc-node-router-box"></div>' +
|
||||
'</div>');
|
||||
}
|
||||
|
||||
if (data.router_ports && data.router_ports.length > 0) {
|
||||
var trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
|
||||
addSetup('<div class="svc-detail-section">' +
|
||||
@@ -993,6 +1023,9 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
$credsBody.innerHTML = html;
|
||||
if (isNwc) _nwcWireTabs();
|
||||
_attachCopyHandlers($credsBody);
|
||||
if (document.getElementById("svc-node-router-box")) {
|
||||
renderRouterPortsBox("svc-node-router-box");
|
||||
}
|
||||
if (_isNwcServiceUnit(unit) && (effectiveEnabled || data.enabled)) {
|
||||
await _nwcInitWalletFlow(unit, name, icon);
|
||||
var nwcRtlBtn = document.getElementById("nwc-open-rtl-btn");
|
||||
|
||||
@@ -53,7 +53,12 @@ const $modalLog = document.getElementById("modal-log");
|
||||
const $btnReboot = document.getElementById("btn-reboot");
|
||||
const $btnSave = document.getElementById("btn-save-report");
|
||||
const $btnRetryUpdate = document.getElementById("btn-retry-update-status");
|
||||
const $btnRetryRun = document.getElementById("btn-retry-update");
|
||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||
const $btnCheckAgain = document.getElementById("btn-check-again");
|
||||
const $updateCloseBtn = document.getElementById("update-close-btn");
|
||||
const $updPill = document.getElementById("upd-pill");
|
||||
const $updLastChecked = document.getElementById("upd-last-checked");
|
||||
|
||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||
const $rebootMainCard = document.getElementById("reboot-main-card");
|
||||
@@ -79,6 +84,8 @@ const $rebuildLog = document.getElementById("rebuild-log");
|
||||
const $rebuildReboot = document.getElementById("rebuild-reboot-btn");
|
||||
const $rebuildSave = document.getElementById("rebuild-save-report");
|
||||
const $rebuildClose = document.getElementById("rebuild-close-btn");
|
||||
const $rebuildCloseHdr = document.getElementById("rebuild-close-hdr");
|
||||
const $rebuildPill = document.getElementById("rebuild-pill");
|
||||
|
||||
// Feature Manager — domain setup modal
|
||||
const $domainSetupModal = document.getElementById("domain-setup-modal");
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
async function openSupportModal() {
|
||||
if (!$supportModal) return;
|
||||
$supportModal.classList.add("open");
|
||||
// The dialog is shared with Security — always restore its identity
|
||||
var title = document.getElementById("support-modal-title");
|
||||
if (title) title.textContent = "Tech Support";
|
||||
var chip = document.getElementById("support-modal-chip");
|
||||
if (chip) {
|
||||
var use = chip.querySelector("use");
|
||||
if (use) use.setAttribute("href", "#g-lifebuoy");
|
||||
}
|
||||
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
|
||||
try {
|
||||
var status = await apiFetch("/api/support/status");
|
||||
|
||||
@@ -25,6 +25,7 @@ function buildTiles(services, categoryLabels) {
|
||||
continue;
|
||||
}
|
||||
var cat = svc.category || "other";
|
||||
if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(svc);
|
||||
}
|
||||
@@ -52,6 +53,7 @@ function buildTiles(services, categoryLabels) {
|
||||
if ($tilesArea.children.length === 0) {
|
||||
$tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>';
|
||||
}
|
||||
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
||||
}
|
||||
|
||||
function renderSidebarSupport(supportServices) {
|
||||
@@ -62,20 +64,22 @@ function renderSidebarSupport(supportServices) {
|
||||
sidebarUpdateBtn.className = "sidebar-support-btn";
|
||||
sidebarUpdateBtn.id = "sidebar-btn-update";
|
||||
sidebarUpdateBtn.innerHTML =
|
||||
'<img class="sidebar-support-icon" src="/static/icons/update.svg" alt="Update" style="width:1.5rem;height:1.5rem;">' +
|
||||
'<span class="sidebar-support-icon"><svg><use href="#g-update"/></svg></span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Update System</span>' +
|
||||
'<span class="sidebar-support-hint" id="sidebar-update-hint">Check for updates</span>' +
|
||||
'</span>';
|
||||
sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); });
|
||||
$sidebarSupport.appendChild(sidebarUpdateBtn);
|
||||
// checkUpdates may already have run before the sidebar was built
|
||||
applyUpdateSidebarState(window._lastUpdateCheck);
|
||||
|
||||
for (var i = 0; i < supportServices.length; i++) {
|
||||
var svc = supportServices[i];
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "sidebar-support-btn";
|
||||
btn.innerHTML =
|
||||
'<span class="sidebar-support-icon">🛟</span>' +
|
||||
'<span class="sidebar-support-icon"><svg><use href="#g-lifebuoy"/></svg></span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">' + escHtml(svc.name || "Tech Support") + '</span>' +
|
||||
'<span class="sidebar-support-hint">Click for help</span>' +
|
||||
@@ -88,7 +92,7 @@ function renderSidebarSupport(supportServices) {
|
||||
var backupBtn = document.createElement("button");
|
||||
backupBtn.className = "sidebar-support-btn";
|
||||
backupBtn.innerHTML =
|
||||
'<span class="sidebar-support-icon">💾</span>' +
|
||||
'<span class="sidebar-support-icon"><svg><use href="#g-box"/></svg></span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Manual Backup</span>' +
|
||||
'<span class="sidebar-support-hint">Back up to external drive</span>' +
|
||||
@@ -100,7 +104,7 @@ function renderSidebarSupport(supportServices) {
|
||||
var securityBtn = document.createElement("button");
|
||||
securityBtn.className = "sidebar-support-btn";
|
||||
securityBtn.innerHTML =
|
||||
'<span class="sidebar-support-icon">\uD83D\uDEE1</span>' +
|
||||
'<span class="sidebar-support-icon"><svg><use href="#g-shield-check"/></svg></span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Security</span>' +
|
||||
'<span class="sidebar-support-hint">Reset & verify system</span>' +
|
||||
@@ -113,7 +117,7 @@ function renderSidebarSupport(supportServices) {
|
||||
var upgradeBtn = document.createElement("button");
|
||||
upgradeBtn.className = "sidebar-support-btn";
|
||||
upgradeBtn.innerHTML =
|
||||
'<span class="sidebar-support-icon">🚀</span>' +
|
||||
'<span class="sidebar-support-icon"><svg><use href="#g-antenna"/></svg></span>' +
|
||||
'<span class="sidebar-support-text">' +
|
||||
'<span class="sidebar-support-title">Upgrade to Full Server</span>' +
|
||||
'<span class="sidebar-support-hint">Unlock all services</span>' +
|
||||
@@ -237,6 +241,7 @@ function updateTiles(services) {
|
||||
if (text) text.textContent = st;
|
||||
}
|
||||
}
|
||||
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
||||
}
|
||||
|
||||
// ── Service polling ───────────────────────────────────────────────
|
||||
@@ -248,6 +253,10 @@ async function refreshServices() {
|
||||
var services = await apiFetch("/api/services");
|
||||
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
|
||||
else { updateTiles(services); }
|
||||
// Service data has rendered — the dashboard is ready; lift the boot
|
||||
// splash (no-op once lifted). Note: dashboardServicesUpdated may also
|
||||
// fire from checkUpdates before this resolves, so the lift lives here.
|
||||
if (typeof window.__liftAppSplash === "function") window.__liftAppSplash();
|
||||
} catch (err) { console.warn("Failed to fetch services:", err); }
|
||||
}
|
||||
|
||||
@@ -267,31 +276,48 @@ async function loadNetwork() {
|
||||
|
||||
// ── Update check ──────────────────────────────────────────────────
|
||||
|
||||
// Paint the sidebar Update button from the last known update state.
|
||||
// Called after each check AND when the button is (re)built, so the hint
|
||||
// is correct regardless of which finishes first at startup.
|
||||
function applyUpdateSidebarState(data) {
|
||||
if (!data) return;
|
||||
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
||||
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
||||
if (!sidebarUpdateBtn) return;
|
||||
var hasUpdates = !!data.available;
|
||||
var updateStatus = data.status || "idle";
|
||||
if (updateStatus === "failed") {
|
||||
// Last update errored and did not apply — surface it as a persistent
|
||||
// red banner that re-opens the failed run with a "Retry Update" action.
|
||||
sidebarUpdateBtn.style.borderColor = "#f66151";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(246, 97, 81, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update failed — click to retry";
|
||||
} else if (updateStatus === "reboot_required") {
|
||||
sidebarUpdateBtn.style.borderColor = "#e9b64a";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(233, 182, 74, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
|
||||
} else if (updateStatus === "running") {
|
||||
sidebarUpdateBtn.style.borderColor = "#78aeed";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(120, 174, 237, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
|
||||
} else if (hasUpdates) {
|
||||
sidebarUpdateBtn.style.borderColor = "#3ecf8e";
|
||||
sidebarUpdateBtn.style.backgroundColor = "rgba(62, 207, 142, 0.10)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
||||
} else {
|
||||
sidebarUpdateBtn.style.borderColor = "";
|
||||
sidebarUpdateBtn.style.backgroundColor = "";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
try {
|
||||
var data = await apiFetch("/api/updates/check");
|
||||
var hasUpdates = !!data.available;
|
||||
var updateStatus = data.status || "idle";
|
||||
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
||||
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
||||
if (sidebarUpdateBtn) {
|
||||
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.backgroundColor = "rgba(46, 194, 126, 0.08)";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
||||
} else {
|
||||
sidebarUpdateBtn.style.borderColor = "";
|
||||
sidebarUpdateBtn.style.backgroundColor = "";
|
||||
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
|
||||
}
|
||||
}
|
||||
window._lastUpdateCheck = data;
|
||||
if (typeof markUpdateChecked === "function") markUpdateChecked();
|
||||
applyUpdateSidebarState(data);
|
||||
// The welcome dashboard's updates card reads this state
|
||||
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,88 @@
|
||||
|
||||
// ── Update modal ──────────────────────────────────────────────────
|
||||
|
||||
// ── Update dialog presentation (pill, status line, last-checked) ──
|
||||
|
||||
var _updateLastCheckTs = 0;
|
||||
|
||||
function markUpdateChecked() {
|
||||
_updateLastCheckTs = Date.now();
|
||||
}
|
||||
|
||||
function _updateLastCheckedText() {
|
||||
if (!$updLastChecked) return;
|
||||
if (!_updateLastCheckTs) { $updLastChecked.textContent = "—"; return; }
|
||||
var s = Math.floor((Date.now() - _updateLastCheckTs) / 1000);
|
||||
if (s < 30) { $updLastChecked.textContent = "just now"; return; }
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) { $updLastChecked.textContent = m + (m === 1 ? " minute ago" : " minutes ago"); return; }
|
||||
var h = Math.floor(m / 60);
|
||||
$updLastChecked.textContent = h + (h === 1 ? " hour ago" : " hours ago");
|
||||
}
|
||||
|
||||
function setUpdatePill(state) {
|
||||
if (!$updPill) return;
|
||||
var cls = "upd-pill";
|
||||
var html;
|
||||
if (state === "checking") {
|
||||
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Checking…';
|
||||
} else if (state === "uptodate") {
|
||||
cls += " st-active"; html = '<span class="status-dot active"></span>Up to date';
|
||||
} else if (state === "complete") {
|
||||
cls += " st-active"; html = '<span class="status-dot active"></span>Update complete';
|
||||
} else if (state === "reboot") {
|
||||
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
|
||||
} else if (state === "failed") {
|
||||
cls += " st-failed"; html = '<span class="status-dot failed"></span>Update failed';
|
||||
} else if (state === "unavailable") {
|
||||
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Status unknown';
|
||||
} else {
|
||||
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Updating…';
|
||||
}
|
||||
$updPill.className = cls;
|
||||
$updPill.innerHTML = html;
|
||||
}
|
||||
|
||||
// Single place that reflects update state in the dialog: status message
|
||||
// text + color, header pill, and the Close / Check again availability.
|
||||
function _setUpdateStatus(text) {
|
||||
if ($modalStatus) $modalStatus.textContent = text;
|
||||
var state;
|
||||
if (text.charAt(0) === "✗") state = "failed";
|
||||
else if (text.indexOf("restart required") !== -1) state = "reboot";
|
||||
else if (text.charAt(0) === "✓") state = (text.indexOf("already up to date") !== -1) ? "uptodate" : "complete";
|
||||
else if (text.indexOf("unavailable") !== -1) state = "unavailable";
|
||||
else if (text.indexOf("Checking") === 0) state = "checking";
|
||||
else state = "updating";
|
||||
if ($modalStatus) {
|
||||
var msg = "update-status-msg";
|
||||
if (state === "failed") $modalStatus.className = msg + " st-err";
|
||||
else if (state === "reboot" || state === "unavailable") $modalStatus.className = msg + " st-warn";
|
||||
else if (state === "uptodate" || state === "complete") $modalStatus.className = msg + " st-ok";
|
||||
else $modalStatus.className = msg;
|
||||
// In the up-to-date state the green console line already says it —
|
||||
// the mockup shows it exactly once.
|
||||
$modalStatus.style.display = (state === "uptodate") ? "none" : "";
|
||||
}
|
||||
setUpdatePill(state);
|
||||
var interactive = (state !== "updating");
|
||||
if ($updateCloseBtn) $updateCloseBtn.disabled = !interactive;
|
||||
if ($btnCheckAgain) {
|
||||
$btnCheckAgain.disabled = (state === "checking" || state === "updating");
|
||||
$btnCheckAgain.style.display = interactive ? "inline-flex" : "none";
|
||||
}
|
||||
_updateLastCheckedText();
|
||||
}
|
||||
|
||||
async function openUpdateModal() {
|
||||
if (!$modal) return;
|
||||
|
||||
// Open immediately in a checking state; the status/check requests below
|
||||
// fill in the real result (mockup: auto-check on open).
|
||||
$modal.classList.add("open");
|
||||
if ($modalLog) $modalLog.innerHTML = '<span class="dim">Checking for updates…</span>';
|
||||
_setUpdateStatus("Checking for updates…");
|
||||
|
||||
// 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.
|
||||
@@ -14,7 +93,10 @@ async function openUpdateModal() {
|
||||
{ cache: "no-store" },
|
||||
STATUS_POLL_FETCH_TIMEOUT
|
||||
);
|
||||
if (current.running || current.result === "reboot_required") {
|
||||
if (current.running || current.result === "reboot_required" || current.result === "failed") {
|
||||
// An in-progress update, a staged update awaiting reboot, or a prior
|
||||
// failed update — reattach to the persisted systemd/log state instead of
|
||||
// starting over or wrongly reporting "up to date".
|
||||
showExistingUpdate(current);
|
||||
return;
|
||||
}
|
||||
@@ -28,6 +110,7 @@ async function openUpdateModal() {
|
||||
STATUS_POLL_FETCH_TIMEOUT
|
||||
)
|
||||
.then(function(data) {
|
||||
markUpdateChecked();
|
||||
if (!data.available) {
|
||||
stopUpdatePoll();
|
||||
_updateLog = "";
|
||||
@@ -35,12 +118,13 @@ async function openUpdateModal() {
|
||||
_updateVisibleLogChars = 0;
|
||||
_updateFinished = true;
|
||||
_updateStatusUnavailable = false;
|
||||
if ($modalLog) $modalLog.textContent = "";
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
||||
if ($modalLog) $modalLog.innerHTML = '<span class="ok">✓ System is already up to date</span>';
|
||||
_setUpdateStatus("✓ System is already up to date");
|
||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
$modal.classList.add("open");
|
||||
return;
|
||||
@@ -64,11 +148,12 @@ function prepareUpdateModal() {
|
||||
_updateStatusUnavailable = false;
|
||||
_updatePollFailures = 0;
|
||||
if ($modalLog) $modalLog.textContent = "";
|
||||
if ($modalStatus) $modalStatus.textContent = "Starting update…";
|
||||
_setUpdateStatus("Starting update…");
|
||||
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||
$modal.classList.add("open");
|
||||
}
|
||||
@@ -84,7 +169,7 @@ function showExistingUpdate(data) {
|
||||
_updateLogOffset = Number(data.offset) || 0;
|
||||
|
||||
if (data.running) {
|
||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||
_setUpdateStatus("Updating…");
|
||||
startUpdatePoll();
|
||||
return;
|
||||
}
|
||||
@@ -149,17 +234,19 @@ function startUpdate() {
|
||||
)
|
||||
.then(function(data) {
|
||||
if (data.status === "no_updates") {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ System is already up to date";
|
||||
if ($modalLog) $modalLog.innerHTML = '<span class="ok">✓ System is already up to date</span>';
|
||||
_setUpdateStatus("✓ System is already up to date");
|
||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
_updateFinished = true;
|
||||
return;
|
||||
}
|
||||
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
|
||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||
_setUpdateStatus("Updating…");
|
||||
startUpdatePoll();
|
||||
})
|
||||
.catch(function(err) {
|
||||
@@ -229,7 +316,7 @@ async function pollUpdateStatus() {
|
||||
return;
|
||||
}
|
||||
appendLog("[Update status reconnected]\n");
|
||||
if ($modalStatus) $modalStatus.textContent = "Updating…";
|
||||
_setUpdateStatus("Updating…");
|
||||
}
|
||||
if (data.log) appendLog(data.log);
|
||||
_updateLogOffset = data.offset;
|
||||
@@ -252,7 +339,7 @@ async function pollUpdateStatus() {
|
||||
if (!_serverWasDown) {
|
||||
_serverWasDown = true;
|
||||
appendLog("\n[Update status connection interrupted — retrying…]\n");
|
||||
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…";
|
||||
_setUpdateStatus("Reconnecting to update…");
|
||||
}
|
||||
} finally {
|
||||
_updatePollInFlight = false;
|
||||
@@ -264,7 +351,7 @@ function showUpdateStatusUnavailable() {
|
||||
_updateStatusUnavailable = true;
|
||||
stopUpdatePoll();
|
||||
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
||||
if ($modalStatus) $modalStatus.textContent = "Update status unavailable — update may still be running";
|
||||
_setUpdateStatus("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;
|
||||
@@ -277,12 +364,22 @@ function retryUpdateStatus() {
|
||||
_updatePollFailures = 0;
|
||||
_serverWasDown = true;
|
||||
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
||||
if ($modalStatus) $modalStatus.textContent = "Reconnecting to update…";
|
||||
_setUpdateStatus("Reconnecting to update…");
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
||||
startUpdatePoll();
|
||||
}
|
||||
|
||||
// Re-run a failed (or never-applied) update from scratch. The backend always
|
||||
// allows this after a FAILED attempt even though flake.lock may already be
|
||||
// advanced (the previous build never staged a bootable generation).
|
||||
function retryUpdateRun() {
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
||||
if ($btnSave) $btnSave.style.display = "none";
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
_doOpenUpdateModal();
|
||||
}
|
||||
|
||||
function resumeUpdateStatusAfterInterruption() {
|
||||
if (!$modal || !$modal.classList.contains("open")) return;
|
||||
if (_updateStatusUnavailable) {
|
||||
@@ -298,15 +395,16 @@ function onUpdateDone(result) {
|
||||
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
||||
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
||||
if (result === true) {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||
_setUpdateStatus("✓ Update complete");
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else if (result === "reboot_required") {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — restart required";
|
||||
_setUpdateStatus("✓ Update complete — restart required");
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
||||
_setUpdateStatus("✗ Update failed — your system was not changed. Run the update again or save the error report for support.");
|
||||
if ($btnRetryRun) $btnRetryRun.style.display = "inline-flex";
|
||||
if ($btnSave) $btnSave.style.display = "inline-flex";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
if ($btnReboot) $btnReboot.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -364,17 +364,12 @@ async function loadStep3() {
|
||||
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
|
||||
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
|
||||
+ '</div>';
|
||||
html += '<p class="onboarding-hint">Enter each service\'s full domain — a subdomain (e.g. <code>call.yourdomain.com</code>) or a separate domain (e.g. <code>call.com</code>) — and its Njal.la DDNS curl command.</p>';
|
||||
html += '<p class="onboarding-hint">Enter the address for each service and paste its update command from Njal.la.</p>';
|
||||
|
||||
// Router note (the same wording is shown again, per-service, whenever a
|
||||
// domain-based feature is enabled, and lives on each service tile afterwards)
|
||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||
+ renderRouterPortsHtml({
|
||||
internalIp: internalIp,
|
||||
plural: true,
|
||||
includeSsh: true,
|
||||
extraNote: "Element Call needs a few extra ports (some UDP), and you’ll be shown exactly which when you enable it.",
|
||||
})
|
||||
+ renderRouterPortsHtml({ internalIp: internalIp })
|
||||
+ '</div>';
|
||||
|
||||
relevantDomains.forEach(function(d) {
|
||||
@@ -382,20 +377,20 @@ async function loadStep3() {
|
||||
html += '<div class="onboarding-domain-group">';
|
||||
html += '<label class="onboarding-domain-label">' + escHtml(d.label) + '</label>';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="domain-input-' + escHtml(d.name) + '" data-domain="' + escHtml(d.name) + '" placeholder="e.g. ' + escHtml(d.name) + '.yourdomain.com" value="' + escHtml(currentVal) + '" />';
|
||||
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la DDNS Curl Command</label>';
|
||||
html += '<label class="onboarding-domain-label onboarding-domain-label--sub">Njal.la Update Command</label>';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl "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 += '<span class="onboarding-domain-save-status" id="domain-save-status-' + escHtml(d.name) + '" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// SSL email section
|
||||
// Contact email section
|
||||
var emailVal = domainValues["sslemail"] || "";
|
||||
html += '<div class="onboarding-domain-group onboarding-domain-group--email">';
|
||||
html += '<label class="onboarding-domain-label">📧 SSL Certificate Email</label>';
|
||||
html += '<p class="onboarding-hint onboarding-hint--inline">Let\'s Encrypt uses this for certificate expiry notifications.</p>';
|
||||
html += '<label class="onboarding-domain-label">📧 Email Address</label>';
|
||||
html += '<p class="onboarding-hint onboarding-hint--inline">Used to send important notices about your services.</p>';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="email" id="ssl-email-input" placeholder="you@example.com" value="' + escHtml(emailVal) + '" />';
|
||||
html += '<button type="button" class="btn btn-primary onboarding-domain-save-btn" data-save-email="true" style="align-self:flex-start;margin-top:8px;font-size:0.82rem;padding:6px 16px;">Save</button>';
|
||||
html += '<span class="onboarding-domain-save-status" id="domain-save-status-email" style="font-size:0.82rem;min-height:1.2em;"></span>';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sovran_SystemsOS Hub</title>
|
||||
<title>Sovran_SystemsOS — The Hub</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/header.css?v={{ asset_version }}" />
|
||||
@@ -18,65 +18,324 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header bar -->
|
||||
<header class="header-bar">
|
||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
|
||||
|
||||
<div class="title-group">
|
||||
<span class="title">Sovran_SystemsOS Hub</span>
|
||||
<!-- service glyphs -->
|
||||
<!-- ═══ Real service logos — extracted verbatim from app/icons/*.svg ═══
|
||||
(internal ids namespaced per icon to avoid collisions) -->
|
||||
|
||||
<!-- OS Version Badge — styled identically to the version badge in service modals -->
|
||||
<span class="os-version-badge" id="os-version-badge" title="Sovran_SystemsOS Version">v{{ sovran_version }}</span>
|
||||
<!-- symbolic helpers (nav + modal internals) -->
|
||||
<!-- Bitcoin nav icon: the OFFICIAL logo silhouette (paths taken verbatim
|
||||
from app/icons/bitcoin-core.svg) rendered monochrome in currentColor
|
||||
so it matches the symbolic sidebar set -->
|
||||
<symbol id="g-btc-sym" viewBox="-34 -34 580 580">
|
||||
<path fill="currentColor" d="M317.871 7.656c-137.12-34.192-276.024 49.28-310.2 186.44-34.208 137.136 49.256 276.048 186.36 310.24 137.16 34.199 276.063-49.265 310.256-186.408 34.192-137.152-49.264-276.08-186.416-310.272m50.936 211.872c-3.688 24.936-17.512 37.008-35.864 41.24 25.2 13.12 38.024 33.239 25.809 68.12-15.16 43.319-51.176 46.976-99.072 37.912l-11.624 46.584-28.088-7 11.472-45.96a1076 1076 0 0 1-22.384-5.809l-11.512 46.177-28.056-7 11.624-46.673c-6.561-1.68-13.225-3.464-20.024-5.168l-36.552-9.111 13.943-32.152s20.696 5.504 20.416 5.096c7.952 1.969 11.48-3.216 12.872-6.672l18.368-73.64.048-.2 13.104-52.568c.344-5.968-1.712-13.496-13.088-16.336.439-.296-20.4-5.072-20.4-5.072l7.472-30 38.736 9.673-.032.144c5.824 1.448 11.824 2.824 17.937 4.216L245.423 89.2l28.072 7-11.28 45.224c7.536 1.721 15.12 3.456 22.504 5.297l11.2-44.929 28.088 7-11.504 46.145c35.464 12.215 61.401 30.527 56.304 64.591"/>
|
||||
<path fill="currentColor" d="m254.647 174.6-13.983 56.08c15.855 3.951 64.735 20.071 72.656-11.656 8.248-33.096-42.817-40.472-58.673-44.424"/>
|
||||
<path fill="currentColor" d="m233.608 258.984-15.425 61.832c19.04 4.729 77.769 23.584 86.448-11.296 9.072-36.376-51.984-45.784-71.023-50.536"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="g-chat" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 5.5v7a2.5 2.5 0 0 1-2.5 2.5H10l-4 3.2c-.6.5-1 .3-1-.5V5.5A2.5 2.5 0 0 1 7.5 3h10A2.5 2.5 0 0 1 20 5.5z"/>
|
||||
<circle cx="8.7" cy="9.2" r="0.5" fill="currentColor"/><circle cx="12" cy="9.2" r="0.5" fill="currentColor"/><circle cx="15.3" cy="9.2" r="0.5" fill="currentColor"/>
|
||||
</g>
|
||||
</symbol>
|
||||
|
||||
<symbol id="g-antenna" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
|
||||
<path d="M5.4 9.9a9.3 9.3 0 0 1 13.2 0"/>
|
||||
<path d="M8.1 12.9a5.6 5.6 0 0 1 7.8 0"/>
|
||||
<circle cx="12" cy="16.6" r="1.5" fill="currentColor" stroke="none"/>
|
||||
<path d="M12 16.6V21"/>
|
||||
</g>
|
||||
</symbol>
|
||||
|
||||
<symbol id="g-lock" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5.5" y="10.5" width="13" height="9.5" rx="2.6"/>
|
||||
<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>
|
||||
<circle cx="12" cy="15" r="1.3" fill="currentColor" stroke="none"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<!-- nav + ui symbolic icons -->
|
||||
<symbol id="g-home" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 11.2L12 4.5l8 6.7"/>
|
||||
<path d="M6 10.5v8a1.2 1.2 0 0 0 1.2 1.2h9.6a1.2 1.2 0 0 0 1.2-1.2v-8"/>
|
||||
<path d="M10 19.7v-5.4h4v5.4"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-grid" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round">
|
||||
<rect x="3.5" y="3.5" width="7.2" height="7.2" rx="2"/><rect x="13.3" y="3.5" width="7.2" height="7.2" rx="2"/>
|
||||
<rect x="3.5" y="13.3" width="7.2" height="7.2" rx="2"/><rect x="13.3" y="13.3" width="7.2" height="7.2" rx="2"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-server" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round">
|
||||
<rect x="3.5" y="3.5" width="17" height="7.4" rx="2"/><rect x="3.5" y="13.1" width="17" height="7.4" rx="2"/>
|
||||
<circle cx="7.2" cy="7.2" r="0.6" fill="currentColor"/><circle cx="7.2" cy="16.8" r="0.6" fill="currentColor"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-dots" viewBox="0 0 24 24">
|
||||
<g fill="currentColor">
|
||||
<circle cx="5" cy="5" r="1.7"/><circle cx="12" cy="5" r="1.7"/><circle cx="19" cy="5" r="1.7"/>
|
||||
<circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/>
|
||||
<circle cx="5" cy="19" r="1.7"/><circle cx="12" cy="19" r="1.7"/><circle cx="19" cy="19" r="1.7"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-refresh" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M20 12a8 8 0 1 1-2.9-6.2"/>
|
||||
<path d="M20.6 2.6l-.5 4.9-4.6-1.7z" fill="currentColor" stroke="none"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<!-- Update System: down arrow into a tray (get / install updates) -->
|
||||
<symbol id="g-update" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 4v10.5"/>
|
||||
<path d="M7.8 10.3L12 14.5l4.2-4.2"/>
|
||||
<path d="M4.5 16.5v2a2.5 2.5 0 0 0 2.5 2.5h10a2.5 2.5 0 0 0 2.5-2.5v-2"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-box" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3.5" y="4" width="17" height="4.4" rx="1.6"/>
|
||||
<path d="M5.2 8.4v9a2.4 2.4 0 0 0 2.4 2.4h8.8a2.4 2.4 0 0 0 2.4-2.4v-9"/>
|
||||
<path d="M10 12.5h4"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-shield-check" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2.5 4.5 5.5v6c0 4.6 3.1 8 7.5 9.9 4.4-1.9 7.5-5.3 7.5-9.9v-6z"/>
|
||||
<path d="M8.6 12l2.4 2.4 4.4-4.6"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<!-- Systems Operational: activity pulse (system health) — distinct from
|
||||
the Security shield above -->
|
||||
<symbol id="g-pulse" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M3 12h4l3-7 4 14 3-7h4"/>
|
||||
</symbol>
|
||||
<symbol id="g-lifebuoy" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="8.6"/><circle cx="12" cy="12" r="3.6"/>
|
||||
<path d="M6 6l3.2 3.2M18 6l-3.2 3.2M6 18l3.2-3.2M18 18l-3.2-3.2"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-power" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M7.2 6.1a7 7 0 1 0 9.6 0"/>
|
||||
<path d="M12 2.8v8.4"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-logout" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14"/>
|
||||
<path d="M4 12h10M10.5 8.5 14 12l-3.5 3.5"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-search" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="11" cy="11" r="6.5"/>
|
||||
<path d="M15.8 15.8 21 21"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-copy" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="11" height="11" rx="2.2"/>
|
||||
<path d="M5.5 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v.5"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-check" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.5l5 5 10-11"/></symbol>
|
||||
<symbol id="g-chev" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" d="M9.5 5.5 16 12l-6.5 6.5"/></symbol>
|
||||
<symbol id="g-x" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" d="M6 6l12 12M18 6 6 18"/></symbol>
|
||||
<symbol id="g-wifi" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
|
||||
<path d="M4.5 10.2a11 11 0 0 1 15 0"/>
|
||||
<path d="M7.3 13.3a7 7 0 0 1 9.4 0"/>
|
||||
<circle cx="12" cy="17" r="1.4" fill="currentColor" stroke="none"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-key" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="7.5" cy="15.5" r="4"/>
|
||||
<path d="M10.4 12.6 20 3M16.2 6.8l2.4 2.4M18.8 4.2l2.2 2.2"/>
|
||||
</g>
|
||||
</symbol>
|
||||
<symbol id="g-alert" viewBox="0 0 24 24">
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3.6 22.2 20.4H1.8z"/>
|
||||
<path d="M12 9.8v4.6"/><circle cx="12" cy="17.2" r="0.6" fill="currentColor" stroke="none"/>
|
||||
</g>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
||||
<!-- ═══ BOOT SPLASH ═══ covers the shell while the first data loads;
|
||||
dashboard.js lifts it once the welcome cards are rendered -->
|
||||
<div id="app-splash" role="status" aria-live="polite">
|
||||
<div class="splash-card">
|
||||
<div class="splash-ring">
|
||||
<img src="/static/sovran-hub-icon.svg" alt="" width="64" height="64" />
|
||||
<span class="splash-ring-arc" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="splash-title">Starting The Hub</div>
|
||||
<div class="splash-sub" id="splash-sub">Gathering your services…</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var splash = document.getElementById("app-splash");
|
||||
var sub = document.getElementById("splash-sub");
|
||||
var lifted = false;
|
||||
function lift() {
|
||||
if (lifted || !splash) return;
|
||||
lifted = true;
|
||||
splash.classList.add("done");
|
||||
setTimeout(function () {
|
||||
if (splash && splash.parentNode) splash.parentNode.removeChild(splash);
|
||||
}, 500);
|
||||
}
|
||||
window.__liftAppSplash = lift;
|
||||
// Reassure the user if the backend is slow (e.g. right after a reboot)
|
||||
setTimeout(function () {
|
||||
if (!lifted && sub) sub.textContent = "Still starting\u2026 this can take a moment after a reboot.";
|
||||
}, 8000);
|
||||
// Never trap the user behind the splash
|
||||
setTimeout(lift, 25000);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
|
||||
<!-- ═══ SIDEBAR ═══ -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="brand">
|
||||
<img src="/static/sovran-hub-icon.svg" class="brand-logo" alt="The Hub" />
|
||||
<div class="brand-text">
|
||||
<div class="brand-title">The <em>Hub</em></div>
|
||||
<div class="brand-sub">Sovran_SystemsOS v{{ sovran_version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="sidebar-nav" aria-label="Service categories"></nav>
|
||||
|
||||
<div class="nav-label">System</div>
|
||||
<div id="sidebar-support"></div>
|
||||
|
||||
<!-- Feature Manager + Preferences (features.js) -->
|
||||
<div id="sidebar-features"></div>
|
||||
|
||||
<div class="sidebar-spacer"></div>
|
||||
</aside>
|
||||
|
||||
<!-- ═══ MAIN ═══ -->
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="page-title" id="page-title">Dashboard</div>
|
||||
|
||||
<div class="search-box">
|
||||
<svg><use href="#g-search"/></svg>
|
||||
<input id="search-input" type="text" placeholder="Search services…" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="top-actions">
|
||||
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
|
||||
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content" id="content">
|
||||
<!-- ═══ WELCOME DASHBOARD (default view) ═══ -->
|
||||
<section class="welcome" id="welcome-view">
|
||||
<div class="welcome-bg" aria-hidden="true">
|
||||
<div class="orb orb-a"></div>
|
||||
<div class="orb orb-b"></div>
|
||||
<div class="orb orb-c"></div>
|
||||
</div>
|
||||
<div class="welcome-inner">
|
||||
<div class="welcome-greeting" id="welcome-greeting">Hello</div>
|
||||
<h1 class="welcome-title">Welcome to Your <em>Sovereign</em> Digital & Financial Life</h1>
|
||||
<div class="welcome-meta">Sovran_SystemsOS v{{ sovran_version }}<span class="welcome-meta-sep">·</span><span id="welcome-role"></span></div>
|
||||
<div class="welcome-cards" id="welcome-cards">
|
||||
<div class="welcome-dyn" id="wc-systems"></div>
|
||||
<div class="widget w-network" id="w-network">
|
||||
<div class="widget-chip chip-green"><svg><use href="#g-wifi"/></svg></div>
|
||||
<div class="w-body">
|
||||
<h3>Network</h3>
|
||||
<div class="net-row"><span class="net-k">LAN</span><span class="ip-value" id="ip-internal">…</span></div>
|
||||
<div class="net-row"><span class="net-k">WAN</span><span class="ip-value" id="ip-external">…</span></div>
|
||||
<div class="sub">sovransystemsos.local</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcome-dyn" id="wc-more"></div>
|
||||
</div>
|
||||
<button class="btn btn-ghost welcome-browse" id="welcome-browse-btn">Browse all services →</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ SERVICES GRID ═══ -->
|
||||
<div id="tiles-area" style="display:none">
|
||||
<div class="dashboard-loading" role="status" aria-live="polite">
|
||||
<span class="dashboard-loading-spinner" aria-hidden="true"></span>
|
||||
<span>Loading service status…</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="header-buttons">
|
||||
<span class="role-badge" id="role-badge">Loading…</span>
|
||||
<button class="btn btn-header-reboot" id="btn-header-reboot" title="Restart the entire computer">Reboot</button>
|
||||
<button class="btn btn-logout" id="btn-logout" title="Sign out">Sign Out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- IP bar -->
|
||||
<div class="ip-bar">
|
||||
<span>
|
||||
<span class="ip-label">Internal IP:</span>
|
||||
<span class="ip-value" id="ip-internal">…</span>
|
||||
</span>
|
||||
<span class="ip-separator">|</span>
|
||||
<span>
|
||||
<span class="ip-label">External IP:</span>
|
||||
<span class="ip-value" id="ip-external">…</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Service tiles -->
|
||||
<main class="main-content">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div id="sidebar-support"></div>
|
||||
<div id="sidebar-features"></div>
|
||||
</aside>
|
||||
<div id="tiles-area">
|
||||
<div class="dashboard-loading" role="status" aria-live="polite">
|
||||
<span class="dashboard-loading-spinner" aria-hidden="true"></span>
|
||||
<span>Loading service status…</span>
|
||||
<!-- ═══ SYSTEMS OPERATIONAL MODAL (dashboard.js) ═══ -->
|
||||
<div class="modal-overlay" id="systems-modal" role="dialog" aria-modal="true" aria-labelledby="systems-modal-title">
|
||||
<div class="creds-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="systems-modal-title">
|
||||
<span class="widget-chip chip-green" style="width:54px;height:54px;border-radius:16px"><svg style="width:27px;height:27px"><use href="#g-pulse"/></svg></span>
|
||||
<span>Systems Operational</span>
|
||||
</span>
|
||||
<button class="creds-close-btn" id="systems-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body" id="systems-body">
|
||||
<p class="creds-loading">Loading…</p>
|
||||
</div>
|
||||
<!-- Auto-launch preference (features.js) — moved here from the sidebar -->
|
||||
<div class="sysprefs" id="autolaunch-slot"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Update modal -->
|
||||
<div class="modal-overlay" id="update-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-text">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="modal-title-text">Sovran_SystemsOS Update</span>
|
||||
<div class="modal-spinner" id="modal-spinner"></div>
|
||||
<span class="modal-status" id="modal-status">Updating…</span>
|
||||
<div class="upd-header">
|
||||
<span class="widget-chip chip-sovran upd-chip"><svg class="upd-chip-svg"><use href="#g-update"/></svg></span>
|
||||
<span class="upd-title-wrap">
|
||||
<span class="upd-title" id="modal-title-text">Sovran_SystemsOS Update</span>
|
||||
<span class="upd-sub">
|
||||
<span class="ver-chip">Sovran_SystemsOS v{{ sovran_version }}</span>
|
||||
<span class="upd-pill" id="upd-pill"></span>
|
||||
<span class="modal-spinner" id="modal-spinner"></span>
|
||||
</span>
|
||||
</span>
|
||||
<button class="creds-close-btn" id="update-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body-scroll">
|
||||
<div class="sysmodal-card">
|
||||
<div class="sysmodal-card-title"><svg><use href="#g-server"/></svg>System Details</div>
|
||||
<div class="sysstep"><div class="sysnum">1</div><div class="sysstep-x"><div class="sysstep-t">Current version</div><div class="sysval"><span class="sysval-text">{{ sovran_version }}</span></div></div></div>
|
||||
<div class="sysstep"><div class="sysnum">2</div><div class="sysstep-x"><div class="sysstep-t">Release channel</div><div class="sysval"><span class="sysval-text">stable</span></div></div></div>
|
||||
<div class="sysstep"><div class="sysnum">3</div><div class="sysstep-x"><div class="sysstep-t">Last checked</div><div class="sysval"><span class="sysval-text" id="upd-last-checked">—</span></div></div></div>
|
||||
</div>
|
||||
<div class="update-status-msg" id="modal-status">Checking for updates…</div>
|
||||
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="modal-log" id="modal-log" aria-live="polite"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="btn-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-save" id="btn-retry-update-status" style="display:none">Retry Status</button>
|
||||
<button class="btn btn-reboot" id="btn-retry-update" style="display:none">Retry Update</button>
|
||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
||||
<span class="modal-footer-spacer"></span>
|
||||
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
||||
<button class="btn btn-primary" id="btn-check-again"><svg><use href="#g-refresh"/></svg>Check again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +357,10 @@
|
||||
<div class="modal-overlay" id="support-modal" role="dialog" aria-modal="true" aria-labelledby="support-modal-title">
|
||||
<div class="creds-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="support-modal-title">Tech Support</span>
|
||||
<span class="creds-title">
|
||||
<span class="widget-chip chip-neutral" id="support-modal-chip" style="width:54px;height:54px;border-radius:16px"><svg style="width:27px;height:27px"><use href="#g-lifebuoy"/></svg></span>
|
||||
<span id="support-modal-title">Tech Support</span>
|
||||
</span>
|
||||
<button class="creds-close-btn" id="support-close-btn" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="creds-body" id="support-body">
|
||||
@@ -170,15 +432,26 @@
|
||||
<!-- Rebuild Modal -->
|
||||
<div class="modal-overlay" id="rebuild-modal" role="dialog" aria-modal="true" aria-labelledby="rebuild-modal-title">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
|
||||
<div class="modal-spinner" id="rebuild-spinner"></div>
|
||||
<span class="modal-status" id="rebuild-status">Rebuilding…</span>
|
||||
<div class="upd-header">
|
||||
<span class="widget-chip chip-sovran upd-chip"><svg class="upd-chip-svg"><use href="#g-server"/></svg></span>
|
||||
<span class="upd-title-wrap">
|
||||
<span class="upd-title" id="rebuild-modal-title">Sovran_SystemsOS Rebuild</span>
|
||||
<span class="upd-sub">
|
||||
<span class="ver-chip">Sovran_SystemsOS v{{ sovran_version }}</span>
|
||||
<span class="upd-pill" id="rebuild-pill"></span>
|
||||
<span class="modal-spinner" id="rebuild-spinner"></span>
|
||||
</span>
|
||||
</span>
|
||||
<button class="creds-close-btn" id="rebuild-close-hdr" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body-scroll">
|
||||
<div class="update-status-msg" id="rebuild-status">Rebuilding…</div>
|
||||
<div class="modal-log" id="rebuild-log" aria-live="polite" style="display:none"></div>
|
||||
</div>
|
||||
<div class="modal-log" id="rebuild-log" aria-live="polite"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-save" id="rebuild-save-report" style="display:none">Save Error Report</button>
|
||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
|
||||
<span class="modal-footer-spacer"></span>
|
||||
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,22 +470,14 @@
|
||||
encrypted messaging, password management, cloud storage, website hosting, and more.
|
||||
</p>
|
||||
<div class="upgrade-info-box">
|
||||
<p class="upgrade-info-title">⚠ What you should know:</p>
|
||||
<p class="upgrade-info-title">Simple, guided setup:</p>
|
||||
<ul class="upgrade-info-list">
|
||||
<li>You will need to purchase domains for your services — <a href="https://njal.la" target="_blank" rel="noopener noreferrer">Njal.la</a> is the only supported domain provider</li>
|
||||
<li>Some services require ports to be opened on your router</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="upgrade-info-box">
|
||||
<p class="upgrade-info-title">ℹ️ Good to know:</p>
|
||||
<ul class="upgrade-info-list">
|
||||
<li>Your services will be accessible via your home internet connection. Your approximate geographic area may be visible through DNS records — this is normal for all self-hosted services</li>
|
||||
<li>Your Bitcoin node remains fully private over Tor</li>
|
||||
<li>Sovran_SystemsOS walks you through getting your domain from <a href="https://njal.la" target="_blank" rel="noopener noreferrer">Njal.la</a> and connecting your services</li>
|
||||
<li>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="support-desc">
|
||||
<strong>Don't worry</strong> — the Hub will walk you through every step after the upgrade.
|
||||
Domain setup, port forwarding, and configuration are all guided.
|
||||
The Hub guides you through every step.
|
||||
</p>
|
||||
<p class="support-desc upgrade-rebuild-note">
|
||||
The system will rebuild after upgrading. This may take several minutes.
|
||||
@@ -263,7 +528,7 @@
|
||||
<div class="reboot-overlay" id="reboot-overlay">
|
||||
<!-- Normal restarting card -->
|
||||
<div class="reboot-card" id="reboot-main-card">
|
||||
<div class="reboot-icon" aria-hidden="true">↻</div>
|
||||
<div class="reboot-icon" aria-hidden="true"></div>
|
||||
<h2 class="reboot-title">Restarting Entire System</h2>
|
||||
<p class="reboot-message">
|
||||
The entire computer is restarting, including the desktop and all hosted services.<br />
|
||||
@@ -278,7 +543,7 @@
|
||||
</div>
|
||||
<!-- Error card (shown if restart request fails definitively) -->
|
||||
<div class="reboot-card" id="reboot-error-card" style="display:none">
|
||||
<div class="reboot-icon" aria-hidden="true">⚠</div>
|
||||
<div class="reboot-icon" aria-hidden="true"></div>
|
||||
<h2 class="reboot-title">Restart could not be started</h2>
|
||||
<p class="reboot-message">
|
||||
The computer did not begin restarting. No services were intentionally stopped. Please try again.
|
||||
@@ -323,6 +588,7 @@
|
||||
<script src="/static/js/rebuild.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/features.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/security.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/dashboard.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/events.js?v={{ asset_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sovran Hub — Login</title>
|
||||
<title>The Hub — Login</title>
|
||||
<link rel="stylesheet" href="/static/css/base.css?v={{ asset_version }}" />
|
||||
<link rel="stylesheet" href="/static/css/buttons.css?v={{ asset_version }}" />
|
||||
</head>
|
||||
@@ -11,8 +11,9 @@
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="login-logo" />
|
||||
<div class="login-title">Sovran Hub</div>
|
||||
<img src="/static/sovran-hub-icon.svg" alt="The Hub" class="login-logo" />
|
||||
<div class="login-title">The <em>Hub</em></div>
|
||||
<div class="login-sub">Sovran_SystemsOS</div>
|
||||
</div>
|
||||
|
||||
<form class="login-form" id="login-form" onsubmit="return false;">
|
||||
|
||||
@@ -50,10 +50,10 @@
|
||||
<p class="onboarding-body-text" style="text-align:center; margin-bottom:4px;">
|
||||
Your new login password is:
|
||||
</p>
|
||||
<div id="migration-password-value" style="font-family:monospace; font-size:1.35rem; font-weight:700; color:var(--text-primary); background:rgba(109, 191, 139, 0.10); border:1.5px solid rgba(109, 191, 139, 0.35); border-radius:8px; padding:14px 24px; letter-spacing:0.04em; text-align:center; word-break:break-all; margin-bottom:8px;">
|
||||
<div id="migration-password-value" style="font-family:monospace; font-size:1.35rem; font-weight:700; color:var(--text-primary); background:rgba(62, 207, 142, 0.10); border:1.5px solid rgba(62, 207, 142, 0.35); border-radius:8px; padding:14px 24px; letter-spacing:0.04em; text-align:center; word-break:break-all; margin-bottom:8px;">
|
||||
|
||||
</div>
|
||||
<div style="padding:10px 14px; background-color:rgba(229, 165, 10, 0.1); border:1px solid rgba(229, 165, 10, 0.35); border-radius:8px; font-size:0.92rem; color:var(--yellow); line-height:1.55;">
|
||||
<div style="padding:10px 14px; background-color:rgba(233, 182, 74, 0.1); border:1px solid rgba(233, 182, 74, 0.35); border-radius:8px; font-size:0.92rem; color:var(--yellow); line-height:1.55;">
|
||||
⚠ Write this password down! You will need it to log in next time. This is also your Sovran Hub login password.
|
||||
</div>
|
||||
<div id="migration-password-status" class="onboarding-save-status" style="margin-top:8px;"></div>
|
||||
@@ -123,10 +123,10 @@
|
||||
<div class="onboarding-panel" id="step-3" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🌐</span>
|
||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
||||
<h2 class="onboarding-step-title">Connect Your Services</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Sovran_SystemsOS uses <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> for domains and Dynamic DNS, and your router needs ports <strong>80</strong> and <strong>443</strong> (TCP) forwarded to this computer.
|
||||
Everything you need — Njal.la account, Dynamic records, and the one router task — is laid out step by step below.
|
||||
Sovran_SystemsOS walks you through getting your domain from <strong><a href="https://njal.la" target="_blank" style="color: var(--accent-color);">Njal.la</a></strong> and connecting your services.
|
||||
To make your services available outside your home, you’ll complete one router task: forward ports <strong>80</strong> and <strong>443</strong> to this computer.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-3-body">
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
"bitcoind.service": "27.1.0",
|
||||
"electrs.service": "0.10.6",
|
||||
"lnd.service": "0.18.0",
|
||||
"rtl.service": "0.15.10",
|
||||
"rtl.service": "0.15.12",
|
||||
"btcpayserver.service": "2.4.2",
|
||||
"albyhub.service": "1.8.0",
|
||||
"albyhub.service": "1.24.0",
|
||||
"mempool.service": "3.2.1",
|
||||
"matrix-synapse.service": "1.115.0",
|
||||
"livekit.service": "1.5.2",
|
||||
"livekit.service": "1.13.6",
|
||||
"vaultwarden.service": "1.32.0",
|
||||
"haven-relay.service": "0.1.0"
|
||||
}
|
||||
|
||||
+1
-1
@@ -147,7 +147,7 @@
|
||||
hunspell hunspellDicts.en_US
|
||||
synadm brave-origin dua
|
||||
gparted pv unzip parted screen zenity
|
||||
libargon2 gnome-terminal libreoffice-fresh
|
||||
libargon2 gnome-terminal libreoffice-stable
|
||||
dig firefox wp-cli axel
|
||||
lk-jwt-service livekit-libwebrtc livekit
|
||||
matrix-synapse age onlyoffice-desktopeditors
|
||||
|
||||
Generated
+74
-22
@@ -5,11 +5,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787246616,
|
||||
"narHash": "sha256-TTbXIBwoaPbzk2o+rGEayqOvrXqKo9JdYJAMDwqjCQ4=",
|
||||
"lastModified": 1788871704,
|
||||
"narHash": "sha256-Vtc2SqCB7NOO028+K5JHbpWQ3XVpLyoC75MZQ12l13o=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "btc-clients-nix",
|
||||
"rev": "8b10c40cb13bac5d100ae1d1fb42eccc0d9c3223",
|
||||
"rev": "e14502cba22806341f8c54c6f0c349094830c58b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -26,11 +26,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785627969,
|
||||
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
||||
"lastModified": 1787559586,
|
||||
"narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
||||
"rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -41,11 +41,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1785590095,
|
||||
"narHash": "sha256-CNO2szJbdLjVN/Hi1BML9MSALz1GM2fIdwnzs404QO8=",
|
||||
"lastModified": 1788179970,
|
||||
"narHash": "sha256-r5LmxzIhsu5+oDybatN/HJ8roYOKjb2Apa5xI6v46VU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e568f3b19d54b08f48bfae9b12b3e124d1a28002",
|
||||
"rev": "1db62ab7d2ccf1916bbf7deb61fc9d16f1c4ab49",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -56,11 +56,27 @@
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1787101114,
|
||||
"narHash": "sha256-gwrPcFf/rDjHPaVflbDZ040ZDmBTRj/7+s8ZmE2SaIM=",
|
||||
"lastModified": 1788921488,
|
||||
"narHash": "sha256-8+xWRxEkD6l217cIUdRxfeUGS9lQX0hVtUuNVsBaDzk=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b18a4b905f8d028dc4476412e6d6891728695379",
|
||||
"rev": "6aefcda9401be8acc2b74244fb3b37520ea1f0a8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-stable_2": {
|
||||
"locked": {
|
||||
"lastModified": 1788807765,
|
||||
"narHash": "sha256-J9oC0bKnkXUrMegqRTXVkyDFJ0gn2U/Qpoo9HgGMQmA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "93108a538f079596c9a16c72cf03e9322782b6dd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -72,11 +88,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1787135253,
|
||||
"narHash": "sha256-RD2kNWCG+Bjo6h+JVjWVNntZs2GtRoeY2xHjts/FNkA=",
|
||||
"lastModified": 1788881743,
|
||||
"narHash": "sha256-2V9GZGvPfrNzxFozhI9dcqV+c3QdA8YZrvAAzqEB+dI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ffb3c9b700e759be2ef13237c9d8f953b32a1e46",
|
||||
"rev": "d6524aaca2ff07876657ae2b323f24be4874944b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -88,11 +104,11 @@
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1787111413,
|
||||
"narHash": "sha256-sFosWtq21eHGJRnTc/hvf4M1obRgLEUMNm/IzllkHMA=",
|
||||
"lastModified": 1787631388,
|
||||
"narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "afe3d8ac4395617bdcdac9f188ac8717a062e014",
|
||||
"rev": "ac6b2166e7a9375683b8e98f860f273222337b16",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -102,6 +118,22 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1788881743,
|
||||
"narHash": "sha256-2V9GZGvPfrNzxFozhI9dcqV+c3QdA8YZrvAAzqEB+dI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d6524aaca2ff07876657ae2b323f24be4874944b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixvim": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
@@ -109,11 +141,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787151631,
|
||||
"narHash": "sha256-EblMdrDFBFNNlUPm5zUQdh0j6gDx+OFQPv7LFE4B5AA=",
|
||||
"lastModified": 1788190018,
|
||||
"narHash": "sha256-59BAfH0txPAZrPBF4QJqwvUWppD+ICrcjA1LZAmPnrQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "d0d62a2b5027da689b4e8d5ee43f1cf83f2e975d",
|
||||
"rev": "41844750e55f17b1385d5b09ca7ade5f11f49506",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -127,7 +159,27 @@
|
||||
"btc-clients": "btc-clients",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-stable": "nixpkgs-stable",
|
||||
"nixvim": "nixvim"
|
||||
"nixvim": "nixvim",
|
||||
"sovran-bitcoin": "sovran-bitcoin"
|
||||
}
|
||||
},
|
||||
"sovran-bitcoin": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"nixpkgs-stable": "nixpkgs-stable_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1788921077,
|
||||
"narHash": "sha256-k2i55M0lK3xzjsJKS/kOhQHSf8479r6RYEllnICxggg=",
|
||||
"owner": "naturallaw777",
|
||||
"repo": "Sovran_Bitcoin",
|
||||
"rev": "7c4d5b509c0533ea48192146f987182668bf42dc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "naturallaw777",
|
||||
"repo": "Sovran_Bitcoin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
nixvim.url = "github:nix-community/nixvim";
|
||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||
|
||||
# Bitcoin / Lightning stack — standalone flake, consumed as a module.
|
||||
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, ... }:
|
||||
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, sovran-bitcoin, ... }:
|
||||
|
||||
let
|
||||
overlay-stable = final: prev: {
|
||||
@@ -16,6 +19,24 @@
|
||||
system = prev.stdenv.hostPlatform.system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
|
||||
# Pin LiveKit to 1.13.6: element-calling.nix sets
|
||||
# rtc.advertise_internal_ip, which gives LAN callers a host candidate
|
||||
# so calls work on Wi-Fi without the router needing NAT-hairpin. That
|
||||
# flag is only honoured when node_ip is set manually from LiveKit
|
||||
# v1.13.6 (mediatransportutil f234b53); nixpkgs-unstable currently
|
||||
# ships 1.13.5. Remove this override once nixpkgs-unstable reaches
|
||||
# >= 1.13.6.
|
||||
livekit = prev.livekit.overrideAttrs (old: {
|
||||
version = "1.13.6";
|
||||
src = prev.fetchFromGitHub {
|
||||
owner = "livekit";
|
||||
repo = "livekit";
|
||||
rev = "v1.13.6";
|
||||
hash = "sha256-sUAx6ooeEUUqot5xuZv7xiQa3DdRFVULteTwYgFUzCI=";
|
||||
};
|
||||
vendorHash = "sha256-nOGSmoNuQQm/sIVI1HojsiS4GkbhA68uYMQ6X7d4a5Q=";
|
||||
});
|
||||
};
|
||||
in
|
||||
{
|
||||
@@ -33,7 +54,7 @@
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
./iso/common.nix
|
||||
./modules/bitcoin
|
||||
sovran-bitcoin.nixosModules.default
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
};
|
||||
@@ -44,7 +65,8 @@
|
||||
nixpkgs.overlays = [ overlay-stable ];
|
||||
})
|
||||
./configuration.nix
|
||||
./modules/bitcoin
|
||||
sovran-bitcoin.nixosModules.default
|
||||
./modules/sovran-bitcoin-integration.nix
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
config = {
|
||||
@@ -60,17 +82,9 @@
|
||||
pkgs = import nixpkgs {
|
||||
system = "x86_64-linux";
|
||||
};
|
||||
fetchNodeModules =
|
||||
pkgs.callPackage ./packages/build-support/fetch-node-modules.nix {};
|
||||
mempoolPkgs =
|
||||
pkgs.callPackage ./packages/mempool { inherit fetchNodeModules; };
|
||||
in {
|
||||
bitcoin-btcpay-hardening = import ./tests/bitcoin-btcpay-hardening.nix {
|
||||
inherit nixpkgs overlay-stable;
|
||||
};
|
||||
mempool-backend = mempoolPkgs.mempool-backend;
|
||||
mempool-frontend = mempoolPkgs.mempool-frontend;
|
||||
rtl = pkgs.callPackage ./packages/rtl { inherit fetchNodeModules; };
|
||||
# Bitcoin hardening and package checks now live in the Sovran_Bitcoin flake.
|
||||
# Run them with: nix build github:naturallaw777/Sovran_Bitcoin#checks.x86_64-linux
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+1
-35
@@ -423,40 +423,6 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
sep.set_margin_end(40)
|
||||
outer.append(sep)
|
||||
|
||||
notice_frame = Gtk.Frame()
|
||||
notice_frame.add_css_class("card")
|
||||
notice_frame.set_margin_start(40)
|
||||
notice_frame.set_margin_end(40)
|
||||
notice_frame.set_margin_top(20)
|
||||
notice_frame.set_margin_bottom(4)
|
||||
|
||||
notice_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
notice_box.set_margin_top(12)
|
||||
notice_box.set_margin_bottom(12)
|
||||
notice_box.set_margin_start(16)
|
||||
notice_box.set_margin_end(16)
|
||||
|
||||
notice_icon = symbolic_icon("dialog-information-symbolic")
|
||||
notice_icon.set_valign(Gtk.Align.START)
|
||||
notice_box.append(notice_icon)
|
||||
|
||||
notice_lbl = Gtk.Label()
|
||||
notice_lbl.set_use_markup(True)
|
||||
notice_lbl.set_wrap(True)
|
||||
notice_lbl.set_xalign(0)
|
||||
notice_lbl.set_halign(Gtk.Align.FILL)
|
||||
notice_lbl.set_markup(
|
||||
"<span weight='bold'>Heads up — Server + Desktop prerequisites</span>\n"
|
||||
"• A domain or subdomain from <span weight='bold'>https://njal.la</span>\n"
|
||||
"• The ability to open / forward ports on your router\n\n"
|
||||
"Don't worry — after install, the onboarding wizard walks you through every step.\n"
|
||||
"<span size='small'>Desktop Only and Node Only do not require a domain or port forwarding.</span>"
|
||||
)
|
||||
notice_box.append(notice_lbl)
|
||||
|
||||
notice_frame.set_child(notice_box)
|
||||
outer.append(notice_frame)
|
||||
|
||||
if self.virtualization:
|
||||
vm_frame = Gtk.Frame()
|
||||
vm_frame.add_css_class("card")
|
||||
@@ -505,7 +471,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
# Role cards
|
||||
roles = [
|
||||
("Server + Desktop",
|
||||
"Full sovereign experience: beautiful desktop, your own cloud, secure messaging, Bitcoin node, and more.",
|
||||
"Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer.",
|
||||
"Server+Desktop"),
|
||||
("Desktop Only",
|
||||
"A beautiful, easy-to-use desktop without the background server applications.",
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# RPC calls that are safe for public use
|
||||
# Vendored from nix-bitcoin - do not fetch from upstream at runtime
|
||||
[
|
||||
"echo"
|
||||
"getinfo"
|
||||
"getindexinfo"
|
||||
"help"
|
||||
"ping"
|
||||
"uptime"
|
||||
# Blockchain
|
||||
"getbestblockhash"
|
||||
"getblock"
|
||||
"getblockchaininfo"
|
||||
"getblockcount"
|
||||
"getblockfilter"
|
||||
"getblockfrompeer"
|
||||
"getblockhash"
|
||||
"getblockheader"
|
||||
"getblockstats"
|
||||
"getchaintips"
|
||||
"getchaintxstats"
|
||||
"getdeploymentinfo"
|
||||
"getdifficulty"
|
||||
"getmempoolancestors"
|
||||
"getmempooldescendants"
|
||||
"getmempoolentry"
|
||||
"getmempoolinfo"
|
||||
"getrawmempool"
|
||||
"gettxout"
|
||||
"gettxoutproof"
|
||||
"gettxoutsetinfo"
|
||||
"scantxoutset"
|
||||
"verifytxoutproof"
|
||||
# Mining
|
||||
"getblocktemplate"
|
||||
"getmininginfo"
|
||||
"getnetworkhashps"
|
||||
# Network
|
||||
"getnetworkinfo"
|
||||
"getnodeaddresses"
|
||||
"getpeerinfo"
|
||||
# Rawtransactions
|
||||
"analyzepsbt"
|
||||
"combinepsbt"
|
||||
"combinerawtransaction"
|
||||
"converttopsbt"
|
||||
"createpsbt"
|
||||
"createrawtransaction"
|
||||
"decodepsbt"
|
||||
"decoderawtransaction"
|
||||
"decodescript"
|
||||
"finalizepsbt"
|
||||
"fundrawtransaction"
|
||||
"getrawtransaction"
|
||||
"joinpsbts"
|
||||
"sendrawtransaction"
|
||||
"signrawtransactionwithkey"
|
||||
"testmempoolaccept"
|
||||
"utxoupdatepsbt"
|
||||
# Util
|
||||
"createmultisig"
|
||||
"deriveaddresses"
|
||||
"estimatesmartfee"
|
||||
"getdescriptorinfo"
|
||||
"signmessagewithprivkey"
|
||||
"validateaddress"
|
||||
"verifymessage"
|
||||
# Zmq
|
||||
"getzmqnotifications"
|
||||
]
|
||||
@@ -1,507 +0,0 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options = {
|
||||
services.bitcoind = {
|
||||
enable = mkEnableOption "Bitcoin daemon";
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for peer connections.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = if !cfg.regtest then 8333 else 18444;
|
||||
defaultText = "if !cfg.regtest then 8333 else 18444";
|
||||
description = "Port to listen for peer connections.";
|
||||
};
|
||||
onionPort = mkOption {
|
||||
type = types.nullOr types.port;
|
||||
# When the bitcoind onion service is enabled, add an onion-tagged socket
|
||||
# to distinguish local connections from Tor connections
|
||||
default = if (config.nix-bitcoin.onionServices.bitcoind.enable or false) then 8334 else null;
|
||||
description = ''
|
||||
Port to listen for Tor peer connections.
|
||||
If set, inbound connections to this port are tagged as onion peers.
|
||||
'';
|
||||
};
|
||||
listen = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Listen for peer connections at `address:port`
|
||||
and `address:onionPort` (if {option}`onionPort` is set).
|
||||
'';
|
||||
};
|
||||
listenWhitelisted = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Listen for peer connections at `address:whitelistedPort`.
|
||||
Peers connected through this socket are automatically whitelisted.
|
||||
'';
|
||||
};
|
||||
whitelistedPort = mkOption {
|
||||
type = types.port;
|
||||
default = 8335;
|
||||
description = "See `listenWhitelisted`.";
|
||||
};
|
||||
getPublicAddressCmd = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = ''
|
||||
Bash expression which outputs the public service address to announce to peers.
|
||||
If left empty, no address is announced.
|
||||
'';
|
||||
};
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.bitcoind;
|
||||
defaultText = "pkgs.bitcoind";
|
||||
description = ''
|
||||
The package providing bitcoind binaries.
|
||||
|
||||
'';
|
||||
};
|
||||
extraConfig = mkOption {
|
||||
type = types.lines;
|
||||
default = "";
|
||||
example = ''
|
||||
par=16
|
||||
logips=1
|
||||
'';
|
||||
description = "Extra lines appended to {file}`bitcoin.conf`.";
|
||||
};
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/bitcoind";
|
||||
description = "The data directory for bitcoind.";
|
||||
};
|
||||
rpc = {
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = ''
|
||||
Address to listen for JSON-RPC connections.
|
||||
'';
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = if !cfg.regtest then 8332 else 18443;
|
||||
defaultText = "if !cfg.regtest then 8332 else 18443";
|
||||
description = "Port to listen for JSON-RPC connections.";
|
||||
};
|
||||
threads = mkOption {
|
||||
type = types.nullOr types.ints.u16;
|
||||
default = null;
|
||||
description = "The number of threads to service RPC calls.";
|
||||
};
|
||||
allowip = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ "127.0.0.1" ];
|
||||
description = ''
|
||||
Allow JSON-RPC connections from specified sources.
|
||||
'';
|
||||
};
|
||||
users = mkOption {
|
||||
default = {};
|
||||
description = ''
|
||||
Allowed users for JSON-RPC connections.
|
||||
'';
|
||||
example = {
|
||||
alice = {
|
||||
passwordHMAC = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
|
||||
rpcwhitelist = [ "sendtoaddress" "getnewaddress" ];
|
||||
};
|
||||
};
|
||||
type = with types; attrsOf (submodule ({ name, ... }: {
|
||||
options = {
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
default = name;
|
||||
example = "alice";
|
||||
description = ''
|
||||
Username for JSON-RPC connections.
|
||||
'';
|
||||
};
|
||||
passwordHMAC = mkOption {
|
||||
type = types.str;
|
||||
example = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
|
||||
description = ''
|
||||
Password HMAC-SHA-256 for JSON-RPC connections. Must be a string of the
|
||||
format `<SALT-HEX>$<HMAC-HEX>`.
|
||||
'';
|
||||
};
|
||||
passwordHMACFromFile = mkOption {
|
||||
type = lib.types.bool;
|
||||
internal = true;
|
||||
default = false;
|
||||
};
|
||||
rpcwhitelist = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [];
|
||||
description = ''
|
||||
List of allowed rpc calls for each user.
|
||||
If empty list, rpcwhitelist is disabled for that user.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}));
|
||||
};
|
||||
};
|
||||
regtest = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable regtest mode.";
|
||||
};
|
||||
network = mkOption {
|
||||
readOnly = true;
|
||||
default = if cfg.regtest then "regtest" else "mainnet";
|
||||
};
|
||||
makeNetworkName = mkOption {
|
||||
readOnly = true;
|
||||
default = mainnet: regtest: if cfg.regtest then regtest else mainnet;
|
||||
};
|
||||
proxy = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
|
||||
description = "Connect through SOCKS5 proxy";
|
||||
};
|
||||
i2p = mkOption {
|
||||
type = types.enum [ false true "only-outgoing" ];
|
||||
default = false;
|
||||
description = ''
|
||||
Enable peer connections via i2p.
|
||||
With `only-outgoing`, incoming i2p connections are disabled.
|
||||
'';
|
||||
};
|
||||
dataDirReadableByGroup = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
If enabled, data dir content is readable by the bitcoind service group.
|
||||
Warning: This disables bitcoind's wallet support.
|
||||
'';
|
||||
};
|
||||
sysperms = mkOption {
|
||||
type = types.nullOr types.bool;
|
||||
default = null;
|
||||
description = ''
|
||||
Create new files with system default permissions, instead of umask 077
|
||||
(only effective with disabled wallet functionality)
|
||||
'';
|
||||
};
|
||||
disablewallet = mkOption {
|
||||
type = types.nullOr types.bool;
|
||||
default = null;
|
||||
description = ''
|
||||
Do not load the wallet and disable wallet RPC calls
|
||||
'';
|
||||
};
|
||||
dbCache = mkOption {
|
||||
type = types.nullOr (intAtLeast 4);
|
||||
default = null;
|
||||
example = 4000;
|
||||
description = "Override the default database cache size in MiB.";
|
||||
};
|
||||
prune = mkOption {
|
||||
type = types.ints.unsigned;
|
||||
default = 0;
|
||||
example = 10000;
|
||||
description = ''
|
||||
Automatically prune block files to stay under the specified target size in MiB.
|
||||
Value 0 disables pruning.
|
||||
'';
|
||||
};
|
||||
txindex = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable the transaction index.";
|
||||
};
|
||||
zmqpubrawblock = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "tcp://127.0.0.1:28332";
|
||||
description = "ZMQ address for zmqpubrawblock notifications";
|
||||
};
|
||||
zmqpubrawtx = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "tcp://127.0.0.1:28333";
|
||||
description = "ZMQ address for zmqpubrawtx notifications";
|
||||
};
|
||||
assumevalid = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "00000000000000000000e5abc3a74fe27dc0ead9c70ea1deb456f11c15fd7bc6";
|
||||
description = ''
|
||||
If this block is in the chain assume that it and its ancestors are
|
||||
valid and potentially skip their script verification.
|
||||
'';
|
||||
};
|
||||
addnodes = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [];
|
||||
example = [ "ecoc5q34tmbq54wl.onion" ];
|
||||
description = "Add nodes to connect to and attempt to keep the connections open";
|
||||
};
|
||||
discover = mkOption {
|
||||
type = types.nullOr types.bool;
|
||||
default = null;
|
||||
description = "Discover own IP addresses";
|
||||
};
|
||||
addresstype = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "bech32";
|
||||
description = "The type of addresses to use";
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "bitcoin";
|
||||
description = "The user as which to run bitcoind.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.user;
|
||||
description = "The group as which to run bitcoind.";
|
||||
};
|
||||
cli = mkOption {
|
||||
readOnly = true;
|
||||
type = types.package;
|
||||
default = pkgs.writers.writeBashBin "bitcoin-cli" ''
|
||||
exec ${cfg.package}/bin/bitcoin-cli -datadir='${cfg.dataDir}' "$@"
|
||||
'';
|
||||
defaultText = "(See source)";
|
||||
description = "Binary to connect with the bitcoind instance.";
|
||||
};
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.services.bitcoind;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
|
||||
# nixpkgs 26.11 moved i2pd's protocol configuration from
|
||||
# `services.i2pd.proto` to the RFC42-style `services.i2pd.settings`.
|
||||
i2pSAM = config.services.i2pd.settings.sam;
|
||||
|
||||
configFile = builtins.toFile "bitcoin.conf" ''
|
||||
# We're already logging via journald
|
||||
nodebuglogfile=1
|
||||
logtimestamps=0
|
||||
|
||||
startupnotify=/run/current-system/systemd/bin/systemd-notify --ready
|
||||
|
||||
${optionalString cfg.regtest ''
|
||||
regtest=1
|
||||
[regtest]
|
||||
''}
|
||||
${optionalString (cfg.dbCache != null) "dbcache=${toString cfg.dbCache}"}
|
||||
prune=${toString cfg.prune}
|
||||
${optionalString cfg.txindex "txindex=1"}
|
||||
${optionalString (cfg.sysperms != null) "sysperms=${if cfg.sysperms then "1" else "0"}"}
|
||||
${optionalString (cfg.disablewallet != null) "disablewallet=${if cfg.disablewallet then "1" else "0"}"}
|
||||
${optionalString (cfg.assumevalid != null) "assumevalid=${cfg.assumevalid}"}
|
||||
|
||||
# Connection options
|
||||
listen=${if (cfg.listen || cfg.listenWhitelisted) then "1" else "0"}
|
||||
${optionalString cfg.listen
|
||||
"bind=${cfg.address}:${toString cfg.port}"}
|
||||
${optionalString (cfg.listen && cfg.onionPort != null)
|
||||
"bind=${cfg.address}:${toString cfg.onionPort}=onion"}
|
||||
${optionalString cfg.listenWhitelisted
|
||||
"whitebind=${cfg.address}:${toString cfg.whitelistedPort}"}
|
||||
${optionalString (cfg.proxy != null) "proxy=${cfg.proxy}"}
|
||||
${optionalString (cfg.i2p != false) "i2psam=${nbLib.addressWithPort i2pSAM.address i2pSAM.port}"}
|
||||
${optionalString (cfg.i2p == "only-outgoing") "i2pacceptincoming=0"}
|
||||
|
||||
${optionalString (cfg.discover != null) "discover=${if cfg.discover then "1" else "0"}"}
|
||||
${lib.concatMapStrings (node: "addnode=${node}\n") cfg.addnodes}
|
||||
|
||||
# RPC server options
|
||||
rpcbind=${cfg.rpc.address}
|
||||
rpcport=${toString cfg.rpc.port}
|
||||
rpcconnect=${cfg.rpc.address}
|
||||
${optionalString (cfg.rpc.threads != null) "rpcthreads=${toString cfg.rpc.threads}"}
|
||||
rpcwhitelistdefault=0
|
||||
${concatMapStrings (user: ''
|
||||
${optionalString (!user.passwordHMACFromFile) "rpcauth=${user.name}:${user.passwordHMAC}"}
|
||||
${optionalString (user.rpcwhitelist != [])
|
||||
"rpcwhitelist=${user.name}:${lib.strings.concatStringsSep "," user.rpcwhitelist}"}
|
||||
'') (builtins.attrValues cfg.rpc.users)
|
||||
}
|
||||
${lib.concatMapStrings (rpcallowip: "rpcallowip=${rpcallowip}\n") cfg.rpc.allowip}
|
||||
|
||||
# Wallet options
|
||||
${optionalString (cfg.addresstype != null) "addresstype=${cfg.addresstype}"}
|
||||
|
||||
# ZMQ options
|
||||
${optionalString (cfg.zmqpubrawblock != null) "zmqpubrawblock=${cfg.zmqpubrawblock}"}
|
||||
${optionalString (cfg.zmqpubrawtx != null) "zmqpubrawtx=${cfg.zmqpubrawtx}"}
|
||||
|
||||
# Extra options
|
||||
${cfg.extraConfig}
|
||||
'';
|
||||
|
||||
zmqServerEnabled = (cfg.zmqpubrawblock != null) || (cfg.zmqpubrawtx != null);
|
||||
|
||||
intAtLeast = n: types.addCheck types.int (x: x >= n) // {
|
||||
name = "intAtLeast";
|
||||
description = "integer >= ${toString n}";
|
||||
};
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
||||
|
||||
services.bitcoind = mkMerge [
|
||||
(mkIf cfg.dataDirReadableByGroup {
|
||||
disablewallet = true;
|
||||
sysperms = true;
|
||||
})
|
||||
{
|
||||
rpc.users.privileged = {
|
||||
passwordHMACFromFile = true;
|
||||
};
|
||||
rpc.users.public = {
|
||||
passwordHMACFromFile = true;
|
||||
rpcwhitelist = import ./bitcoind-rpc-public-whitelist.nix;
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
services.i2pd = mkIf (cfg.i2p != false) {
|
||||
enable = true;
|
||||
settings.sam = {
|
||||
enabled = true;
|
||||
address = "127.0.0.1";
|
||||
port = 7656;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.bitcoind = rec {
|
||||
wants = [
|
||||
"network-online.target"
|
||||
# Use `wants` instead of `requires` for `nix-bitcoin-secrets.target`
|
||||
# so that bitcoind and all dependent services are not restarted when
|
||||
# the secrets target restarts.
|
||||
# The secrets target always restarts when deploying with one of the methods
|
||||
# in ./deployment.
|
||||
#
|
||||
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
||||
# that propagates initial start failures but no restarts
|
||||
"nix-bitcoin-secrets.target"
|
||||
];
|
||||
after = wants;
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
preStart = let
|
||||
extraRpcauth = concatMapStrings (name: let
|
||||
user = cfg.rpc.users.${name};
|
||||
in optionalString user.passwordHMACFromFile ''
|
||||
hmacPayload="$(readValidatedRpcHmac '${secretsDir}/bitcoin-HMAC-${name}')" || exit 1
|
||||
printf '%s\n' "rpcauth=${user.name}:$hmacPayload"
|
||||
''
|
||||
) (builtins.attrNames cfg.rpc.users);
|
||||
in ''
|
||||
${optionalString cfg.dataDirReadableByGroup ''
|
||||
if [[ -e '${cfg.dataDir}/blocks' ]]; then
|
||||
chmod -R g+rX '${cfg.dataDir}/blocks'
|
||||
fi
|
||||
''}
|
||||
|
||||
readValidatedRpcHmac() {
|
||||
local hmacFile="$1"
|
||||
local hmacPayload
|
||||
|
||||
if [[ ! -e "$hmacFile" ]]; then
|
||||
echo "Error: Bitcoin RPC HMAC file is missing: $hmacFile" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -r "$hmacFile" ]]; then
|
||||
echo "Error: Bitcoin RPC HMAC file is unreadable: $hmacFile" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
hmacPayload="$(<"$hmacFile")"
|
||||
if [[ -z "$hmacPayload" ]]; then
|
||||
echo "Error: Bitcoin RPC HMAC file is empty: $hmacFile" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$hmacPayload" =~ ^[[:xdigit:]]+\$[[:xdigit:]]+$ ]]; then
|
||||
echo "Error: Bitcoin RPC HMAC file has invalid format: $hmacFile" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$hmacPayload"
|
||||
}
|
||||
|
||||
cfg=$(
|
||||
cat ${configFile}
|
||||
${extraRpcauth}
|
||||
echo
|
||||
${optionalString (cfg.getPublicAddressCmd != "") ''
|
||||
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
||||
''}
|
||||
)
|
||||
confFile='${cfg.dataDir}/bitcoin.conf'
|
||||
if [[ ! -e $confFile || $cfg != $(cat $confFile) ]]; then
|
||||
install -o '${cfg.user}' -g '${cfg.group}' -m 640 <(echo "$cfg") $confFile
|
||||
fi
|
||||
'';
|
||||
|
||||
# Enable RPC access for group
|
||||
postStart = ''
|
||||
chmod g=r '${cfg.dataDir}/${optionalString cfg.regtest "regtest/"}.cookie'
|
||||
'' + (optionalString cfg.regtest) ''
|
||||
chmod g=x '${cfg.dataDir}/regtest'
|
||||
'';
|
||||
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
Type = "notify";
|
||||
NotifyAccess = "all";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
TimeoutStartSec = "30min";
|
||||
TimeoutStopSec = "30min";
|
||||
ExecStart = "${cfg.package}/bin/bitcoind -datadir='${cfg.dataDir}'";
|
||||
Restart = "on-failure";
|
||||
UMask = mkIf cfg.dataDirReadableByGroup "0027";
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
||||
// optionalAttrs zmqServerEnabled nbLib.allowNetlink;
|
||||
};
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
};
|
||||
users.groups.${cfg.group} = {};
|
||||
users.groups.bitcoinrpc-public = {};
|
||||
|
||||
nix-bitcoin.operator.groups = [ cfg.group ];
|
||||
|
||||
nix-bitcoin.secrets = {
|
||||
bitcoin-rpcpassword-privileged.user = cfg.user;
|
||||
bitcoin-rpcpassword-public = {
|
||||
user = cfg.user;
|
||||
group = "bitcoinrpc-public";
|
||||
};
|
||||
|
||||
bitcoin-HMAC-privileged.user = cfg.user;
|
||||
bitcoin-HMAC-public.user = cfg.user;
|
||||
};
|
||||
nix-bitcoin.generateSecretsCmds.bitcoind = ''
|
||||
makeBitcoinRPCPassword privileged
|
||||
makeBitcoinRPCPassword public
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.services = {
|
||||
nbxplorer = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Enable nbxplorer, a lightweight API for Bitcoin HD wallets.
|
||||
|
||||
Access API documentation here:
|
||||
{option}`services.nbxplorer.address`:{option}`services.nbxplorer.port`
|
||||
'';
|
||||
};
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen on.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 24444;
|
||||
description = "Port to listen on.";
|
||||
};
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.stable.nbxplorer;
|
||||
defaultText = "pkgs.stable.nbxplorer";
|
||||
description = "The package providing nbxplorer binaries.";
|
||||
};
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/nbxplorer";
|
||||
description = "The data directory for nbxplorer.";
|
||||
};
|
||||
# TODO-EXTERNAL:
|
||||
# The shortcut link `main` in the datadir has changed to a directory
|
||||
# in version 2.3.3.
|
||||
# Add a dummy symlink, if it does not already exist, to be compatible with older modules.
|
||||
# When the old system uses a link and the new system a directory, switching fails with:
|
||||
# mv: cannot move '/var/lib/nbxplorer/Main' to '/var/lib/nbxplorer/.Main.tmp':
|
||||
# No such file or directory
|
||||
#
|
||||
# Remove this option when it is irrelevant (i.e. when the old system will never
|
||||
# be nix-bitcoin <=0.0.91)
|
||||
addNetworkSymlink = mkOption {
|
||||
readOnly = true;
|
||||
default = pkgs.stable.nbxplorer != cfg.nbxplorer.package;
|
||||
description = ''
|
||||
Whether to add a compatibility symlink (like `${cfg.nbxplorer.dataDir}/Main`)
|
||||
to the dataDir.
|
||||
This is enabled by default if the nbxplorer package is set to the version-locked package.
|
||||
'';
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "nbxplorer";
|
||||
description = "The user as which to run NBXplorer.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.nbxplorer.user;
|
||||
description = "The group as which to run NBXplorer.";
|
||||
};
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
|
||||
btcpayserver = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Enable BTCPay Server, a self-hosted, open-source payment processor.
|
||||
|
||||
Extra recommendations:
|
||||
- Enable `services.btcpayserver.lightningBackend` to provide Lightning payment support.
|
||||
- Secure this service if the instance is publically accessible. For example, set
|
||||
{option}`services.btcpayserver.address` to `127.0.0.1` and use a reverse proxy
|
||||
that enforces TLS (Transport Layer Security).
|
||||
'';
|
||||
};
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.stable.btcpayserver;
|
||||
defaultText = "pkgs.stable.btcpayserver";
|
||||
description = "The package providing BTCPay Server binaries.";
|
||||
};
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen on.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 23000;
|
||||
description = "Port to listen on.";
|
||||
};
|
||||
lightningBackend = mkOption {
|
||||
type = types.nullOr (types.enum [ "lnd" ]);
|
||||
default = null;
|
||||
description = ''
|
||||
The lightning node to use as a backend.
|
||||
Enables the node service if not already enabled.
|
||||
'';
|
||||
};
|
||||
lbtc = mkOption {
|
||||
readOnly = true;
|
||||
default = false;
|
||||
description = ''
|
||||
Enable Liquid support.
|
||||
'';
|
||||
};
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/btcpayserver";
|
||||
description = "The data directory for BTCPay Server.";
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "btcpayserver";
|
||||
description = "The user as which to run BTCPay Server.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.btcpayserver.user;
|
||||
description = "The group as which to run BTCPay Server.";
|
||||
};
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
};
|
||||
|
||||
cfg = {
|
||||
inherit (config.services)
|
||||
nbxplorer
|
||||
btcpayserver
|
||||
bitcoind;
|
||||
};
|
||||
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkMerge [
|
||||
(mkIf cfg.nbxplorer.enable {
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.nbxplorer.dataDir}' 0770 ${cfg.nbxplorer.user} ${cfg.nbxplorer.group} - -"
|
||||
] ++ optional cfg.nbxplorer.addNetworkSymlink
|
||||
"L+ '${cfg.nbxplorer.dataDir}/Main' - - - - '${cfg.nbxplorer.dataDir}/main'";
|
||||
|
||||
systemd.services.nbxplorer = let
|
||||
configFile = builtins.toFile "nbxplorer-config" ''
|
||||
network=${cfg.bitcoind.network}
|
||||
btcrpcuser=${cfg.bitcoind.rpc.users.btcpayserver.name}
|
||||
btcrpcurl=http://${nbLib.addressWithPort cfg.bitcoind.rpc.address cfg.bitcoind.rpc.port}
|
||||
btcnodeendpoint=${nbLib.addressWithPort cfg.bitcoind.address cfg.bitcoind.whitelistedPort}
|
||||
bind=${cfg.nbxplorer.address}
|
||||
port=${toString cfg.nbxplorer.port}
|
||||
postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
|
||||
'';
|
||||
in rec {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
requires = [ "postgresql.target" ];
|
||||
wants = [ "bitcoind.service" ];
|
||||
after = requires ++ wants ++ [ "nix-bitcoin-secrets.target" ];
|
||||
preStart = ''
|
||||
install -m 600 ${configFile} '${cfg.nbxplorer.dataDir}/settings.config'
|
||||
printf '%s\n' "btcrpcpassword=$(<${secretsDir}/bitcoin-rpcpassword-btcpayserver)" \
|
||||
>> '${cfg.nbxplorer.dataDir}/settings.config'
|
||||
'';
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
ExecStart = ''
|
||||
${cfg.nbxplorer.package}/bin/nbxplorer --conf=${cfg.nbxplorer.dataDir}/settings.config \
|
||||
--datadir='${cfg.nbxplorer.dataDir}'
|
||||
'';
|
||||
RuntimeDirectory = "nbxplorer";
|
||||
StateDirectory = "nbxplorer";
|
||||
User = cfg.nbxplorer.user;
|
||||
Group = cfg.nbxplorer.group;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
ReadWritePaths = [ cfg.nbxplorer.dataDir ];
|
||||
MemoryDenyWriteExecute = false;
|
||||
} // nbLib.allowedIPAddresses cfg.nbxplorer.tor.enforce;
|
||||
};
|
||||
|
||||
services.bitcoind = {
|
||||
enable = true;
|
||||
listenWhitelisted = true;
|
||||
txindex = true;
|
||||
};
|
||||
|
||||
users.users.${cfg.nbxplorer.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.nbxplorer.group;
|
||||
home = cfg.nbxplorer.dataDir;
|
||||
};
|
||||
users.groups.${cfg.nbxplorer.group} = {};
|
||||
})
|
||||
|
||||
(mkIf cfg.btcpayserver.enable {
|
||||
services.nbxplorer.enable = true;
|
||||
|
||||
services.bitcoind = {
|
||||
listenWhitelisted = true;
|
||||
rpc.users.btcpayserver = {
|
||||
name = "btcpayserver";
|
||||
passwordHMACFromFile = true;
|
||||
rpcwhitelist = [
|
||||
"getblockchaininfo"
|
||||
"getblock"
|
||||
"getblockhash"
|
||||
"getblockheader"
|
||||
"getblockstats"
|
||||
"gettransaction"
|
||||
"getrawtransaction"
|
||||
"sendrawtransaction"
|
||||
"getblockcount"
|
||||
"getbestblockhash"
|
||||
"getnetworkinfo"
|
||||
"getpeerinfo"
|
||||
"estimatesmartfee"
|
||||
"getmempoolinfo"
|
||||
"getmempoolentry"
|
||||
"getrawmempool"
|
||||
"gettxout"
|
||||
"scantxoutset"
|
||||
"importmulti"
|
||||
"listunspent"
|
||||
"getwalletinfo"
|
||||
"listtransactions"
|
||||
"listreceivedbyaddress"
|
||||
"getnewaddress"
|
||||
"uptime"
|
||||
"getrpcinfo"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.btcpayserver.dataDir}' 0770 ${cfg.btcpayserver.user} ${cfg.btcpayserver.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.btcpayserver = let
|
||||
nbExplorerUrl = "http://${nbLib.addressWithPort cfg.nbxplorer.address cfg.nbxplorer.port}/";
|
||||
nbExplorerCookie =
|
||||
"${cfg.nbxplorer.dataDir}/${cfg.bitcoind.makeNetworkName "Main" "RegTest"}/.cookie";
|
||||
configFile = builtins.toFile "btcpayserver-config" (
|
||||
''
|
||||
network=${cfg.bitcoind.network}
|
||||
bind=${cfg.btcpayserver.address}
|
||||
port=${toString cfg.btcpayserver.port}
|
||||
socksendpoint=${config.nix-bitcoin.torClientAddressWithPort}
|
||||
btcexplorerurl=${nbExplorerUrl}
|
||||
btcexplorercookiefile=${nbExplorerCookie}
|
||||
explorer.postgres=User ID=${cfg.nbxplorer.user};Host=/run/postgresql;Database=nbxplorer
|
||||
postgres=User ID=${cfg.btcpayserver.user};Host=/run/postgresql;Database=btcpayserver
|
||||
'' + optionalString (cfg.btcpayserver.lightningBackend == "lnd")
|
||||
(
|
||||
"btclightning=type=lnd-rest;"
|
||||
+ "server=https://${nbLib.address config.services.lnd.restAddress}:${toString config.services.lnd.restPort}/;"
|
||||
+ "macaroonfilepath=/run/lnd/btcpayserver.macaroon;"
|
||||
+ "certfilepath=${config.services.lnd.certPath}\n"
|
||||
)
|
||||
);
|
||||
in rec {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
requires = [ "postgresql.target" "nbxplorer.service" ];
|
||||
wants = optional (cfg.btcpayserver.lightningBackend == "lnd") "lnd.service";
|
||||
after = requires ++ wants;
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
ExecStart = ''
|
||||
${cfg.btcpayserver.package}/bin/btcpayserver --conf=${configFile} \
|
||||
--datadir='${cfg.btcpayserver.dataDir}'
|
||||
'';
|
||||
WorkingDirectory = "${cfg.btcpayserver.package}/lib/btcpayserver";
|
||||
RuntimeDirectory = "btcpayserver";
|
||||
StateDirectory = "btcpayserver";
|
||||
User = cfg.btcpayserver.user;
|
||||
Group = cfg.btcpayserver.group;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
ReadWritePaths = [ cfg.btcpayserver.dataDir ];
|
||||
MemoryDenyWriteExecute = false;
|
||||
} // nbLib.allowedIPAddresses cfg.btcpayserver.tor.enforce;
|
||||
};
|
||||
|
||||
services.postgresql = {
|
||||
enable = true;
|
||||
ensureDatabases = [ "btcpayserver" "nbxplorer" ];
|
||||
ensureUsers = [
|
||||
{ name = cfg.btcpayserver.user; ensureDBOwnership = true; }
|
||||
{ name = cfg.nbxplorer.user; ensureDBOwnership = true; }
|
||||
];
|
||||
};
|
||||
|
||||
users.users.${cfg.btcpayserver.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.btcpayserver.group;
|
||||
home = cfg.btcpayserver.dataDir;
|
||||
extraGroups = optional (cfg.btcpayserver.lightningBackend == "lnd") config.services.lnd.group;
|
||||
};
|
||||
users.groups.${cfg.btcpayserver.group} = {};
|
||||
|
||||
nix-bitcoin.secrets = {
|
||||
bitcoin-rpcpassword-btcpayserver = {
|
||||
user = cfg.bitcoind.user;
|
||||
group = cfg.nbxplorer.group;
|
||||
};
|
||||
bitcoin-HMAC-btcpayserver.user = cfg.bitcoind.user;
|
||||
};
|
||||
nix-bitcoin.generateSecretsCmds.btcpayserver = ''
|
||||
makeBitcoinRPCPassword btcpayserver
|
||||
'';
|
||||
})
|
||||
|
||||
(mkIf (cfg.btcpayserver.enable && cfg.btcpayserver.lightningBackend == "lnd") {
|
||||
services.lnd = {
|
||||
enable = true;
|
||||
macaroons.btcpayserver = {
|
||||
user = cfg.btcpayserver.user;
|
||||
permissions = ''
|
||||
{"entity":"address","action":"write"},{"entity":"info","action":"read"},{"entity":"invoices","action":"read"},{"entity":"invoices","action":"write"},{"entity":"offchain","action":"read"},{"entity":"offchain","action":"write"},{"entity":"onchain","action":"read"},{"entity":"onchain","action":"write"},{"entity":"peers","action":"read"},{"entity":"peers","action":"write"}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
users.users.${config.services.lnd.user}.extraGroups = [ cfg.btcpayserver.group ];
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
# Common Bitcoin infrastructure: secrets, onion services, nodeinfo, security
|
||||
# Extracted from nix-bitcoin, tailored for Sovran (lnd-only)
|
||||
{ config, lib, pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
./nix-bitcoin.nix
|
||||
./secrets/secrets.nix
|
||||
./operator.nix
|
||||
./security.nix
|
||||
./onion-addresses.nix
|
||||
./onion-services.nix
|
||||
./nodeinfo.nix
|
||||
./versioning.nix
|
||||
];
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
# Sovran Bitcoin stack - tailored from nix-bitcoin, lnd-only, nixpkgs packages
|
||||
# Original: https://github.com/fort-nix/nix-bitcoin
|
||||
{
|
||||
imports = [
|
||||
./common.nix
|
||||
./bitcoind.nix
|
||||
./electrs.nix
|
||||
./lnd.nix
|
||||
./lndconnect.nix
|
||||
./rtl.nix
|
||||
./btcpayserver.nix
|
||||
./mempool.nix
|
||||
];
|
||||
|
||||
disabledModules = [ "services/networking/bitcoind.nix" ];
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.services.electrs = {
|
||||
enable = mkEnableOption "electrs, an Electrum server implemented in Rust";
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for RPC connections.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 50001;
|
||||
description = "Port to listen for RPC connections.";
|
||||
};
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/electrs";
|
||||
description = "The data directory for electrs.";
|
||||
};
|
||||
monitoringPort = mkOption {
|
||||
type = types.port;
|
||||
default = 4224;
|
||||
description = "Prometheus monitoring port.";
|
||||
};
|
||||
extraArgs = mkOption {
|
||||
type = types.separatedString " ";
|
||||
default = "";
|
||||
description = "Extra command line arguments passed to electrs.";
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "electrs";
|
||||
description = "The user as which to run electrs.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.user;
|
||||
description = "The group as which to run electrs.";
|
||||
};
|
||||
tor.enforce = nbLib.tor.enforce;
|
||||
};
|
||||
|
||||
cfg = config.services.electrs;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
bitcoind = config.services.bitcoind;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
assertions = [
|
||||
{ assertion = bitcoind.prune == 0;
|
||||
message = "electrs does not support bitcoind pruning.";
|
||||
}
|
||||
];
|
||||
|
||||
services.bitcoind = {
|
||||
enable = true;
|
||||
listenWhitelisted = true;
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.electrs = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
requires = [ "bitcoind.service" ];
|
||||
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
||||
preStart = ''
|
||||
echo "auth = \"${bitcoind.rpc.users.public.name}:$(cat ${secretsDir}/bitcoin-rpcpassword-public)\"" \
|
||||
> electrs.toml
|
||||
'';
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
# electrs only uses the working directory for reading electrs.toml
|
||||
WorkingDirectory = cfg.dataDir;
|
||||
ExecStart = ''
|
||||
${pkgs.electrs}/bin/electrs \
|
||||
--log-filters=INFO \
|
||||
--network=${bitcoind.makeNetworkName "bitcoin" "regtest"} \
|
||||
--db-dir='${cfg.dataDir}' \
|
||||
--daemon-dir='${bitcoind.dataDir}' \
|
||||
--electrum-rpc-addr=${cfg.address}:${toString cfg.port} \
|
||||
--monitoring-addr=${cfg.address}:${toString cfg.monitoringPort} \
|
||||
--daemon-rpc-addr=${nbLib.addressWithPort bitcoind.rpc.address bitcoind.rpc.port} \
|
||||
--daemon-p2p-addr=${nbLib.addressWithPort bitcoind.address bitcoind.whitelistedPort} \
|
||||
${cfg.extraArgs}
|
||||
'';
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
||||
};
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
extraGroups = [ "bitcoinrpc-public" ];
|
||||
};
|
||||
users.groups.${cfg.group} = {};
|
||||
};
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
lib: pkgs: config:
|
||||
|
||||
with lib;
|
||||
|
||||
# See `man systemd.exec` and `man systemd.resource-control` for an explanation
|
||||
# of the systemd-related options available through this file.
|
||||
let self = {
|
||||
# These settings roughly follow systemd's "strict" security profile
|
||||
defaultHardening = {
|
||||
PrivateTmp = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
NoNewPrivileges = true;
|
||||
PrivateDevices = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectKernelLogs = true;
|
||||
ProtectClock = true;
|
||||
ProtectProc = "invisible";
|
||||
ProcSubset = "pid";
|
||||
ProtectControlGroups = true;
|
||||
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
|
||||
RestrictNamespaces = true;
|
||||
LockPersonality = true;
|
||||
IPAddressDeny = "any";
|
||||
PrivateUsers = true;
|
||||
RestrictSUIDSGID = true;
|
||||
RemoveIPC = true;
|
||||
RestrictRealtime = true;
|
||||
ProtectHostname = true;
|
||||
CapabilityBoundingSet = "";
|
||||
# @system-service whitelist and docker seccomp blacklist (except for "clone"
|
||||
# which is a core requirement for systemd services)
|
||||
# @system-service is defined in src/shared/seccomp-util.c (systemd source)
|
||||
SystemCallFilter = [ "@system-service" "~add_key kcmp keyctl mbind move_pages name_to_handle_at personality process_vm_readv process_vm_writev request_key setns unshare userfaultfd" ];
|
||||
SystemCallArchitectures = "native";
|
||||
};
|
||||
|
||||
allowNetlink = {
|
||||
RestrictAddressFamilies = self.defaultHardening.RestrictAddressFamilies + " AF_NETLINK";
|
||||
};
|
||||
|
||||
nodejs = {
|
||||
# Required for JIT compilation
|
||||
MemoryDenyWriteExecute = false;
|
||||
# Required by nodejs >= 18
|
||||
SystemCallFilter = self.defaultHardening.SystemCallFilter ++ [ "@pkey" ];
|
||||
};
|
||||
|
||||
# Allow takes precedence over Deny.
|
||||
allowLocalIPAddresses = {
|
||||
IPAddressAllow = [
|
||||
"127.0.0.1/32"
|
||||
"::1/128"
|
||||
"169.254.0.0/16"
|
||||
];
|
||||
};
|
||||
allowAllIPAddresses = { IPAddressAllow = "any"; };
|
||||
allowTor = self.allowLocalIPAddresses;
|
||||
allowedIPAddresses = onlyLocal:
|
||||
if onlyLocal
|
||||
then self.allowLocalIPAddresses
|
||||
else self.allowAllIPAddresses;
|
||||
|
||||
tor = {
|
||||
proxy = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Whether to proxy outgoing connections with Tor.";
|
||||
};
|
||||
enforce = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Whether to enforce Tor on this service by only allowing connections
|
||||
from and to localhost and link-local addresses.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
script = name: src: pkgs.writers.writeBash name ''
|
||||
set -eo pipefail
|
||||
${src}
|
||||
'';
|
||||
|
||||
# Used for ExecStart*
|
||||
rootScript = name: src: "+${self.script name src}";
|
||||
|
||||
cliExec = mkOption {
|
||||
# Used by netns-isolation to execute the cli in the service's private netns
|
||||
internal = true;
|
||||
type = types.str;
|
||||
default = "exec";
|
||||
};
|
||||
|
||||
mkOnionService = map: {
|
||||
map = [ map ];
|
||||
version = 3;
|
||||
};
|
||||
|
||||
# Convert a bind address, which may be a special INADDR_ANY address,
|
||||
# to an actual IP address
|
||||
address = addr:
|
||||
if addr == "0.0.0.0" then
|
||||
"127.0.0.1"
|
||||
else if addr == "::" then
|
||||
"::1"
|
||||
else
|
||||
addr;
|
||||
|
||||
addressWithPort = addr: port: "${self.address addr}:${toString port}";
|
||||
|
||||
optionalAttr = cond: name: if cond then name else null;
|
||||
|
||||
mkCertExtraAltNames = cert:
|
||||
builtins.concatStringsSep "," (
|
||||
(map (domain: "DNS:${domain}") cert.extraDomains) ++
|
||||
(map (ip: "IP:${ip}") cert.extraIPs)
|
||||
);
|
||||
|
||||
test = {
|
||||
mkIfTest = test: mkIf (config.tests.${test} or false);
|
||||
};
|
||||
|
||||
mkAlias = default: mkOption {
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
inherit default;
|
||||
};
|
||||
|
||||
}; in self
|
||||
@@ -1,316 +0,0 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.services.lnd = {
|
||||
enable = mkEnableOption "Lightning Network daemon, a Lightning Network implementation in Go";
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for peer connections";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 9735;
|
||||
description = "Port to listen for peer connections";
|
||||
};
|
||||
rpcAddress = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for RPC connections.";
|
||||
};
|
||||
rpcPort = mkOption {
|
||||
type = types.port;
|
||||
default = 10009;
|
||||
description = "Port to listen for gRPC connections.";
|
||||
};
|
||||
restAddress = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for REST connections.";
|
||||
};
|
||||
restPort = mkOption {
|
||||
type = types.port;
|
||||
default = 8080;
|
||||
description = "Port to listen for REST connections.";
|
||||
};
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/lnd";
|
||||
description = "The data directory for LND.";
|
||||
};
|
||||
networkDir = mkOption {
|
||||
readOnly = true;
|
||||
default = "${cfg.dataDir}/chain/bitcoin/${bitcoind.network}";
|
||||
description = "The network data directory.";
|
||||
};
|
||||
tor-socks = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
|
||||
description = "Socks proxy for connecting to Tor nodes";
|
||||
};
|
||||
macaroons = mkOption {
|
||||
default = {};
|
||||
type = with types; attrsOf (submodule {
|
||||
options = {
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
description = "User who owns the macaroon.";
|
||||
};
|
||||
permissions = mkOption {
|
||||
type = types.str;
|
||||
example = ''
|
||||
{"entity":"info","action":"read"},{"entity":"onchain","action":"read"}
|
||||
'';
|
||||
description = "List of granted macaroon permissions.";
|
||||
};
|
||||
};
|
||||
});
|
||||
description = ''
|
||||
Extra macaroon definitions.
|
||||
'';
|
||||
};
|
||||
certificate = {
|
||||
extraIPs = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
example = [ "60.100.0.1" ];
|
||||
description = ''
|
||||
Extra `subjectAltName` IPs added to the certificate.
|
||||
This works the same as lnd option {option}`tlsextraip`.
|
||||
'';
|
||||
};
|
||||
extraDomains = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
example = [ "example.com" ];
|
||||
description = ''
|
||||
Extra `subjectAltName` domain names added to the certificate.
|
||||
This works the same as lnd option {option}`tlsextradomain`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
extraConfig = mkOption {
|
||||
type = types.lines;
|
||||
default = "";
|
||||
example = ''
|
||||
autopilot.active=1
|
||||
'';
|
||||
description = ''
|
||||
Extra lines appended to {file}`lnd.conf`.
|
||||
See here for all available options:
|
||||
https://github.com/lightningnetwork/lnd/blob/master/sample-lnd.conf
|
||||
'';
|
||||
};
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.lnd;
|
||||
defaultText = "pkgs.lnd";
|
||||
description = "The package providing lnd binaries.";
|
||||
};
|
||||
cli = mkOption {
|
||||
default = pkgs.writers.writeBashBin "lncli"
|
||||
# Switch user because lnd makes datadir contents readable by user only
|
||||
''
|
||||
${runAsUser} ${cfg.user} ${cfg.package}/bin/lncli \
|
||||
--rpcserver ${cfg.rpcAddress}:${toString cfg.rpcPort} \
|
||||
--tlscertpath '${cfg.certPath}' \
|
||||
--macaroonpath '${networkDir}/admin.macaroon' "$@"
|
||||
'';
|
||||
defaultText = "(See source)";
|
||||
description = "Binary to connect with the lnd instance.";
|
||||
};
|
||||
getPublicAddressCmd = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = ''
|
||||
Bash expression which outputs the public service address to announce to peers.
|
||||
If left empty, no address is announced.
|
||||
'';
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "lnd";
|
||||
description = "The user as which to run LND.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.user;
|
||||
description = "The group as which to run LND.";
|
||||
};
|
||||
certPath = mkOption {
|
||||
readOnly = true;
|
||||
default = "${secretsDir}/lnd-cert";
|
||||
description = "LND TLS certificate path.";
|
||||
};
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
|
||||
cfg = config.services.lnd;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
runAsUser = config.nix-bitcoin.runAsUserCmd;
|
||||
lndinit = "${pkgs.lndinit}/bin/lndinit";
|
||||
|
||||
bitcoind = config.services.bitcoind;
|
||||
|
||||
bitcoindRpcAddress = nbLib.address bitcoind.rpc.address;
|
||||
networkDir = cfg.networkDir;
|
||||
configFile = pkgs.writeText "lnd.conf" ''
|
||||
datadir=${cfg.dataDir}
|
||||
tlscertpath=${cfg.certPath}
|
||||
tlskeypath=${secretsDir}/lnd-key
|
||||
|
||||
# We're logging via journald
|
||||
logging.file.disable=1
|
||||
logging.console.no-timestamps=1
|
||||
|
||||
listen=${toString cfg.address}:${toString cfg.port}
|
||||
rpclisten=${cfg.rpcAddress}:${toString cfg.rpcPort}
|
||||
restlisten=${cfg.restAddress}:${toString cfg.restPort}
|
||||
|
||||
bitcoin.${bitcoind.network}=1
|
||||
bitcoin.node=bitcoind
|
||||
|
||||
${optionalString (cfg.tor.proxy) "tor.active=true"}
|
||||
${optionalString (cfg.tor-socks != null) "tor.socks=${cfg.tor-socks}"}
|
||||
|
||||
bitcoind.rpchost=${bitcoindRpcAddress}:${toString bitcoind.rpc.port}
|
||||
bitcoind.rpcuser=${bitcoind.rpc.users.public.name}
|
||||
bitcoind.zmqpubrawblock=${zmqHandleSpecialAddress bitcoind.zmqpubrawblock}
|
||||
bitcoind.zmqpubrawtx=${zmqHandleSpecialAddress bitcoind.zmqpubrawtx}
|
||||
|
||||
wallet-unlock-password-file=${secretsDir}/lnd-wallet-password
|
||||
|
||||
${cfg.extraConfig}
|
||||
'';
|
||||
|
||||
zmqHandleSpecialAddress = builtins.replaceStrings [ "0.0.0.0" "[::]" ] [ "127.0.0.1" "[::1]" ];
|
||||
in {
|
||||
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
assertions = [
|
||||
{ assertion =
|
||||
!(config.services ? clightning)
|
||||
|| true; # clightning enable/port check disabled - option structure differs between nixpkgs versions (f13ff45 has plugins only, 8b8c811 removed). Sovran uses lnd only, so no conflict.
|
||||
message = ''
|
||||
LND and clightning can't both bind to lightning port 9735. Either
|
||||
disable LND/clightning or change services.clightning.port or
|
||||
services.lnd.port to a port other than 9735.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
services.bitcoind = {
|
||||
enable = true;
|
||||
|
||||
# Increase rpc thread count due to reports that lightning implementations fail
|
||||
# under high bitcoind rpc load
|
||||
rpc.threads = 16;
|
||||
|
||||
zmqpubrawblock = mkDefault "tcp://${bitcoindRpcAddress}:28332";
|
||||
zmqpubrawtx = mkDefault "tcp://${bitcoindRpcAddress}:28333";
|
||||
};
|
||||
|
||||
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
services.lnd.certificate.extraIPs = mkIf (cfg.rpcAddress != "127.0.0.1") [ "${cfg.rpcAddress}" ];
|
||||
|
||||
systemd.services.lnd = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
requires = [ "bitcoind.service" ];
|
||||
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
||||
preStart = ''
|
||||
install -m600 ${configFile} '${cfg.dataDir}/lnd.conf'
|
||||
{
|
||||
echo "bitcoind.rpcpass=$(cat ${secretsDir}/bitcoin-rpcpassword-public)"
|
||||
${optionalString (cfg.getPublicAddressCmd != "") ''
|
||||
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
||||
''}
|
||||
} >> '${cfg.dataDir}/lnd.conf'
|
||||
|
||||
if [[ ! -f ${networkDir}/wallet.db ]]; then
|
||||
seed='${cfg.dataDir}/lnd-seed-mnemonic'
|
||||
|
||||
if [[ ! -f "$seed" ]]; then
|
||||
echo "Create lnd seed"
|
||||
(umask u=r,go=; ${lndinit} gen-seed > "$seed")
|
||||
fi
|
||||
|
||||
echo "Create lnd wallet"
|
||||
${lndinit} -v init-wallet \
|
||||
--file.seed="$seed" \
|
||||
--file.wallet-password='${secretsDir}/lnd-wallet-password' \
|
||||
--init-file.output-wallet-dir='${cfg.networkDir}'
|
||||
fi
|
||||
'';
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
Type = "notify";
|
||||
RuntimeDirectory = "lnd"; # Only used to store custom macaroons
|
||||
RuntimeDirectoryMode = "711";
|
||||
ExecStart = "${cfg.package}/bin/lnd --configfile='${cfg.dataDir}/lnd.conf'";
|
||||
User = cfg.user;
|
||||
TimeoutSec = "15min";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
ExecStartPost = let
|
||||
curl = "${pkgs.curl}/bin/curl -fsS --cacert ${cfg.certPath}";
|
||||
restUrl = "https://${nbLib.addressWithPort cfg.restAddress cfg.restPort}/v1";
|
||||
# Setting macaroon permissions for other users needs root permissions
|
||||
# The admin macaroon is passed to curl via a fd because argv is
|
||||
# world-readable through /proc/<pid>/cmdline
|
||||
script = nbLib.rootScript "lnd-create-macaroons" ''
|
||||
umask ug=r,o=
|
||||
${lib.concatMapStrings (macaroon: ''
|
||||
echo "Create custom macaroon ${macaroon}"
|
||||
macaroonPath="$RUNTIME_DIRECTORY/${macaroon}.macaroon"
|
||||
adminMacaroonHex=$(${pkgs.xxd}/bin/xxd -ps -u -c 99999 '${networkDir}/admin.macaroon')
|
||||
${curl} \
|
||||
-H @<(printf 'Grpc-Metadata-macaroon: %s\n' "$adminMacaroonHex") \
|
||||
-X POST \
|
||||
-d '{"permissions":[${cfg.macaroons.${macaroon}.permissions}]}' \
|
||||
${restUrl}/macaroon |\
|
||||
${pkgs.jq}/bin/jq -c '.macaroon' | ${pkgs.xxd}/bin/xxd -p -r > "$macaroonPath"
|
||||
chown ${cfg.macaroons.${macaroon}.user}: "$macaroonPath"
|
||||
'') (attrNames cfg.macaroons)}
|
||||
'';
|
||||
in [
|
||||
script
|
||||
];
|
||||
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
||||
};
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
extraGroups = [ "bitcoinrpc-public" ];
|
||||
home = cfg.dataDir; # lnd creates .lnd dir in HOME
|
||||
};
|
||||
users.groups.${cfg.group} = {};
|
||||
nix-bitcoin.operator = {
|
||||
groups = [ cfg.group ];
|
||||
allowRunAsUsers = [ cfg.user ];
|
||||
};
|
||||
|
||||
nix-bitcoin.secrets = {
|
||||
lnd-wallet-password.user = cfg.user;
|
||||
lnd-key.user = cfg.user;
|
||||
lnd-cert.user = cfg.user;
|
||||
lnd-cert.permissions = "444"; # world readable
|
||||
};
|
||||
# Advantages of manually pre-generating certs:
|
||||
# - Reduces dynamic state
|
||||
# - Enables deployment of a mesh of server plus client nodes with predefined certs
|
||||
nix-bitcoin.generateSecretsCmds.lnd = ''
|
||||
makePasswordSecret lnd-wallet-password
|
||||
makeCert lnd '${nbLib.mkCertExtraAltNames cfg.certificate}'
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
# LND-only lndconnect wrapper. Restored to the fort-nix/nix-bitcoin contract
|
||||
# after the LND-only rewrite shipped a Zeus QR that Zeus cannot use:
|
||||
# - unknown flags (--cert / --macaroon instead of --tlscertpath / --adminmacaroonpath)
|
||||
# - onion hostname read from /var/lib/tor/onion/free/lnd/hostname (does not exist)
|
||||
# - REST hidden service named "lnd", colliding with the LND P2P onion
|
||||
# - TLS cert embedded in the URI (localhost CN + QR too dense to scan)
|
||||
#
|
||||
# Zeus needs: lndconnect://<lnd-rest-onion>:8080?macaroon=<admin> (no cert over Tor)
|
||||
|
||||
with lib;
|
||||
let
|
||||
cfg = config.services.lnd;
|
||||
operatorName = config.nix-bitcoin.operator.name;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
runAsUser = config.nix-bitcoin.runAsUserCmd;
|
||||
|
||||
mkLndconnect = {
|
||||
name,
|
||||
shebang ? "#!${pkgs.stdenv.shell} -e",
|
||||
port,
|
||||
authSecretPath,
|
||||
enableOnion,
|
||||
onionService ? null,
|
||||
certPath ? null
|
||||
}:
|
||||
# lndconnect requires a --configfile argument, although it's unused
|
||||
# https://github.com/LN-Zap/lndconnect/issues/25
|
||||
lib.hiPrio (pkgs.writeScriptBin name ''
|
||||
${shebang}
|
||||
url=$(
|
||||
${getExe pkgs.lndconnect} --url \
|
||||
${optionalString enableOnion "--host=$(cat ${config.nix-bitcoin.onionAddresses.dataDir}/${onionService})"} \
|
||||
--port=${toString port} \
|
||||
${if enableOnion || certPath == null then "--nocert" else "--tlscertpath='${certPath}'"} \
|
||||
--adminmacaroonpath='${authSecretPath}' \
|
||||
--configfile=/dev/null "$@"
|
||||
)
|
||||
|
||||
# If --url is in args
|
||||
if [[ " $* " =~ " --url " ]]; then
|
||||
echo "$url"
|
||||
else
|
||||
# UTF-8 QR is smaller than lndconnect's native output
|
||||
echo -n "$url" | ${getExe pkgs.qrencode} -t UTF8 -o -
|
||||
fi
|
||||
'');
|
||||
in {
|
||||
options.services.lnd.lndconnect = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Add a `lndconnect` binary to the system environment which prints
|
||||
connection info for lnd clients (Zeus).
|
||||
See: https://github.com/LN-Zap/lndconnect
|
||||
|
||||
Usage:
|
||||
```bash
|
||||
# Print QR code
|
||||
lndconnect
|
||||
|
||||
# Print URL
|
||||
lndconnect --url
|
||||
```
|
||||
'';
|
||||
};
|
||||
onion = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Create an onion service for the lnd REST server,
|
||||
which is used by lndconnect / Zeus.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf (cfg.enable && cfg.lndconnect.enable) (mkMerge [
|
||||
{
|
||||
environment.systemPackages = [(
|
||||
mkLndconnect {
|
||||
name = "lndconnect";
|
||||
# Run as lnd user because the macaroon and cert are not group-readable
|
||||
shebang = "#!/usr/bin/env -S ${runAsUser} ${cfg.user} ${pkgs.bash}/bin/bash";
|
||||
enableOnion = cfg.lndconnect.onion;
|
||||
onionService = "${cfg.user}/lnd-rest";
|
||||
port = cfg.restPort;
|
||||
certPath = cfg.certPath;
|
||||
authSecretPath = "${cfg.networkDir}/admin.macaroon";
|
||||
}
|
||||
)];
|
||||
|
||||
# LAN / clearnet Zeus needs REST on all interfaces. Tor-only stays on
|
||||
# the existing restAddress (loopback) and is reached via lnd-rest.
|
||||
services.lnd.restAddress = mkIf (!cfg.lndconnect.onion) "0.0.0.0";
|
||||
}
|
||||
|
||||
(mkIf cfg.lndconnect.onion {
|
||||
services.tor = {
|
||||
enable = true;
|
||||
# Dedicated name — must not reuse onionServices.lnd (that's P2P :9735).
|
||||
relay.onionServices.lnd-rest = nbLib.mkOnionService {
|
||||
target.addr = nbLib.address cfg.restAddress;
|
||||
target.port = cfg.restPort;
|
||||
port = cfg.restPort;
|
||||
};
|
||||
};
|
||||
nix-bitcoin.onionAddresses.access = {
|
||||
${cfg.user} = [ "lnd-rest" ];
|
||||
${operatorName} = [ "lnd-rest" ];
|
||||
};
|
||||
})
|
||||
]);
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.services = {
|
||||
mempool = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Enable Mempool, a fully featured Bitcoin visualizer, explorer, and API service.
|
||||
|
||||
Note: Mempool enables `txindex` in bitcoind (this is a requirement).
|
||||
|
||||
This module has two components:
|
||||
- A backend service (systemd service `mempool`)
|
||||
|
||||
- An optional web interface run by nginx, defined by options `services.mempool.frontend.*`.
|
||||
The frontend is enabled by default when mempool is enabled.
|
||||
For details, see `services.mempool.frontend.enable`.
|
||||
'';
|
||||
};
|
||||
|
||||
frontend = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = cfg.enable;
|
||||
description = ''
|
||||
Enable the mempool frontend (web interface).
|
||||
This starts a simple nginx instance, configured for local usage with
|
||||
settings similar to the `mempool/frontend` Docker image.
|
||||
|
||||
IMPORTANT:
|
||||
If you want to expose the mempool frontend to the internet, you
|
||||
should create a custom nginx config that includes TLS, backend caching, rate limiting
|
||||
and performance tuning.
|
||||
For this task, reuse the config snippets from option `services.mempool.frontend.nginxConfig`.
|
||||
See also: https://github.com/fort-nix/nixbitcoin.org/blob/master/website/mempool.nix,
|
||||
which contains a mempool nginx config for public hosting (running at
|
||||
https://mempool.nixbitcoin.org).
|
||||
'';
|
||||
};
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "HTTP server address.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 60845; # A random private port
|
||||
description = "HTTP server port.";
|
||||
};
|
||||
settings = mkOption {
|
||||
type = with types; attrsOf anything;
|
||||
default = {};
|
||||
example = {
|
||||
TESTNET_ENABLED = true;
|
||||
MEMPOOL_WEBSITE_URL = "mempool.mynode.org";
|
||||
};
|
||||
description = ''
|
||||
Mempool frontend settings.
|
||||
See here for available options:
|
||||
https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
|
||||
(`interface Env` and `defaultEnv`)
|
||||
'';
|
||||
};
|
||||
staticContentRoot = mkOption {
|
||||
type = types.path;
|
||||
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-frontend.withConfig cfg.frontend.settings;
|
||||
defaultText = "mempoolPkgs.mempool-frontend";
|
||||
description = "
|
||||
Path of the static frontend content root.
|
||||
";
|
||||
};
|
||||
nginxConfig = mkOption {
|
||||
readOnly = true;
|
||||
default = frontend.nginxConfig;
|
||||
defaultText = "(See source)";
|
||||
description = "
|
||||
An attrset of nginx config snippets for assembling a custom
|
||||
mempool nginx config.
|
||||
For details, see the source comments at the point of definition.
|
||||
";
|
||||
};
|
||||
};
|
||||
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Mempool backend address.";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 8999;
|
||||
description = "Mempool backend port.";
|
||||
};
|
||||
electrumServer = mkOption {
|
||||
type = types.enum [ "electrs" ];
|
||||
default = "electrs";
|
||||
description = ''
|
||||
The Electrum server to use for fetching address information.
|
||||
|
||||
Possible options:
|
||||
- electrs:
|
||||
Small database size, slow when querying new addresses.
|
||||
'';
|
||||
};
|
||||
settings = mkOption {
|
||||
type = with types; attrsOf (attrsOf anything);
|
||||
example = {
|
||||
MEMPOOL = {
|
||||
POLL_RATE_MS = 3000;
|
||||
STDOUT_LOG_MIN_PRIORITY = "debug";
|
||||
};
|
||||
PRICE_DATA_SERVER = {
|
||||
CLEARNET_URL = "https://myserver.org/prices";
|
||||
};
|
||||
};
|
||||
description = ''
|
||||
Mempool backend settings.
|
||||
See here for available options:
|
||||
https://github.com/mempool/mempool/blob/master/backend/src/config.ts
|
||||
'';
|
||||
};
|
||||
database = {
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
default = "mempool";
|
||||
description = "Database name.";
|
||||
};
|
||||
};
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = (pkgs.callPackage ../../packages/mempool { fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {}; }).mempool-backend;
|
||||
defaultText = "mempoolPkgs.mempool-backend";
|
||||
description = "The package providing mempool binaries.";
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "mempool";
|
||||
description = "The user as which to run Mempool.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.user;
|
||||
description = "The group as which to run Mempool.";
|
||||
};
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
|
||||
# Internal read-only options used by `./nodeinfo.nix` and `./onion-services.nix`
|
||||
mempool-frontend = let
|
||||
inherit (nbLib) mkAlias;
|
||||
in {
|
||||
enable = mkAlias cfg.frontend.enable;
|
||||
address = mkAlias cfg.frontend.address;
|
||||
port = mkAlias cfg.frontend.port;
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.services.mempool;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
nbPkgs = pkgs; # vendored: now alias to pkgs
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
|
||||
configFile = builtins.toFile "mempool-config" (builtins.toJSON cfg.settings);
|
||||
cacheDir = "/var/cache/mempool";
|
||||
|
||||
inherit (config.services)
|
||||
bitcoind
|
||||
electrs;
|
||||
|
||||
torSocket = config.services.tor.client.socksListenAddress;
|
||||
# Vendored mempool package
|
||||
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
|
||||
mempoolPkgs = pkgs.callPackage ../../packages/mempool { inherit fetchNodeModules; };
|
||||
|
||||
# See the `services.nginx` definition further below
|
||||
# on how to use these snippets.
|
||||
frontend.nginxConfig = {
|
||||
# This must be added to `services.nginx.commonHttpConfig` when
|
||||
# `mempool/location-static.conf` is used
|
||||
httpConfig = ''
|
||||
include ${mempoolPkgs.mempool-nginx-conf}/http-language.conf;
|
||||
'';
|
||||
|
||||
# Config for static website content.
|
||||
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
|
||||
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-redirects.conf
|
||||
staticContent = ''
|
||||
index index.html;
|
||||
|
||||
add_header Cache-Control "public, no-transform";
|
||||
add_header Vary Accept-Language;
|
||||
add_header Vary Cookie;
|
||||
|
||||
include ${mempoolPkgs.mempool-nginx-conf}/location-static.conf;
|
||||
|
||||
# Redirect /api to /docs/api
|
||||
location = /api {
|
||||
return 308 https://$host/docs/api;
|
||||
}
|
||||
location = /api/ {
|
||||
return 308 https://$host/docs/api;
|
||||
}
|
||||
'';
|
||||
|
||||
# Config for backend API.
|
||||
# This should be added to `services.nginx.virtualHosts.<mempool server name>.extraConfig`.
|
||||
# Adapted from mempool/nginx-mempool.conf and mempool/production/nginx/location-api.conf.
|
||||
proxyApi = let
|
||||
backend = "http://${nbLib.addressWithPort cfg.address cfg.port}";
|
||||
in ''
|
||||
location /api/ {
|
||||
proxy_pass ${backend}/api/v1/;
|
||||
}
|
||||
location /api/v1 {
|
||||
proxy_pass ${backend};
|
||||
}
|
||||
# Websocket API
|
||||
location /api/v1/ws {
|
||||
proxy_pass ${backend};
|
||||
|
||||
# Websocket header settings
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
|
||||
# Relevant settings from `recommendedProxyConfig` (nixos/nginx/default.nix)
|
||||
# (In the above api locations, these are inherited from the parent scope)
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
services.bitcoind.txindex = true;
|
||||
services.electrs.enable = true;
|
||||
services.mysql = {
|
||||
enable = true;
|
||||
package = pkgs.mariadb;
|
||||
ensureDatabases = [ cfg.database.name ];
|
||||
ensureUsers = [
|
||||
{
|
||||
name = cfg.user;
|
||||
ensurePermissions."${cfg.database.name}.*" = "ALL PRIVILEGES";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
# Available options:
|
||||
# https://github.com/mempool/mempool/blob/master/backend/src/config.ts
|
||||
services.mempool.settings = {
|
||||
MEMPOOL = {
|
||||
# mempool doesn't support regtest
|
||||
NETWORK = "mainnet";
|
||||
BACKEND = "electrum";
|
||||
HTTP_PORT = cfg.port;
|
||||
CACHE_DIR = "${cacheDir}/cache";
|
||||
STDOUT_LOG_MIN_PRIORITY = mkDefault "info";
|
||||
AUTOMATIC_POOLS_UPDATE = true;
|
||||
};
|
||||
CORE_RPC = {
|
||||
HOST = bitcoind.rpc.address;
|
||||
PORT = bitcoind.rpc.port;
|
||||
USERNAME = bitcoind.rpc.users.public.name;
|
||||
PASSWORD = "@btcRpcPassword@";
|
||||
};
|
||||
ELECTRUM = let
|
||||
server = config.services.${cfg.electrumServer};
|
||||
in {
|
||||
HOST = server.address;
|
||||
PORT = server.port;
|
||||
TLS_ENABLED = false;
|
||||
};
|
||||
DATABASE = {
|
||||
ENABLED = true;
|
||||
DATABASE = cfg.database.name;
|
||||
SOCKET = "/run/mysqld/mysqld.sock";
|
||||
PID_DIR = cacheDir;
|
||||
};
|
||||
} // optionalAttrs (cfg.tor.proxy) {
|
||||
# Use Tor for rate fetching and pool updating
|
||||
SOCKS5PROXY = {
|
||||
ENABLED = true;
|
||||
USE_ONION = true;
|
||||
HOST = torSocket.addr;
|
||||
PORT = torSocket.port;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.mempool = rec {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
requires = [ "mysql.service" ];
|
||||
wants = [ "${cfg.electrumServer}.service" ];
|
||||
after = requires ++ wants;
|
||||
preStart = ''
|
||||
mkdir -p '${cacheDir}/cache'
|
||||
<${configFile} sed \
|
||||
-e "s|@btcRpcPassword@|$(cat ${secretsDir}/bitcoin-rpcpassword-public)|" \
|
||||
> '${cacheDir}/config.json'
|
||||
'';
|
||||
environment.MEMPOOL_CONFIG_FILE = "${cacheDir}/config.json";
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
ExecStart = "${cfg.package}/bin/mempool-backend";
|
||||
CacheDirectory = "mempool";
|
||||
CacheDirectoryMode = "770";
|
||||
# Show "mempool" instead of "node" in the journal
|
||||
SyslogIdentifier = "mempool";
|
||||
User = cfg.user;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
||||
// nbLib.nodejs;
|
||||
};
|
||||
|
||||
services.nginx = mkIf cfg.frontend.enable {
|
||||
enable = true;
|
||||
enableReload = true;
|
||||
recommendedBrotliSettings = true;
|
||||
recommendedGzipSettings = true;
|
||||
recommendedOptimisation = true;
|
||||
recommendedProxySettings = true;
|
||||
recommendedTlsSettings = true;
|
||||
commonHttpConfig = frontend.nginxConfig.httpConfig;
|
||||
virtualHosts."mempool" = {
|
||||
serverName = "_";
|
||||
listen = [ { addr = cfg.frontend.address; port = cfg.frontend.port; } ];
|
||||
root = cfg.frontend.staticContentRoot;
|
||||
extraConfig =
|
||||
frontend.nginxConfig.staticContent +
|
||||
frontend.nginxConfig.proxyApi;
|
||||
};
|
||||
};
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
extraGroups = [ "bitcoinrpc-public" ];
|
||||
};
|
||||
users.groups.${cfg.group} = {};
|
||||
};
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
{
|
||||
options = {
|
||||
nix-bitcoin = {
|
||||
# Kept for compatibility, now simply aliases system pkgs
|
||||
pkgs = mkOption {
|
||||
type = types.attrs;
|
||||
default = pkgs;
|
||||
defaultText = "pkgs";
|
||||
description = "Alias to system pkgs (vendored nix-bitcoin now uses nixpkgs directly).";
|
||||
};
|
||||
|
||||
useVersionLockedPkgs = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Deprecated — vendored modules always use system pkgs.";
|
||||
};
|
||||
|
||||
pkgOverlays = mkOption {
|
||||
internal = true;
|
||||
type = with types; functionTo attrs;
|
||||
default = _: _: {};
|
||||
description = "Deprecated stub.";
|
||||
};
|
||||
|
||||
lib = mkOption {
|
||||
readOnly = true;
|
||||
default = import ./lib.nix lib pkgs config;
|
||||
defaultText = "vendor/nix-bitcoin/lib.nix";
|
||||
};
|
||||
|
||||
torClientAddressWithPort = mkOption {
|
||||
readOnly = true;
|
||||
default = with config.services.tor.client.socksListenAddress;
|
||||
"${addr}:${toString port}";
|
||||
defaultText = "(See source)";
|
||||
};
|
||||
|
||||
torify = mkOption {
|
||||
readOnly = true;
|
||||
default = pkgs.writers.writeBashBin "torify" ''
|
||||
${pkgs.tor}/bin/torify \
|
||||
--address ${config.services.tor.client.socksListenAddress.addr} \
|
||||
"$@"
|
||||
'';
|
||||
defaultText = "(See source)";
|
||||
};
|
||||
|
||||
runAsUserCmd = mkOption {
|
||||
readOnly = true;
|
||||
default = if config.security.doas.enable
|
||||
then "doas -u"
|
||||
else "sudo -u";
|
||||
defaultText = "(See source)";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options = {
|
||||
nix-bitcoin.nodeinfo = {
|
||||
enable = mkEnableOption "nodeinfo";
|
||||
|
||||
program = mkOption {
|
||||
readOnly = true;
|
||||
default = script;
|
||||
defaultText = "(See source)";
|
||||
};
|
||||
|
||||
services = mkOption {
|
||||
internal = true;
|
||||
type = types.attrs;
|
||||
default = {};
|
||||
defaultText = "(See source)";
|
||||
description = ''
|
||||
Nodeinfo service definitions.
|
||||
'';
|
||||
};
|
||||
|
||||
lib = mkOption {
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
default = nodeinfoLib;
|
||||
defaultText = "(See source)";
|
||||
description = ''
|
||||
Helper functions for defining nodeinfo services.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.nix-bitcoin.nodeinfo;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
|
||||
script = pkgs.writeScriptBin "nodeinfo" ''
|
||||
#!${pkgs.python3}/bin/python
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
|
||||
def success(*args):
|
||||
return subprocess.call(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
|
||||
|
||||
def is_active(unit):
|
||||
return success("systemctl", "is-active", "--quiet", unit)
|
||||
|
||||
def is_enabled(unit):
|
||||
return success("systemctl", "is-enabled", "--quiet", unit)
|
||||
|
||||
def cmd(*args):
|
||||
return subprocess.run(args, stdout=subprocess.PIPE).stdout.decode('utf-8')
|
||||
|
||||
def shell(*args):
|
||||
return cmd("bash", "-c", *args).strip()
|
||||
|
||||
infos = OrderedDict()
|
||||
operator = "${config.nix-bitcoin.operator.name}"
|
||||
|
||||
def get_onion_address(name, port):
|
||||
path = f"/var/lib/onion-addresses/{operator}/{name}"
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
onion_address = f.read().strip()
|
||||
except OSError:
|
||||
print(f"error reading file {path}", file=sys.stderr)
|
||||
return
|
||||
return f"{onion_address}:{port}"
|
||||
|
||||
def add_service(service, make_info, systemd_service = None):
|
||||
systemd_service = systemd_service or service
|
||||
if not is_active(systemd_service):
|
||||
infos[service] = f"'{systemd_service}.service' is not running"
|
||||
else:
|
||||
info = OrderedDict()
|
||||
exec(make_info, globals(), locals())
|
||||
infos[service] = info
|
||||
|
||||
if is_enabled("onion-addresses") and not is_active("onion-addresses"):
|
||||
print("error: service 'onion-addresses' is not running")
|
||||
exit(1)
|
||||
|
||||
${concatStrings infos}
|
||||
|
||||
print(json.dumps(infos, indent=2))
|
||||
'';
|
||||
|
||||
infos = map (serviceName:
|
||||
let serviceCfg = config.services.${serviceName};
|
||||
in optionalString serviceCfg.enable (cfg.services.${serviceName} serviceName serviceCfg)
|
||||
) (builtins.attrNames cfg.services);
|
||||
|
||||
nodeinfoLib = rec {
|
||||
mkInfo = extraCode: name: cfg:
|
||||
mkInfoLong {
|
||||
inherit extraCode name cfg;
|
||||
};
|
||||
|
||||
mkInfoLong = { extraCode ? "", name, cfg, systemdServiceName ? name }: ''
|
||||
add_service("${name}", """
|
||||
info["local_address"] = "${nbLib.addressWithPort cfg.address cfg.port}"
|
||||
'' + mkIfOnionPort name (onionPort: ''
|
||||
info["onion_address"] = get_onion_address("${name}", ${onionPort})
|
||||
'') + extraCode + ''
|
||||
|
||||
""", "${systemdServiceName}")
|
||||
'';
|
||||
|
||||
mkIfOnionPort = name: fn:
|
||||
if onionServices ? ${name} then
|
||||
fn (toString (builtins.elemAt onionServices.${name}.map 0).port)
|
||||
else
|
||||
"";
|
||||
};
|
||||
|
||||
inherit (config.services.tor.relay) onionServices;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
environment.systemPackages = [ script ];
|
||||
|
||||
nix-bitcoin.operator.enable = true;
|
||||
|
||||
nix-bitcoin.nodeinfo.services = with nodeinfoLib; {
|
||||
bitcoind = mkInfo "";
|
||||
lnd = name: cfg: mkInfo (''
|
||||
info["rest_address"] = "${nbLib.addressWithPort cfg.restAddress cfg.restPort}"
|
||||
'' + mkIfOnionPort "lnd-rest" (onionPort: ''
|
||||
info["onion_rest_address"] = get_onion_address("lnd-rest", ${onionPort})
|
||||
'') + ''
|
||||
info["nodeid"] = shell("lncli getinfo | jq -r '.identity_pubkey'")
|
||||
'') name cfg;
|
||||
electrs = mkInfo "";
|
||||
btcpayserver = mkInfo "";
|
||||
rtl = mkInfo "";
|
||||
mempool = mkInfo "";
|
||||
mempool-frontend = name: cfg: mkInfoLong {
|
||||
inherit name cfg;
|
||||
systemdServiceName = "nginx";
|
||||
};
|
||||
# Only add sshd when it has an onion service
|
||||
sshd = name: cfg: mkIfOnionPort "sshd" (onionPort: ''
|
||||
add_service("sshd", """info["onion_address"] = get_onion_address("sshd", ${onionPort})""")
|
||||
'');
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
# This module enables unprivileged users to read onion addresses.
|
||||
# By default, onion addresses in /var/lib/tor/onion are only readable by the
|
||||
# tor user.
|
||||
# The included service copies onion addresses to /var/lib/onion-addresses/<user>/
|
||||
# and sets permissions according to option 'access'.
|
||||
|
||||
{ config, lib, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.nix-bitcoin.onionAddresses = {
|
||||
access = mkOption {
|
||||
type = with types; attrsOf (listOf str);
|
||||
default = {};
|
||||
description = ''
|
||||
This option controls who is allowed to access onion addresses.
|
||||
For example, the following allows user 'myuser' to access bitcoind
|
||||
and clightning onion addresses:
|
||||
```nix
|
||||
{
|
||||
"myuser" = [ "bitcoind" "clightning" ];
|
||||
};
|
||||
```
|
||||
The onion hostnames can then be read from
|
||||
{file}`/var/lib/onion-addresses/myuser`.
|
||||
'';
|
||||
};
|
||||
services = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
description = ''
|
||||
Services that can access their onion address via file
|
||||
{file}`/var/lib/onion-addresses/<service>`
|
||||
The file is readable only by the service user.
|
||||
'';
|
||||
};
|
||||
dataDir = mkOption {
|
||||
readOnly = true;
|
||||
default = "/var/lib/onion-addresses";
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.nix-bitcoin.onionAddresses;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf (cfg.access != {} || cfg.services != []) {
|
||||
systemd.services.onion-addresses = {
|
||||
wantedBy = [ "tor.service" ];
|
||||
bindsTo = [ "tor.service" ];
|
||||
after = [ "tor.service" ];
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
StateDirectory = "onion-addresses";
|
||||
StateDirectoryMode = "771";
|
||||
PrivateNetwork = true; # This service needs no network access
|
||||
PrivateUsers = false;
|
||||
CapabilityBoundingSet = "CAP_CHOWN CAP_FSETID CAP_SETFCAP CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_IPC_OWNER";
|
||||
};
|
||||
script = ''
|
||||
waitForFile() {
|
||||
file=$1
|
||||
for ((i=0; i<300; i++)); do
|
||||
if [[ -e $file ]]; then
|
||||
return;
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
echo "Error: File $file did not appear after 30 sec."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Wait until tor is up
|
||||
waitForFile /var/lib/tor/state
|
||||
|
||||
cd ${cfg.dataDir}
|
||||
rm -rf ./*
|
||||
|
||||
${concatMapStrings
|
||||
(user: ''
|
||||
mkdir -p -m 0700 ${user}
|
||||
chown ${user} ${user}
|
||||
${concatMapStrings
|
||||
(service: ''
|
||||
onionFile='/var/lib/tor/onion/${service}/hostname'
|
||||
waitForFile "$onionFile"
|
||||
cp "$onionFile" '${user}/${service}'
|
||||
chown '${user}' '${user}/${service}'
|
||||
'')
|
||||
cfg.access.${user}
|
||||
}
|
||||
'')
|
||||
(builtins.attrNames cfg.access)
|
||||
}
|
||||
|
||||
${concatMapStrings (service: ''
|
||||
onionFile=/var/lib/tor/onion/${service}/hostname
|
||||
waitForFile "$onionFile"
|
||||
install -D -o ${config.systemd.services.${service}.serviceConfig.User} -m 400 "$onionFile" services/${service}
|
||||
'') cfg.services}
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
# This module creates onion-services for NixOS services.
|
||||
# An onion service can be enabled for every service that defines
|
||||
# options 'address', 'port' and optionally 'getPublicAddressCmd'.
|
||||
#
|
||||
# See it in use at ./presets/enable-tor.nix
|
||||
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.nix-bitcoin.onionServices = mkOption {
|
||||
default = {};
|
||||
type = with types; attrsOf (submodule (
|
||||
{ config, ... }: {
|
||||
options = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = config.public;
|
||||
description = ''
|
||||
Create an onion service for the given service.
|
||||
The service must define options {option}`address` and {option}`onionPort` (or `port`).
|
||||
'';
|
||||
};
|
||||
public = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Make the onion address accessible to the service.
|
||||
If enabled, the onion service is automatically enabled.
|
||||
Only available for services that define option {option}`getPublicAddressCmd`.
|
||||
'';
|
||||
};
|
||||
externalPort = mkOption {
|
||||
type = types.nullOr types.port;
|
||||
default = null;
|
||||
description = "Override the external port of the onion service.";
|
||||
};
|
||||
};
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
cfg = config.nix-bitcoin.onionServices;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
|
||||
onionServices = builtins.attrNames cfg;
|
||||
|
||||
activeServices = builtins.filter (service:
|
||||
config.services.${service}.enable && cfg.${service}.enable
|
||||
) onionServices;
|
||||
|
||||
publicServices = builtins.filter (service: cfg.${service}.public) activeServices;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkMerge [
|
||||
(mkIf (activeServices != []) {
|
||||
# Define hidden services
|
||||
services.tor = {
|
||||
enable = true;
|
||||
relay.onionServices = genAttrs activeServices (name:
|
||||
let
|
||||
service = config.services.${name};
|
||||
inherit (cfg.${name}) externalPort;
|
||||
in nbLib.mkOnionService {
|
||||
port = if externalPort != null then externalPort else service.port;
|
||||
target.port = service.onionPort or service.port;
|
||||
target.addr = nbLib.address service.address;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
nix-bitcoin.onionAddresses = {
|
||||
# Enable public services to access their own onion addresses
|
||||
services = publicServices;
|
||||
|
||||
# Allow the operator user to access onion addresses for all active services
|
||||
access.${config.nix-bitcoin.operator.name} = mkIf config.nix-bitcoin.operator.enable activeServices;
|
||||
};
|
||||
systemd.services = let
|
||||
onionAddresses = [ "onion-addresses.service" ];
|
||||
in genAttrs publicServices (service: {
|
||||
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
||||
# that propagates initial start failures but no restarts
|
||||
wants = onionAddresses;
|
||||
after = onionAddresses;
|
||||
});
|
||||
})
|
||||
|
||||
# Set getPublicAddressCmd for public services
|
||||
{
|
||||
services = let
|
||||
# publicServices' doesn't depend on config.services.*.enable,
|
||||
# so we can use it to define config.services without causing infinite recursion
|
||||
publicServices' = builtins.filter (service:
|
||||
let srv = cfg.${service};
|
||||
in srv.public && srv.enable
|
||||
) onionServices;
|
||||
in genAttrs publicServices' (service: {
|
||||
getPublicAddressCmd = "cat ${config.nix-bitcoin.onionAddresses.dataDir}/services/${service}";
|
||||
});
|
||||
}
|
||||
|
||||
# Set sensible defaults for some services
|
||||
{
|
||||
nix-bitcoin.onionServices = {
|
||||
btcpayserver = {
|
||||
externalPort = 80;
|
||||
};
|
||||
rtl = {
|
||||
externalPort = 80;
|
||||
};
|
||||
mempool-frontend = {
|
||||
externalPort = 80;
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.nix-bitcoin.operator = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Whether to define a user named `operator` for convenient interactive access
|
||||
to nix-bitcoin features (like `bitcoin-cli`).
|
||||
|
||||
When using nix-bitcoin as part of a larger system config, it makes sense
|
||||
to set your main system user as the operator, by setting option
|
||||
`nix-bitcoin.operator.name = "MAIN_USER_NAME";`.
|
||||
'';
|
||||
};
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
default = "operator";
|
||||
description = "Name of the operator user.";
|
||||
};
|
||||
groups = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
description = "Extra groups of the operatur user.";
|
||||
};
|
||||
allowRunAsUsers = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
description = "Users as which the operator is allowed to run commands.";
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.nix-bitcoin.operator;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
users.users.${cfg.name} = {
|
||||
isNormalUser = true;
|
||||
extraGroups = [
|
||||
"systemd-journal"
|
||||
"proc" # Enable full /proc access and systemd-status
|
||||
] ++ cfg.groups;
|
||||
};
|
||||
|
||||
security = mkIf (cfg.allowRunAsUsers != []) {
|
||||
# Use doas instead of sudo if enabled
|
||||
doas.extraConfig = mkIf config.security.doas.enable ''
|
||||
${lib.concatMapStrings (user: "permit nopass ${cfg.name} as ${user}\n") cfg.allowRunAsUsers}
|
||||
'';
|
||||
sudo.extraConfig = mkIf (!config.security.doas.enable) ''
|
||||
${cfg.name} ALL=(${builtins.concatStringsSep "," cfg.allowRunAsUsers}) NOPASSWD: ALL
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.services.rtl = {
|
||||
enable = mkEnableOption "RTL, a web interface for LND";
|
||||
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for HTTP connections.";
|
||||
};
|
||||
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 3000;
|
||||
description = "Port to listen for HTTP connections.";
|
||||
};
|
||||
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/rtl";
|
||||
description = "The data directory for RTL.";
|
||||
};
|
||||
|
||||
nightTheme = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable night theme by default.";
|
||||
};
|
||||
|
||||
extraCurrency = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "USD";
|
||||
description = ''
|
||||
Additional currency for displaying amounts.
|
||||
When set, Tor is disabled for the RTL service to allow currency rate fetching.
|
||||
'';
|
||||
};
|
||||
|
||||
nodes = {
|
||||
lnd = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable LND node in RTL.";
|
||||
};
|
||||
loop = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable Lightning Loop integration (requires loopd).";
|
||||
};
|
||||
};
|
||||
clightning = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable Core Lightning node in RTL (not supported in Sovran).";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "rtl";
|
||||
description = "The user as which to run RTL.";
|
||||
};
|
||||
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.user;
|
||||
description = "The group as which to run RTL.";
|
||||
};
|
||||
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
|
||||
cfg = config.services.rtl;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
|
||||
# Vendored RTL package
|
||||
fetchNodeModules = pkgs.callPackage ../../packages/build-support/fetch-node-modules.nix {};
|
||||
rtlPackage = pkgs.callPackage ../../packages/rtl { inherit fetchNodeModules; };
|
||||
|
||||
runePath = "${cfg.dataDir}/CLN-Rune.env";
|
||||
|
||||
rtlConfig = {
|
||||
multiPass = "@multiPass@";
|
||||
port = cfg.port;
|
||||
host = cfg.address;
|
||||
defaultNodeIndex = 1;
|
||||
dbDirectoryPath = cfg.dataDir;
|
||||
SSO = {
|
||||
rtlSSO = 0;
|
||||
rtlCookiePath = "";
|
||||
logoutRedirectLink = "";
|
||||
};
|
||||
nodes = optional cfg.nodes.lnd.enable ({
|
||||
index = 1;
|
||||
lnNode = "lnd";
|
||||
lnImplementation = "LND";
|
||||
authentication = {
|
||||
macaroonPath = "${cfg.dataDir}/macaroons";
|
||||
swapMacaroonPath = if cfg.nodes.lnd.loop then "${cfg.dataDir}/loop-macaroons" else "";
|
||||
boltzMacaroonPath = "";
|
||||
};
|
||||
settings = {
|
||||
userPersona = "OPERATOR";
|
||||
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
|
||||
themeColor = "PURPLE";
|
||||
channelBackupPath = "${cfg.dataDir}/backup";
|
||||
logLevel = "INFO";
|
||||
lnServerUrl = "https://${lnd.restAddress}:${toString lnd.restPort}";
|
||||
swapServerUrl = if cfg.nodes.lnd.loop then "https://127.0.0.1:8081" else "";
|
||||
boltzServerUrl = "";
|
||||
fiatConversion = cfg.extraCurrency != null;
|
||||
unannouncedChannels = true;
|
||||
} // optionalAttrs (cfg.extraCurrency != null) {
|
||||
currencyUnit = cfg.extraCurrency;
|
||||
};
|
||||
}) ++ optional cfg.nodes.clightning.enable {
|
||||
index = 2;
|
||||
lnNode = "clightning";
|
||||
lnImplementation = "CLN";
|
||||
authentication = {
|
||||
runePath = runePath;
|
||||
};
|
||||
settings = {
|
||||
userPersona = "OPERATOR";
|
||||
themeMode = if cfg.nightTheme then "NIGHT" else "DAY";
|
||||
themeColor = "PURPLE";
|
||||
logLevel = "INFO";
|
||||
fiatConversion = cfg.extraCurrency != null;
|
||||
} // optionalAttrs (cfg.extraCurrency != null) {
|
||||
currencyUnit = cfg.extraCurrency;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
configFile = builtins.toFile "config" (builtins.toJSON rtlConfig);
|
||||
|
||||
inherit (config.services)
|
||||
bitcoind
|
||||
lnd;
|
||||
|
||||
lndLoopEnabled = cfg.nodes.lnd.enable && cfg.nodes.lnd.loop;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
assertions = [
|
||||
{ assertion = cfg.nodes.lnd.enable;
|
||||
message = ''
|
||||
RTL: At least one node must be enabled. Sovran supports LND only.
|
||||
'';
|
||||
}
|
||||
{ assertion = !cfg.nodes.clightning.enable;
|
||||
message = ''
|
||||
RTL: Core Lightning (clightning) is not supported in Sovran. Use LND instead.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
services.lnd.enable = mkIf cfg.nodes.lnd.enable true;
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
services.rtl.tor.enforce = mkIf (cfg.extraCurrency != null) false;
|
||||
|
||||
systemd.services.rtl = rec {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = optional cfg.nodes.lnd.enable "lnd.service";
|
||||
after = wants ++ [ "nix-bitcoin-secrets.target" ];
|
||||
environment.RTL_CONFIG_PATH = cfg.dataDir;
|
||||
environment.DB_DIRECTORY_PATH = cfg.dataDir;
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
ExecStartPre = [
|
||||
(nbLib.script "rtl-setup-config" ''
|
||||
<${configFile} sed "s|@multiPass@|$(cat ${secretsDir}/rtl-password)|" \
|
||||
> '${cfg.dataDir}/RTL-Config.json'
|
||||
'')
|
||||
]
|
||||
++ optional cfg.nodes.lnd.enable
|
||||
# The lnd admin macaroon is not readable by group `lnd`, so copy it
|
||||
(nbLib.rootScript "rtl-copy-macaroon" ''
|
||||
install --compare -m 640 -o ${cfg.user} -g ${cfg.group} -D ${lnd.networkDir}/admin.macaroon \
|
||||
'${cfg.dataDir}/macaroons/admin.macaroon'
|
||||
'');
|
||||
ExecStart = "${rtlPackage}/bin/rtl";
|
||||
# Show "rtl" instead of "node" in the journal
|
||||
SyslogIdentifier = "rtl";
|
||||
User = cfg.user;
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
||||
// nbLib.nodejs;
|
||||
};
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
extraGroups = optional lndLoopEnabled lnd.group;
|
||||
};
|
||||
users.groups.${cfg.group} = {};
|
||||
|
||||
nix-bitcoin.secrets.rtl-password.user = cfg.user;
|
||||
nix-bitcoin.generateSecretsCmds.rtl = ''
|
||||
makePasswordSecret rtl-password
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.nix-bitcoin = {
|
||||
secretsDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/etc/nix-bitcoin-secrets";
|
||||
description = "Directory to store secrets";
|
||||
};
|
||||
|
||||
setupSecrets = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Set permissions for existing secrets in {option}`nix-bitcoin.secretsDir`
|
||||
before services are started.
|
||||
'';
|
||||
};
|
||||
|
||||
generateSecrets = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Automatically generate all required secrets before services are started.
|
||||
Note: Make sure to create a backup of the generated secrets.
|
||||
'';
|
||||
};
|
||||
|
||||
generateSecretsCmds = mkOption {
|
||||
type = types.attrsOf types.lines;
|
||||
default = {};
|
||||
description = ''
|
||||
Bash expressions for generating secrets.
|
||||
'';
|
||||
};
|
||||
|
||||
# Currently, this is used only by ../deployment/nixops.nix
|
||||
deployment.secretsDir = mkOption {
|
||||
type = types.path;
|
||||
description = ''
|
||||
Directory of local secrets that are transferred to the nix-bitcoin node on deployment
|
||||
'';
|
||||
};
|
||||
|
||||
secrets = mkOption {
|
||||
default = {};
|
||||
type = with types; attrsOf (submodule (
|
||||
{ config, ... }: {
|
||||
options = {
|
||||
user = mkOption {
|
||||
type = str;
|
||||
default = "root";
|
||||
};
|
||||
group = mkOption {
|
||||
type = str;
|
||||
default = config.user;
|
||||
};
|
||||
permissions = mkOption {
|
||||
type = str;
|
||||
default = "440";
|
||||
};
|
||||
};
|
||||
}
|
||||
));
|
||||
};
|
||||
|
||||
secretsSetupMethod = mkOption {
|
||||
type = with types; nullOr str;
|
||||
default = null;
|
||||
};
|
||||
|
||||
generateSecretsScript = mkOption {
|
||||
internal = true;
|
||||
default = let
|
||||
rpcauthSrc = pkgs.fetchurl {
|
||||
url = "https://raw.githubusercontent.com/bitcoin/bitcoin/d6cde007db9d3e6ee93bd98a9bbfdce9bfa9b15b/share/rpcauth/rpcauth.py";
|
||||
sha256 = "189mpplam6yzizssrgiyv70c9899ggh8cac76j4n7v0xqzfip07n";
|
||||
};
|
||||
rpcauth = pkgs.writers.writeBash "rpcauth" ''
|
||||
exec ${pkgs.python3}/bin/python ${rpcauthSrc} "$@"
|
||||
'';
|
||||
# Writes secrets to PWD
|
||||
in pkgs.writers.writeBash "generate-secrets" ''
|
||||
set -euo pipefail
|
||||
|
||||
export PATH=${lib.makeBinPath (with pkgs; [ coreutils gnugrep ])}
|
||||
|
||||
makePasswordSecret() {
|
||||
# Passwords have alphabet {a-z, A-Z, 0-9} and ~119 bits of entropy
|
||||
[[ -e $1 ]] || ${pkgs.pwgen}/bin/pwgen -s 20 1 > "$1"
|
||||
}
|
||||
makeBitcoinRPCPassword() {
|
||||
user=$1
|
||||
file=bitcoin-rpcpassword-$user
|
||||
HMACfile=bitcoin-HMAC-$user
|
||||
makePasswordSecret "$file"
|
||||
if [[ $file -nt $HMACfile ]]; then
|
||||
${rpcauth} $user $(cat "$file") | grep rpcauth | cut -d ':' -f 2 > "$HMACfile"
|
||||
fi
|
||||
}
|
||||
makeCert() {
|
||||
name=$1
|
||||
# Add leading comma if not empty
|
||||
extraAltNames=''${2:+,}''${2:-}
|
||||
if [[ ! -e $name-key ]]; then
|
||||
# Create new key and cert
|
||||
doMakeCert "-newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -keyout $name-key"
|
||||
elif [[ ! -e $name-cert \
|
||||
|| $(cat "$name-cert-alt-names" 2>/dev/null) != $extraAltNames ]]; then
|
||||
# Create cert from existing key
|
||||
doMakeCert "-key $name-key"
|
||||
fi;
|
||||
}
|
||||
doMakeCert() {
|
||||
# This fn uses global variables `name` and `extraAltNames`
|
||||
keyOpts=$1
|
||||
${pkgs.openssl}/bin/openssl req -x509 \
|
||||
-sha256 -days 3650 $keyOpts -out "$name-cert" \
|
||||
-subj "/CN=localhost/O=$name" \
|
||||
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1$extraAltNames"
|
||||
echo "$extraAltNames" > "$name-cert-alt-names"
|
||||
}
|
||||
|
||||
umask u=rw,go=
|
||||
${builtins.concatStringsSep "\n" (builtins.attrValues cfg.generateSecretsCmds)}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.nix-bitcoin;
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = {
|
||||
assertions = [
|
||||
{ assertion = cfg.secretsSetupMethod != null;
|
||||
message = ''
|
||||
No secrets setup method has been defined.
|
||||
To fix this, choose one of the following:
|
||||
|
||||
- Use one of the deployment methods in ${toString ./../deployment}
|
||||
|
||||
- Set `nix-bitcoin.generateSecrets = true` to automatically generate secrets
|
||||
|
||||
- Set `nix-bitcoin.secretsSetupMethod = "manual"` if you want to manually setup secrets
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
# This target is active when secrets have been setup successfully.
|
||||
systemd.targets.nix-bitcoin-secrets = mkIf (cfg.secretsSetupMethod != "manual") {
|
||||
# This ensures that the secrets target is always activated when switching
|
||||
# configurations.
|
||||
# In this way `switch-to-configuration` is guaranteed to show an error
|
||||
# when activating the secrets target fails on deployment.
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
};
|
||||
|
||||
nix-bitcoin.setupSecrets = mkIf cfg.generateSecrets true;
|
||||
|
||||
nix-bitcoin.secretsSetupMethod = mkIf cfg.setupSecrets "setup-secrets";
|
||||
|
||||
# Operation of this service:
|
||||
# - Set owner and permissions for all used secrets
|
||||
# - Make all other secrets accessible to root only
|
||||
# For all steps make sure that no secrets are copied to the nix store.
|
||||
#
|
||||
systemd.services.setup-secrets = mkIf cfg.setupSecrets {
|
||||
requiredBy = [ "nix-bitcoin-secrets.target" ];
|
||||
before = [ "nix-bitcoin-secrets.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
# Use the same sort order for globbing and sorting as in Nix attrsets.
|
||||
# Required for `comm` below.
|
||||
export LC_COLLATE=C
|
||||
|
||||
${optionalString cfg.generateSecrets ''
|
||||
mkdir -p "${cfg.secretsDir}"
|
||||
cd "${cfg.secretsDir}"
|
||||
chown root: .
|
||||
chmod 0700 .
|
||||
${cfg.generateSecretsScript}
|
||||
''}
|
||||
|
||||
setupSecret() {
|
||||
file="$1"
|
||||
user="$2"
|
||||
group="$3"
|
||||
permissions="$4"
|
||||
if [[ ! -e $file ]]; then
|
||||
echo "Error: Secret file '$file' is missing"
|
||||
exit 1
|
||||
fi
|
||||
chown "$user:$group" "$file"
|
||||
chmod "$permissions" "$file"
|
||||
processedFiles+=("$file")
|
||||
}
|
||||
|
||||
dir="${cfg.secretsDir}"
|
||||
if [[ ! -e $dir ]]; then
|
||||
echo "Error: Secrets dir '$dir' is missing"
|
||||
exit 1
|
||||
fi
|
||||
chown root: "$dir"
|
||||
cd "$dir"
|
||||
|
||||
processedFiles=()
|
||||
${
|
||||
concatStrings (mapAttrsToList (n: v: ''
|
||||
setupSecret ${n} ${v.user} ${v.group} ${v.permissions}
|
||||
'') cfg.secrets)
|
||||
}
|
||||
|
||||
# Make all other files accessible to root only
|
||||
unprocessedFiles=$(
|
||||
comm -23 <(shopt -s nullglob; printf '%s\n' *) <(printf '%s\n' "''${processedFiles[@]}")
|
||||
)
|
||||
if [[ $unprocessedFiles ]]; then
|
||||
IFS=$'\n'
|
||||
# shellcheck disable=SC2086
|
||||
chown root: $unprocessedFiles
|
||||
# shellcheck disable=SC2086
|
||||
chmod 0440 $unprocessedFiles
|
||||
fi
|
||||
|
||||
# Now make the secrets dir accessible to other users
|
||||
chmod 0751 "$dir"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
{
|
||||
options = {
|
||||
nix-bitcoin.security.dbusHideProcessInformation = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Only allow users with group `proc` to retrieve systemd unit information like
|
||||
cgroup paths (i.e. (sub)process command lines) via D-Bus.
|
||||
|
||||
This mitigates a systemd security issue where (sub)process command lines can
|
||||
be retrieved by services even when their access to /proc is restricted
|
||||
(via ProtectProc).
|
||||
|
||||
This option works by restricting the D-Bus method `GetUnitProcesses`, which
|
||||
is also used internally by {command}`systemctl status`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf config.nix-bitcoin.security.dbusHideProcessInformation {
|
||||
users.groups.proc = {};
|
||||
nix-bitcoin.operator.groups = [ "proc" ]; # Enable operator access to systemd-status
|
||||
|
||||
services.dbus.packages = lib.mkAfter [ # Apply at the end to override the default policy
|
||||
(pkgs.writeTextDir "etc/dbus-1/system.d/dbus.conf" ''
|
||||
<busconfig>
|
||||
<policy context="default">
|
||||
<deny
|
||||
send_destination="org.freedesktop.systemd1"
|
||||
send_interface="org.freedesktop.systemd1.Manager"
|
||||
send_member="GetUnitProcesses"
|
||||
/>
|
||||
</policy>
|
||||
<policy group="proc">
|
||||
<allow
|
||||
send_destination="org.freedesktop.systemd1"
|
||||
send_interface="org.freedesktop.systemd1.Manager"
|
||||
send_member="GetUnitProcesses"
|
||||
/>
|
||||
</policy>
|
||||
</busconfig>
|
||||
'')
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{ config, lib, ... }:
|
||||
with lib;
|
||||
let
|
||||
options.nix-bitcoin.configVersion = mkOption {
|
||||
type = with types; nullOr str;
|
||||
default = null;
|
||||
description = "Vendored stub — no version migration needed.";
|
||||
};
|
||||
in {
|
||||
inherit options;
|
||||
config = {};
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
|
||||
services.bitcoind = {
|
||||
enable = true;
|
||||
# Keep the normal loopback P2P socket available for local clients such as
|
||||
# Bisq. Because `address` defaults to 127.0.0.1 this does not expose a
|
||||
# clearnet or LAN listener. When the existing bitcoind onion service is
|
||||
# enabled, this also creates its Tor-tagged loopback target on port 8334.
|
||||
listen = true;
|
||||
package = pkgs.bitcoind;
|
||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||
txindex = true;
|
||||
tor.proxy = true;
|
||||
tor.enforce = true;
|
||||
disablewallet = true;
|
||||
extraConfig = ''
|
||||
peerbloomfilters=1
|
||||
server=1
|
||||
'';
|
||||
};
|
||||
|
||||
nix-bitcoin.onionServices.bitcoind = {
|
||||
enable = true;
|
||||
# This is a locally vendored option namespace, not an upstream dependency.
|
||||
# The onion listener remains available to peers that already know it;
|
||||
# advertising its address through Bitcoin peer gossip is opt-in in the Hub.
|
||||
public = config.sovran_systemsOS.features.bitcoin-tor-gossip;
|
||||
};
|
||||
nix-bitcoin.onionServices.electrs.enable = true;
|
||||
nix-bitcoin.onionServices.rtl.enable = true;
|
||||
|
||||
services.electrs = {
|
||||
enable = true;
|
||||
tor.enforce = true;
|
||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data";
|
||||
};
|
||||
|
||||
services.lnd = {
|
||||
enable = true;
|
||||
tor.enforce = true;
|
||||
tor.proxy = true;
|
||||
extraConfig = ''
|
||||
protocol.option-scid-alias=true
|
||||
'';
|
||||
};
|
||||
|
||||
nix-bitcoin.onionServices.lnd.public = true;
|
||||
|
||||
services.lnd.lndconnect = {
|
||||
enable = true;
|
||||
onion = true;
|
||||
};
|
||||
|
||||
services.rtl = {
|
||||
enable = true;
|
||||
tor.enforce = true;
|
||||
port = 3050;
|
||||
nightTheme = true;
|
||||
nodes = {
|
||||
lnd = {
|
||||
enable = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services.btcpayserver = {
|
||||
enable = config.sovran_systemsOS.web.btcpayserver;
|
||||
};
|
||||
|
||||
services.btcpayserver.lightningBackend = "lnd";
|
||||
|
||||
nix-bitcoin.generateSecrets = true;
|
||||
nix-bitcoin.nodeinfo.enable = true;
|
||||
|
||||
nix-bitcoin.operator = {
|
||||
enable = true;
|
||||
name = "free";
|
||||
};
|
||||
|
||||
# vendored: now no-op (always uses nixpkgs)
|
||||
nix-bitcoin.useVersionLockedPkgs = false;
|
||||
|
||||
systemd.services.bitcoind = {
|
||||
requires = [ "run-media-Second_Drive.mount" ];
|
||||
after = [ "run-media-Second_Drive.mount" ];
|
||||
serviceConfig.PrivateUsers = lib.mkForce false;
|
||||
};
|
||||
|
||||
systemd.services.electrs = {
|
||||
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
|
||||
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
|
||||
wants = [ "bitcoind.service" ];
|
||||
};
|
||||
|
||||
systemd.services.lnd = {
|
||||
wants = [ "bitcoind.service" ];
|
||||
# requires for bitcoind set by nix-bitcoin; mkForce removes it
|
||||
requires = lib.mkForce [ ];
|
||||
};
|
||||
|
||||
systemd.services.sovran-btc-permissions = {
|
||||
description = "Fix Bitcoin/Electrs data directory ownership on second drive";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "run-media-Second_Drive.mount" ];
|
||||
before = [ "bitcoind.service" "electrs.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node ]; then
|
||||
chown -R bitcoin:bitcoin /run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node
|
||||
fi
|
||||
if [ -d /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data ]; then
|
||||
chown -R electrs:electrs /run/media/Second_Drive/BTCEcoandBackup/Electrs_Data
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 3051 ];
|
||||
networking.firewall.allowedUDPPorts = [ 3051 ];
|
||||
|
||||
sovran_systemsOS.domainRequirements = [
|
||||
{ name = "btcpayserver"; label = "BTCPay Server"; example = "pay.yourdomain.com"; }
|
||||
];
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
config = lib.mkMerge [
|
||||
|
||||
# Vendored nix-bitcoin is always imported via modules/vendor/nix-bitcoin/modules.nix.
|
||||
# The Sovran_Bitcoin flake module is always imported (see flake.nix).
|
||||
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
||||
# without enabling any Bitcoin services.
|
||||
{
|
||||
|
||||
+93
-49
@@ -51,8 +51,8 @@ let
|
||||
]; }
|
||||
{ name = "LND"; unit = "lnd.service"; type = "system"; icon = "lnd"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = []; }
|
||||
{ name = "Ride The Lightning"; unit = "rtl.service"; type = "system"; icon = "rtl"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; }
|
||||
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051"; }
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/rtl/hostname"; prefix = "http://"; suffix = "/rtl/"; }
|
||||
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":3051/rtl/"; }
|
||||
{ label = "Password"; file = "/etc/nix-bitcoin-secrets/rtl-password"; }
|
||||
{ label = "How to Access"; value = "• Tor Address: Open in Tor Browser from any device, anywhere in the world\n• Local Network: Open in any browser, but only when connected to your home network"; }
|
||||
]; }
|
||||
@@ -135,16 +135,13 @@ let
|
||||
"bitcoind.service" = if pkgs ? bitcoind then pkgs.bitcoind.version else "27.1.0";
|
||||
"electrs.service" = if pkgs ? electrs then pkgs.electrs.version else "0.10.6";
|
||||
"lnd.service" = if pkgs ? lnd then pkgs.lnd.version else "0.18.0";
|
||||
# Keep the fallbacks aligned with the vendored packages used by the
|
||||
# service modules (RTL 0.15.10 and Mempool 3.2.1). The nixpkgs attrs are
|
||||
# optional because these packages are built locally in this repository.
|
||||
"rtl.service" = if pkgs ? clightning-rtl then pkgs.clightning-rtl.version else (if pkgs ? rtl then pkgs.rtl.version else "0.15.10");
|
||||
# BTCPay Server is intentionally sourced from pkgs.stable by the service
|
||||
# module. Read the configured package here rather than pkgs.btcpayserver
|
||||
# (unstable), otherwise the Hub can advertise a version that is not running.
|
||||
# Vendored packages come from the Sovran_Bitcoin flake overlay
|
||||
# (pkgs.sovran-bitcoin.*). Evaluate them directly so the Hub shows
|
||||
# the exact versions this system ships.
|
||||
"rtl.service" = pkgs.sovran-bitcoin.rtl.version;
|
||||
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
||||
"albyhub.service" = if pkgs ? albyhub then pkgs.albyhub.version else "1.8.0";
|
||||
"mempool.service" = if pkgs ? mempool then pkgs.mempool.version else "3.2.1";
|
||||
"albyhub.service" = pkgs.sovran-bitcoin.albyhub.version;
|
||||
"mempool.service" = pkgs.sovran-bitcoin.mempool-backend.version;
|
||||
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
||||
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
||||
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
||||
@@ -153,6 +150,61 @@ let
|
||||
"haven-relay.service" = if pkgs ? haven-relay then pkgs.haven-relay.version else (if pkgs ? haven then pkgs.haven.version else "0.1.0");
|
||||
});
|
||||
|
||||
# Shared shell prelude used by both the update and rebuild wrapper scripts.
|
||||
# A flake/package fetch that is interrupted (network blip, reboot
|
||||
# mid-download, disk filled, hiccup on the remote) can leave a truncated
|
||||
# tarball or partial git clone in Nix's download caches. Nix then reuses the
|
||||
# corrupt archive on every retry and dies with "cannot read file from
|
||||
# tarball: Truncated tar archive detected" — a failure that is NOT fixed by
|
||||
# simply re-running, but IS fixed by clearing the fetch caches. run_step runs
|
||||
# a command and, on the first failure that matches a download/cache
|
||||
# signature, clears the caches and retries once. Real config errors never
|
||||
# match, so they still fail loudly. Each sourcing script must define $LOG.
|
||||
nix-self-heal-prelude = ''
|
||||
transient_failure() {
|
||||
grep -Eqi 'truncated tar|unexpected end of (file|archive)|unexpected eof|corrupt(ed)? (archive|nar|download|file)|could not (fetch|download)|download.*(failed|interrupted)|timed out|timeout|connection (reset|refused|timed out)|network is unreachable|temporary failure in name resolution|checksum mismatch|hash mismatch|nar hash|unable to download|store path.*is not valid|cannot read file from tarball|into the git cache' "$LOG"
|
||||
}
|
||||
|
||||
clear_fetch_caches() {
|
||||
echo "[SELF-HEAL] Clearing stale Nix download caches and verifying the Nix store…"
|
||||
# Re-fetchable caches only; /nix/store generations and the running system
|
||||
# are never touched here.
|
||||
rm -rf /root/.cache/nix/tarballs /root/.cache/nix/vcs-cache /root/.cache/nix/git* /root/.cache/nix/flakes 2>/dev/null || true
|
||||
# Fast closure-level repair only. A full --check-contents scan hashes
|
||||
# every store path and can take tens of minutes on a big node; the cache
|
||||
# clear above is the actual fix for truncated/corrupt downloads.
|
||||
nix-store --verify --repair >/dev/null 2>&1 || true
|
||||
echo "[SELF-HEAL] Caches cleared; retrying…"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# run_step LABEL CMD [ARGS...] — run a build step; on a transient
|
||||
# fetch/cache failure, heal once and retry. Returns the command exit code
|
||||
# but leaves error messaging to the caller.
|
||||
run_step() {
|
||||
label="$1"; shift
|
||||
rc=1
|
||||
for try in 1 2; do
|
||||
if [ "$try" -eq 2 ]; then
|
||||
echo "── $label — retry after cache repair ──"
|
||||
fi
|
||||
"$@"
|
||||
rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$try" -eq 1 ] && transient_failure; then
|
||||
echo ""
|
||||
echo "[SELF-HEAL] $label failed on a download/cache error (see above)."
|
||||
clear_fetch_caches
|
||||
continue
|
||||
fi
|
||||
return "$rc"
|
||||
done
|
||||
return "$rc"
|
||||
}
|
||||
'';
|
||||
|
||||
# ── Update wrapper script ──────────────────────────────────────
|
||||
update-script = pkgs.writeShellScript "sovran-hub-update.sh" ''
|
||||
set -uo pipefail
|
||||
@@ -174,12 +226,14 @@ let
|
||||
|
||||
RC=0
|
||||
|
||||
${nix-self-heal-prelude}
|
||||
|
||||
echo "── Step 1/3: nix flake update ────────────────────"
|
||||
if ! nix flake update --flake /etc/nixos --print-build-logs \
|
||||
if ! run_step "nix flake update" nix flake update --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1; then
|
||||
--option fallback true; then
|
||||
echo "[ERROR] nix flake update failed"
|
||||
RC=1
|
||||
fi
|
||||
@@ -189,21 +243,21 @@ let
|
||||
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
||||
# Stream output straight into $LOG (see rebuild-script) so the Hub UI
|
||||
# shows live progress instead of an empty log during long builds.
|
||||
nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true
|
||||
BOOT_RC=$?
|
||||
if [ "$BOOT_RC" -ne 0 ]; then
|
||||
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true; then
|
||||
if ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
|
||||
# The marker is informational only. The Hub derives pending-reboot
|
||||
# state from the NixOS system profile itself, so failing to record
|
||||
# the marker must not fail an otherwise successful update.
|
||||
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
||||
rm -f "$GENERATION"
|
||||
fi
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot failed"
|
||||
RC=1
|
||||
elif ! readlink -f /nix/var/nix/profiles/system > "$GENERATION"; then
|
||||
# The marker is informational only. The Hub derives pending-reboot
|
||||
# state from the NixOS system profile itself, so failing to record
|
||||
# the marker must not fail an otherwise successful update.
|
||||
echo "[WARNING] update succeeded but its staged generation could not be recorded"
|
||||
rm -f "$GENERATION"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
@@ -248,12 +302,15 @@ let
|
||||
echo " Sovran_SystemsOS Rebuild — $(date)"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
${nix-self-heal-prelude}
|
||||
|
||||
echo "── Rebuilding system configuration ──────────────"
|
||||
# Stream output straight into $LOG (tee'd by the exec redirect above) so
|
||||
# the Hub UI shows live progress. Capturing the output in a variable
|
||||
# kept the log empty for the entire build+activation, which made long
|
||||
# rebuilds can otherwise look like a hang.
|
||||
nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
run_step "nixos-rebuild switch" nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
@@ -269,11 +326,11 @@ let
|
||||
echo ""
|
||||
echo " ✓ Build succeeded — a reboot is required to apply this rebuild"
|
||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||
if nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
if run_step "nixos-rebuild boot" nixos-rebuild boot --flake /etc/nixos --print-build-logs \
|
||||
--option connect-timeout 10 \
|
||||
--option stalled-download-timeout 90 \
|
||||
--option download-attempts 7 \
|
||||
--option fallback true 2>&1; then
|
||||
--option fallback true; then
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot also failed"
|
||||
@@ -281,6 +338,7 @@ let
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild switch failed"
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✗ Rebuild failed — see errors above"
|
||||
@@ -408,6 +466,9 @@ DESKTOP
|
||||
import os, sys
|
||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
||||
sys.path.insert(0, base)
|
||||
# Canonical NWC implementation — single source of truth from the
|
||||
# Sovran_Bitcoin flake (imported directly by server.py).
|
||||
sys.path.insert(0, os.path.join("${pkgs.sovran-bitcoin.nwc}", "lib", "sovran-nwc"))
|
||||
os.environ["SOVRAN_HUB_CONFIG"] = os.path.join(base, "config.json")
|
||||
os.environ["SOVRAN_HUB_VERSIONS"] = os.path.join(base, "versions.json")
|
||||
os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "icons")
|
||||
@@ -421,26 +482,9 @@ uvicorn.run(
|
||||
LAUNCHER
|
||||
chmod +x $out/bin/sovran-hub-web
|
||||
|
||||
cat > $out/bin/nwc-wallet <<LAUNCHER
|
||||
#!${pkgs.python3}/bin/python3
|
||||
import os, sys
|
||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
||||
sys.path.insert(0, base)
|
||||
from sovran_systemsos_web.nwc_wallet_cli import main
|
||||
sys.exit(main())
|
||||
LAUNCHER
|
||||
chmod +x $out/bin/nwc-wallet
|
||||
|
||||
cat > $out/bin/nwc-lnurl <<LAUNCHER
|
||||
#!${pkgs.python3}/bin/python3
|
||||
import os, sys
|
||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
||||
sys.path.insert(0, base)
|
||||
from sovran_systemsos_web.nwc_lnurl_service import main
|
||||
main()
|
||||
LAUNCHER
|
||||
chmod +x $out/bin/nwc-lnurl
|
||||
|
||||
# nwc-wallet / nwc-lnurl binaries are no longer shipped here: the
|
||||
# Sovran_Bitcoin flake provides them (env-wrapped nwc-wallet via
|
||||
# albyhub.nix, nwc-lnurl.service via lnurl.nix) — single source of truth.
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
|
||||
@@ -185,6 +185,14 @@ EOF
|
||||
fi
|
||||
echo "Detected primary network interface: $IFACE"
|
||||
|
||||
# Derive the LAN subnet this box sits on so the embedded TURN relay
|
||||
# is allowed to hand media to LiveKit's LAN host candidate (see the
|
||||
# allow_restricted_peer_cidrs block below). Computed from the primary
|
||||
# interface's own address, so it always matches the subnet the LAN
|
||||
# clients (phones on Wi-Fi) actually live on.
|
||||
LAN_CIDR=$(ip -4 -o addr show dev "$IFACE" | awk '{print $4}' | grep -vE '^(127\.|169\.254\.)' | head -n1 | python3 -c 'import sys, ipaddress; s = sys.stdin.read().strip(); print(str(ipaddress.ip_network(s, strict=False)) if s else "")' 2>/dev/null)
|
||||
if [ -n "$LAN_CIDR" ]; then echo "Derived LAN CIDR for TURN relay: $LAN_CIDR"; else echo "Derived LAN CIDR for TURN relay: <none>"; fi
|
||||
|
||||
# Generate the full LiveKit config the daemon will load. turn.domain and
|
||||
# rtc.interfaces.includes are only known at runtime, so they are
|
||||
# substituted here. The cert/key paths point at the LoadCredential-staged
|
||||
@@ -229,10 +237,9 @@ port: 7880
|
||||
rtc:
|
||||
use_external_ip: false
|
||||
node_ip: $PUBLIC_IP
|
||||
advertise_internal_ip: true
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
port_range_start: 30000
|
||||
port_range_end: 40000
|
||||
interfaces:
|
||||
includes:
|
||||
- $IFACE
|
||||
@@ -244,10 +251,9 @@ port: 7880
|
||||
rtc:
|
||||
use_external_ip: true
|
||||
skip_external_ip_validation: true
|
||||
advertise_internal_ip: true
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
port_range_start: 30000
|
||||
port_range_end: 40000
|
||||
interfaces:
|
||||
includes:
|
||||
- $IFACE
|
||||
@@ -264,16 +270,37 @@ EOF
|
||||
# vhost (/livekit/jwt/sfu_webhook → 8073).
|
||||
LK_KEY=$(cut -d: -f1 < ${livekitKeyFile} | tr -d '[:space:]')
|
||||
|
||||
# TURN/TLS is intentionally not configured (no tls_port): LiveKit
|
||||
# advertises turns:<domain>:443 to clients regardless of tls_port, so
|
||||
# a 5349 TURN/TLS listener would be unreachable and only adds attack
|
||||
# surface. The staged cert/key stay for a future TURN/TLS-on-443
|
||||
# (Caddy layer4 SNI) setup.
|
||||
cat >> /run/livekit/livekit.yaml <<EOF
|
||||
room:
|
||||
auto_create: false
|
||||
turn:
|
||||
enabled: true
|
||||
domain: $MATRIX
|
||||
tls_port: 5349
|
||||
udp_port: 3478
|
||||
relay_range_start: 40000
|
||||
relay_range_end: 40099
|
||||
cert_file: /run/credentials/livekit.service/turn-cert
|
||||
key_file: /run/credentials/livekit.service/turn-key
|
||||
EOF
|
||||
|
||||
# By default the embedded TURN relay refuses to send media to
|
||||
# private/loopback peers. That would force its final hop to the
|
||||
# public/WAN IP (hairpin NAT) — exactly what breaks calls on routers
|
||||
# without NAT loopback. Allow the LAN subnet so the relay can deliver
|
||||
# directly to LiveKit's LAN host candidate instead.
|
||||
if [ -n "$LAN_CIDR" ]; then
|
||||
cat >> /run/livekit/livekit.yaml <<EOF
|
||||
allow_restricted_peer_cidrs:
|
||||
- $LAN_CIDR
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat >> /run/livekit/livekit.yaml <<EOF
|
||||
webhook:
|
||||
api_key: $LK_KEY
|
||||
urls:
|
||||
@@ -315,10 +342,14 @@ EOF
|
||||
"turn-key:/var/lib/livekit/turn.key"
|
||||
];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
|
||||
# 5349/TCP (TURN/TLS) is deliberately absent — see livekit-turn-setup. RTC
|
||||
# media uses the single UDP mux (7882); the 30000-40000 range is gone so
|
||||
# media is no longer spread across 10000 ports. The TURN relay allocation
|
||||
# range (40000-40099) is kept separate from the media mux.
|
||||
networking.firewall.allowedTCPPorts = [ 7881 ];
|
||||
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
|
||||
networking.firewall.allowedUDPPortRanges = [
|
||||
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range
|
||||
{ from = 40000; to = 40099; } # LiveKit embedded TURN relay allocation range
|
||||
];
|
||||
|
||||
####### JWT SERVICE RUNTIME CONFIG #######
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.features.mempool {
|
||||
|
||||
services.mempool = {
|
||||
enable = true;
|
||||
frontend.enable = true;
|
||||
};
|
||||
|
||||
services.mysql.package = lib.mkForce pkgs.mariadb;
|
||||
|
||||
nix-bitcoin.onionServices.mempool-frontend.enable = true;
|
||||
|
||||
}
|
||||
@@ -28,14 +28,11 @@
|
||||
./wordpress.nix
|
||||
./nextcloud.nix
|
||||
./vaultwarden.nix
|
||||
./bitcoinecosystem.nix
|
||||
./wallet-autoconnect.nix
|
||||
|
||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||
./haven.nix
|
||||
./nwc-wallets.nix
|
||||
./element-calling.nix
|
||||
./mempool.nix
|
||||
./rdp.nix
|
||||
./sshd.nix
|
||||
];
|
||||
|
||||
@@ -1,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; }
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go
|
||||
index bad532f..ba35b7b 100644
|
||||
--- a/lnclient/lnd/lnd.go
|
||||
+++ b/lnclient/lnd/lnd.go
|
||||
@@ -708,7 +708,7 @@ func (svc *LNDService) MakeInvoice(ctx context.Context, amountMsat int64, descri
|
||||
DescriptionHash: descriptionHashBytes,
|
||||
Expiry: expiry,
|
||||
RouteHints: hints,
|
||||
- Private: !hasPublicChannels, // use private channel hints in the invoice
|
||||
+ Private: true, // always include private channel hints in the invoice
|
||||
}
|
||||
|
||||
resp, err := svc.client.AddInvoice(ctx, addInvoiceRequest)
|
||||
@@ -1,71 +0,0 @@
|
||||
diff --git a/api/models.go b/api/models.go
|
||||
index 28bd117..dceb79a 100644
|
||||
--- a/api/models.go
|
||||
+++ b/api/models.go
|
||||
@@ -46,7 +46,7 @@ type API interface {
|
||||
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error)
|
||||
ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error)
|
||||
SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, fromAppId *uint) (*SendPaymentResponse, error)
|
||||
- CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error)
|
||||
+ CreateInvoice(ctx context.Context, amountMsat uint64, description string, appId *uint) (*MakeInvoiceResponse, error)
|
||||
LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error)
|
||||
SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error
|
||||
RequestMempoolApi(ctx context.Context, endpoint string) (interface{}, error)
|
||||
@@ -586,6 +586,7 @@ type MakeInvoiceRequest struct {
|
||||
AmountSat *uint64 `json:"amountSat"`
|
||||
AmountMsat *uint64 `json:"amountMsat"`
|
||||
Description string `json:"description"`
|
||||
+ AppId *uint `json:"appId"`
|
||||
}
|
||||
|
||||
type ResetRouterRequest struct {
|
||||
diff --git a/api/transactions.go b/api/transactions.go
|
||||
index aaf9380..3f9850a 100644
|
||||
--- a/api/transactions.go
|
||||
+++ b/api/transactions.go
|
||||
@@ -12,12 +12,12 @@ import (
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
-func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error) {
|
||||
+func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string, appId *uint) (*MakeInvoiceResponse, error) {
|
||||
lnClient := api.svc.GetLNClient()
|
||||
if lnClient == nil {
|
||||
return nil, ErrLNClientNotStarted
|
||||
}
|
||||
- transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, nil, nil, nil)
|
||||
+ transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, appId, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
diff --git a/http/http_service.go b/http/http_service.go
|
||||
index bf650b9..cf61250 100644
|
||||
--- a/http/http_service.go
|
||||
+++ b/http/http_service.go
|
||||
@@ -687,7 +687,12 @@ func (httpSvc *HttpService) makeInvoiceHandler(c echo.Context) error {
|
||||
amountMsat = *resolvedAmountMsat
|
||||
}
|
||||
|
||||
- invoice, err := httpSvc.api.CreateInvoice(c.Request().Context(), amountMsat, makeInvoiceRequest.Description)
|
||||
+ invoice, err := httpSvc.api.CreateInvoice(
|
||||
+ c.Request().Context(),
|
||||
+ amountMsat,
|
||||
+ makeInvoiceRequest.Description,
|
||||
+ makeInvoiceRequest.AppId,
|
||||
+ )
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||
diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go
|
||||
index d25259b..c9fe4e8 100644
|
||||
--- a/wails/wails_handlers.go
|
||||
+++ b/wails/wails_handlers.go
|
||||
@@ -600,7 +600,7 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
|
||||
if resolvedAmountMsat != nil {
|
||||
amountMsat = *resolvedAmountMsat
|
||||
}
|
||||
- invoice, err := app.api.CreateInvoice(ctx, amountMsat, makeInvoiceRequest.Description)
|
||||
+ invoice, err := app.api.CreateInvoice(ctx, amountMsat, makeInvoiceRequest.Description, nil)
|
||||
if err != nil {
|
||||
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
diff --git a/cmd/http/main.go b/cmd/http/main.go
|
||||
index ed31f96..4d31212 100644
|
||||
--- a/cmd/http/main.go
|
||||
+++ b/cmd/http/main.go
|
||||
@@ -2,8 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
- "fmt"
|
||||
nethttp "net/http"
|
||||
+ "net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -51,8 +51,9 @@ func main() {
|
||||
httpSvc := http.NewHttpService(svc, svc.GetEventPublisher())
|
||||
httpSvc.RegisterSharedRoutes(e)
|
||||
//start Echo server
|
||||
+ bindAddress := net.JoinHostPort(svc.GetConfig().GetEnv().Host, svc.GetConfig().GetEnv().Port)
|
||||
go func() {
|
||||
- if err := e.Start(fmt.Sprintf(":%v", svc.GetConfig().GetEnv().Port)); err != nil && err != nethttp.ErrServerClosed {
|
||||
+ if err := e.Start(bindAddress); err != nil && err != nethttp.ErrServerClosed {
|
||||
logger.Logger.WithError(err).Error("echo server failed to start")
|
||||
cancel()
|
||||
}
|
||||
diff --git a/config/models.go b/config/models.go
|
||||
index 0fb8870..dc11afd 100644
|
||||
--- a/config/models.go
|
||||
+++ b/config/models.go
|
||||
@@ -24,6 +24,7 @@ type AppConfig struct {
|
||||
LNDAddress string `envconfig:"LND_ADDRESS"`
|
||||
LNDCertFile string `envconfig:"LND_CERT_FILE"`
|
||||
LNDMacaroonFile string `envconfig:"LND_MACAROON_FILE"`
|
||||
+ Host string `envconfig:"HOST" default:"127.0.0.1"`
|
||||
Workdir string `envconfig:"WORK_DIR"`
|
||||
Port string `envconfig:"PORT" default:"8080"`
|
||||
DatabaseUri string `envconfig:"DATABASE_URI" default:"nwc.db"`
|
||||
@@ -1,79 +0,0 @@
|
||||
# Vendored from fort-nix/nix-bitcoin commit 360e30fee.
|
||||
# This local copy does not fetch or import nix-bitcoin.
|
||||
# This is a modified version of
|
||||
# https://github.com/NixOS/nixpkgs/pull/128749
|
||||
|
||||
{ lib, stdenvNoCC, makeWrapper, nodejs, cacert }:
|
||||
|
||||
{ src
|
||||
, hash ? ""
|
||||
, runScripts ? false
|
||||
, preferLocalBuild ? true
|
||||
, npmFlags ? ""
|
||||
, ...
|
||||
} @ args:
|
||||
stdenvNoCC.mkDerivation ({
|
||||
inherit src preferLocalBuild;
|
||||
|
||||
name = "${src.name}-node_modules";
|
||||
nativeBuildInputs = [
|
||||
makeWrapper
|
||||
(if args ? nodejs then args.nodejs else nodejs)
|
||||
];
|
||||
|
||||
outputHashMode = "recursive";
|
||||
|
||||
impureEnvVars = lib.fetchers.proxyImpureEnvVars;
|
||||
|
||||
phases = "unpackPhase patchPhase buildPhase installPhase";
|
||||
|
||||
# npm doesn't support var `SSL_CERT_FILE`.
|
||||
NODE_EXTRA_CA_CERTS = "${cacert}/etc/ssl/certs/ca-bundle.crt";
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
if [[ ! -f package.json ]]; then
|
||||
echo "Error: file `package.json` doesn't exist"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f package-lock.json ]]; then
|
||||
echo "Error: file `package-lock.json` doesn't exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export SOURCE_DATE_EPOCH=1
|
||||
export npm_config_cache=/tmp
|
||||
NPM_FLAGS="--omit=dev --omit=optional --no-update-notifier $npmFlags"
|
||||
# Scripts may result in non-deterministic behavior.
|
||||
# Some packages (e.g., Puppeteer) use postinstall scripts to download extra data.
|
||||
if [[ ! $runScripts ]]; then
|
||||
NPM_FLAGS+=" --ignore-scripts"
|
||||
fi
|
||||
|
||||
echo "Running npm ci $NPM_FLAGS"
|
||||
npm ci $NPM_FLAGS
|
||||
|
||||
cp package.json \
|
||||
package-lock.json node_modules/
|
||||
rm -f node_modules/.package-lock.json
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib
|
||||
cp -r node_modules $out/lib
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
} // (
|
||||
if hash == "" then {
|
||||
outputHashAlgo = "sha256";
|
||||
outputHash = "";
|
||||
} else {
|
||||
outputHash = hash;
|
||||
}
|
||||
) // (builtins.removeAttrs args [ "hash" ]))
|
||||
@@ -1,41 +0,0 @@
|
||||
From e4b3ebaf0451c1bddbd7dcf8527c296938ebb607 Mon Sep 17 00:00:00 2001
|
||||
From: Erik Arvstedt <erik.arvstedt@gmail.com>
|
||||
Date: Sun, 1 Jun 2025 11:17:22 +0200
|
||||
Subject: [PATCH] allow disabling mining pool fetching in offline environments
|
||||
|
||||
Previously, Mempool strictly required fetching mining pool data from
|
||||
Github and failed when this was not possible, e.g. in offline
|
||||
environments.
|
||||
|
||||
This patch allows disabling pool fetching.
|
||||
When disabled, empty pool data is inserted into the DB, which
|
||||
effectively turns off block pool classification.
|
||||
---
|
||||
backend/src/tasks/pools-updater.ts | 5 ++++-
|
||||
1 file changed, 4 insertions(+), 1 deletion(-)
|
||||
|
||||
diff --git a/backend/src/tasks/pools-updater.ts b/backend/src/tasks/pools-updater.ts
|
||||
index 6b0520dfc..a74259b95 100644
|
||||
--- a/backend/src/tasks/pools-updater.ts
|
||||
+++ b/backend/src/tasks/pools-updater.ts
|
||||
@@ -75,7 +75,7 @@ class PoolsUpdater {
|
||||
} else {
|
||||
logger.warn(`pools-v2.json is outdated, fetching latest from ${this.poolsUrl} over ${network}`, this.tag);
|
||||
}
|
||||
- const poolsJson = await this.query(this.poolsUrl);
|
||||
+ const poolsJson = (githubSha == "disable-pool-fetching") ? [] : await this.query(this.poolsUrl);
|
||||
if (poolsJson === undefined) {
|
||||
return;
|
||||
}
|
||||
@@ -136,6 +136,9 @@ class PoolsUpdater {
|
||||
* Fetch our latest pools-v2.json sha from github
|
||||
*/
|
||||
private async fetchPoolsSha(): Promise<string | null> {
|
||||
+ if (this.poolsUrl == "disable-pool-fetching") {
|
||||
+ return "disable-pool-fetching";
|
||||
+ }
|
||||
const response = await this.query(this.treeUrl);
|
||||
|
||||
if (response !== undefined) {
|
||||
--
|
||||
2.47.2
|
||||
@@ -1,203 +0,0 @@
|
||||
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
||||
# This local copy does not fetch or import nix-bitcoin.
|
||||
{ lib
|
||||
, stdenvNoCC
|
||||
, nodejs_22
|
||||
, nodejs-slim_22
|
||||
, fetchFromGitHub
|
||||
, fetchNodeModules
|
||||
, runCommand
|
||||
, makeWrapper
|
||||
, curl
|
||||
, cacert
|
||||
, rsync
|
||||
# for rust-gbt (backend module)
|
||||
, cargo
|
||||
, rustc
|
||||
, rustPlatform
|
||||
, napi-rs-cli
|
||||
}:
|
||||
rec {
|
||||
nodejs = nodejs_22;
|
||||
nodejsRuntime = nodejs-slim_22;
|
||||
|
||||
version = "3.2.1";
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "mempool";
|
||||
repo = "mempool";
|
||||
tag = "v${version}";
|
||||
hash = "sha256-O2XPD1/BXQnzuOP/vMVyRfmFZEgjA85r+PShWne0vqU=";
|
||||
};
|
||||
|
||||
nodeModules = {
|
||||
frontend = fetchNodeModules {
|
||||
inherit src nodejs;
|
||||
sourceRoot = "source/frontend";
|
||||
hash = "sha256-+jfgsAkDdYvgso8uSHaBj/sQL3fC/ABQWzVTXfdZcU0=";
|
||||
};
|
||||
backend = fetchNodeModules {
|
||||
inherit src nodejs;
|
||||
sourceRoot = "source/backend";
|
||||
hash = "sha256-y5l2SYZYK9SKSy6g0+mtTWD6JFkkdQHHBboECpEvWZ4=";
|
||||
};
|
||||
};
|
||||
|
||||
frontendAssets = fetchFiles {
|
||||
name = "mempool-frontend-assets";
|
||||
hash = "sha256-r6GfOY8Pdh15o2OQMk8syfvWMV6WMCReToAEkQm7tqQ=";
|
||||
fetcher = ./frontend-assets-fetch.sh;
|
||||
};
|
||||
|
||||
mempool-backend = mkDerivationMempool {
|
||||
pname = "mempool-backend";
|
||||
|
||||
patches = [ ./0001-allow-disabling-mining-pool-fetching.patch ];
|
||||
|
||||
buildPhase = ''
|
||||
cd backend
|
||||
${sync} --chmod=+w ${nodeModules.backend}/lib/node_modules .
|
||||
patchShebangs node_modules
|
||||
|
||||
${sync} ${mempool-rust-gbt}/ rust-gbt
|
||||
npm run package
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/lib/mempool-backend
|
||||
${sync} package/ $out/lib/mempool-backend
|
||||
|
||||
makeWrapper ${nodejsRuntime}/bin/node $out/bin/mempool-backend \
|
||||
--add-flags $out/lib/mempool-backend/index.js
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
inherit nodejs nodejsRuntime;
|
||||
nodeModules = nodeModules.backend;
|
||||
};
|
||||
};
|
||||
|
||||
mempool-frontend = mkFrontend {};
|
||||
|
||||
# Argument `config` (type: attrset) defines the mempool frontend config.
|
||||
# If `{}`, the default config is used.
|
||||
# See here for available options:
|
||||
# https://github.com/mempool/mempool/blob/master/frontend/src/app/services/state.service.ts
|
||||
# (`interface Env` and `defaultEnv`)
|
||||
mkFrontend = config: mkDerivationMempool {
|
||||
pname = "mempool-frontend";
|
||||
|
||||
buildPhase = ''
|
||||
cd frontend
|
||||
|
||||
${sync} --chmod=+w ${nodeModules.frontend}/lib/node_modules .
|
||||
patchShebangs node_modules
|
||||
|
||||
# sync-assets.js is called during `npm run build` and downloads assets from the
|
||||
# internet. Disable this script and instead add the assets manually after building.
|
||||
: > sync-assets.js
|
||||
|
||||
${lib.optionalString (config != {}) ''
|
||||
ln -s ${builtins.toFile "mempool-frontend-config" (builtins.toJSON config)} mempool-frontend-config.json
|
||||
''}
|
||||
|
||||
npm run build
|
||||
|
||||
# Add assets that would otherwise be downloaded by sync-assets.js
|
||||
${sync} ${frontendAssets}/ dist/mempool/browser/resources
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
${sync} dist/mempool/browser/ $out
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
passthru = {
|
||||
withConfig = mkFrontend;
|
||||
assets = frontendAssets;
|
||||
nodeModules = nodeModules.frontend;
|
||||
};
|
||||
};
|
||||
|
||||
mempool-rust-gbt = stdenvNoCC.mkDerivation rec {
|
||||
pname = "mempool-rust-gbt";
|
||||
inherit version src meta;
|
||||
|
||||
sourceRoot = "source/rust/gbt";
|
||||
|
||||
nativeBuildInputs = [
|
||||
rustPlatform.cargoSetupHook
|
||||
cargo
|
||||
rustc
|
||||
napi-rs-cli
|
||||
];
|
||||
|
||||
cargoDeps = rustPlatform.fetchCargoVendor {
|
||||
inherit src;
|
||||
name = "${pname}-${version}";
|
||||
inherit sourceRoot;
|
||||
hash = "sha256-eox/K3ipjAqNyFt87lZnxaU/okQLF/KIhqXrX86n+qw=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
# napi doesn't accept an absolute path as dest dir, so we can't directly write to $out
|
||||
napi build --platform --release --strip out
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mv out $out
|
||||
cp package.json $out
|
||||
'';
|
||||
|
||||
passthru = { inherit cargoDeps; };
|
||||
};
|
||||
|
||||
mempool-nginx-conf = runCommand "mempool-nginx-conf" {} ''
|
||||
${sync} --chmod=u+w ${./nginx-conf}/ $out
|
||||
${sync} ${src}/production/nginx/http-language.conf $out
|
||||
'';
|
||||
|
||||
sync = "${rsync}/bin/rsync -a --inplace";
|
||||
|
||||
mkDerivationMempool = args: stdenvNoCC.mkDerivation ({
|
||||
inherit version src meta;
|
||||
|
||||
nativeBuildInputs = [
|
||||
makeWrapper
|
||||
nodejs
|
||||
rsync
|
||||
];
|
||||
|
||||
phases = "unpackPhase patchPhase buildPhase installPhase";
|
||||
} // args);
|
||||
|
||||
fetchFiles = { name, hash, fetcher }: stdenvNoCC.mkDerivation {
|
||||
inherit name;
|
||||
outputHashMode = "recursive";
|
||||
outputHashAlgo = "sha256";
|
||||
outputHash = hash;
|
||||
nativeBuildInputs = [ curl cacert ];
|
||||
buildCommand = ''
|
||||
mkdir $out
|
||||
cd $out
|
||||
${builtins.readFile fetcher}
|
||||
'';
|
||||
};
|
||||
|
||||
meta = with lib; {
|
||||
description = "Bitcoin blockchain and mempool explorer";
|
||||
homepage = "https://github.com/mempool/mempool/";
|
||||
license = licenses.agpl3Plus;
|
||||
maintainers = with maintainers; [ erikarvstedt ];
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Fetch hash-locked versions of assets that are dynamically fetched via
|
||||
# https://github.com/mempool/mempool/blob/master/frontend/sync-assets.js
|
||||
# when running `npm run build` in the frontend.
|
||||
#
|
||||
# This file is updated by ./frontend-assets-update.sh
|
||||
|
||||
declare -A revs=(
|
||||
["mempool/mining-pool-logos"]=53972ebbd08373cf4910cbb3e6421a1f3bba4563
|
||||
)
|
||||
|
||||
fetchFile() {
|
||||
repo=$1
|
||||
file=$2
|
||||
rev=${revs["$repo"]}
|
||||
curl -fsS "https://raw.githubusercontent.com/$repo/$rev/$file"
|
||||
}
|
||||
|
||||
fetchRepo() {
|
||||
repo=$1
|
||||
rev=${revs["$repo"]}
|
||||
curl -fsSL "https://github.com/$repo/archive/$rev.tar.gz"
|
||||
}
|
||||
|
||||
mkdir mining-pools
|
||||
fetchRepo "mempool/mining-pool-logos" | tar xz --strip-components=1 -C mining-pools
|
||||
@@ -1,47 +0,0 @@
|
||||
# Settings adapted from
|
||||
# https://github.com/mempool/mempool/blob/v3.2.1/production/nginx/server-common.conf
|
||||
|
||||
# see order of nginx location rules
|
||||
# https://stackoverflow.com/questions/5238377/nginx-location-priority
|
||||
|
||||
# for exact / requests, redirect based on $lang
|
||||
# cache redirect for 5 minutes
|
||||
location = / {
|
||||
if ($lang != '') {
|
||||
return 302 $scheme://$host/$lang/;
|
||||
}
|
||||
try_files /en-US/index.html =404;
|
||||
expires 5m;
|
||||
}
|
||||
|
||||
# cache /<lang>/main.f40e91d908a068a2.js forever since they never change
|
||||
location ~ ^/([a-z][a-z])/(.+\..+\.(js|css))$ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
}
|
||||
# cache everything else for 5 minutes
|
||||
location ~ ^/([a-z][a-z])$ {
|
||||
try_files $uri /$1/index.html /en-US/index.html =404;
|
||||
expires 5m;
|
||||
}
|
||||
location ~ ^/([a-z][a-z])/ {
|
||||
try_files $uri /$1/index.html /en-US/index.html =404;
|
||||
expires 5m;
|
||||
}
|
||||
|
||||
# cache /resources/** for 1 week since they don't change often
|
||||
location /resources {
|
||||
try_files $uri /en-US/index.html;
|
||||
expires 1w;
|
||||
}
|
||||
# cache /main.f40e91d908a068a2.js forever since they never change
|
||||
location ~* ^/.+\..+\.(js|css)$ {
|
||||
try_files /$lang/$uri /en-US/$uri =404;
|
||||
expires 1y;
|
||||
}
|
||||
# catch-all for all URLs i.e. /address/foo /tx/foo /block/000
|
||||
# cache 5 minutes since they change frequently
|
||||
location / {
|
||||
try_files /$lang/$uri $uri /en-US/$uri /en-US/index.html =404;
|
||||
expires 5m;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
||||
# This local copy does not fetch or import nix-bitcoin.
|
||||
{ lib
|
||||
, stdenvNoCC
|
||||
, nodejs_22
|
||||
, nodejs-slim_22
|
||||
, fetchNodeModules
|
||||
, fetchurl
|
||||
, makeWrapper
|
||||
}:
|
||||
let self = stdenvNoCC.mkDerivation {
|
||||
pname = "rtl";
|
||||
version = "0.15.10";
|
||||
|
||||
src = fetchurl {
|
||||
url = "https://github.com/Ride-The-Lightning/RTL/archive/refs/tags/v${self.version}.tar.gz";
|
||||
hash = "sha256-r5riYV2FN0OKi0mwj9I1jBeeU1LOv2HVB6CEovPlUuY=";
|
||||
};
|
||||
|
||||
passthru = {
|
||||
nodejs = nodejs_22;
|
||||
nodejsRuntime = nodejs-slim_22;
|
||||
|
||||
nodeModules = fetchNodeModules {
|
||||
inherit (self) src nodejs;
|
||||
# TODO-EXTERNAL: Remove `npmFlags` when no longer required
|
||||
# See: https://github.com/Ride-The-Lightning/RTL/issues/1182
|
||||
npmFlags = "--legacy-peer-deps";
|
||||
hash = "sha256-NKiWcjqYcHBVIB+vbF3aKXLe2fJRmh/quu8obztP3TA=";
|
||||
};
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
phases = "unpackPhase patchPhase installPhase";
|
||||
|
||||
# `src` already contains the precompiled frontend and backend.
|
||||
# Copy all files required for packaging, like in
|
||||
# https://github.com/Ride-The-Lightning/RTL/blob/master/dockerfiles/Dockerfile
|
||||
installPhase = ''
|
||||
dest=$out/lib/node_modules/rtl
|
||||
mkdir -p $dest
|
||||
cp -r \
|
||||
rtl.js \
|
||||
package.json \
|
||||
frontend \
|
||||
backend \
|
||||
${self.nodeModules}/lib/node_modules \
|
||||
$dest
|
||||
|
||||
makeWrapper ${self.nodejsRuntime}/bin/node "$out/bin/rtl" \
|
||||
--add-flags "$dest/rtl.js"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "A web interface for LND, c-lightning and Eclair";
|
||||
homepage = "https://github.com/Ride-The-Lightning/RTL";
|
||||
license = licenses.mit;
|
||||
maintainers = with maintainers; [ nixbitcoin erikarvstedt ];
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
}; in self
|
||||
@@ -1,146 +0,0 @@
|
||||
{ nixpkgs, overlay-stable, system ? "x86_64-linux" }:
|
||||
|
||||
let
|
||||
lib = nixpkgs.lib;
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ overlay-stable ];
|
||||
};
|
||||
|
||||
normalize = s:
|
||||
lib.replaceStrings [ "\n" "\\" " " ] [ " " "" " " ] s;
|
||||
|
||||
extractAfter = prefix: str:
|
||||
let
|
||||
match = builtins.match ".*${prefix} ([^ ]+).*" (normalize str);
|
||||
in
|
||||
if match == null then
|
||||
throw "Unable to extract ${prefix} from: ${normalize str}"
|
||||
else
|
||||
builtins.head match;
|
||||
|
||||
extractFlagValue = flag: str:
|
||||
let
|
||||
match = builtins.match ".*${flag}=([^ ]+).*" (normalize str);
|
||||
in
|
||||
if match == null then
|
||||
throw "Unable to extract ${flag} from: ${normalize str}"
|
||||
else
|
||||
builtins.head match;
|
||||
|
||||
config = (lib.nixosSystem {
|
||||
inherit system;
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = system; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
../modules/bitcoin
|
||||
{
|
||||
nix-bitcoin.generateSecrets = true;
|
||||
nix-bitcoin.secretsDir = "/build/secrets";
|
||||
|
||||
services.btcpayserver.enable = true;
|
||||
services.btcpayserver.lightningBackend = "lnd";
|
||||
services.nbxplorer.dataDir = "/build/nbxplorer";
|
||||
services.btcpayserver.dataDir = "/build/btcpayserver";
|
||||
services.lnd.dataDir = "/build/lnd";
|
||||
services.bitcoind.dataDir = "/build/bitcoind";
|
||||
}
|
||||
];
|
||||
}).config;
|
||||
|
||||
nbxplorerPreStart = config.systemd.services.nbxplorer.preStart;
|
||||
bitcoindPreStart = config.systemd.services.bitcoind.preStart;
|
||||
btcpayExecStart = config.systemd.services.btcpayserver.serviceConfig.ExecStart;
|
||||
btcpayWorkingDir = config.systemd.services.btcpayserver.serviceConfig.WorkingDirectory;
|
||||
|
||||
nbxplorerConfigPath = extractAfter "install -m 600" nbxplorerPreStart;
|
||||
btcpayConfigPath = extractFlagValue "--conf" btcpayExecStart;
|
||||
|
||||
nbxplorerConfig = builtins.readFile nbxplorerConfigPath;
|
||||
btcpayConfig = builtins.readFile btcpayConfigPath;
|
||||
in
|
||||
assert lib.assertMsg
|
||||
(config.users.users.${config.services.btcpayserver.user}.home == config.services.btcpayserver.dataDir)
|
||||
"btcpayserver user home must match btcpayserver dataDir";
|
||||
assert lib.assertMsg
|
||||
(config.users.users.${config.services.nbxplorer.user}.home == config.services.nbxplorer.dataDir)
|
||||
"nbxplorer user home must match nbxplorer dataDir";
|
||||
assert lib.assertMsg
|
||||
(config.nix-bitcoin.secrets.bitcoin-HMAC-btcpayserver.user == config.services.bitcoind.user)
|
||||
"bitcoin-HMAC-btcpayserver must be owned by bitcoind";
|
||||
assert lib.assertMsg
|
||||
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.user == config.services.bitcoind.user)
|
||||
"bitcoin-rpcpassword-btcpayserver must be owned by bitcoind";
|
||||
assert lib.assertMsg
|
||||
(config.nix-bitcoin.secrets.bitcoin-rpcpassword-btcpayserver.group == config.services.nbxplorer.group)
|
||||
"bitcoin-rpcpassword-btcpayserver must be group-readable by nbxplorer";
|
||||
assert lib.assertMsg
|
||||
(!(lib.elem config.services.nbxplorer.group config.users.users.${config.services.btcpayserver.user}.extraGroups))
|
||||
"btcpayserver must not receive the nbxplorer group";
|
||||
assert lib.assertMsg
|
||||
(lib.elem "nix-bitcoin-secrets.target" config.systemd.services.nbxplorer.after)
|
||||
"nbxplorer must wait for nix-bitcoin-secrets.target";
|
||||
assert lib.assertMsg
|
||||
(config.systemd.services.nbxplorer.serviceConfig.MemoryDenyWriteExecute == false)
|
||||
"nbxplorer needs MemoryDenyWriteExecute = false";
|
||||
assert lib.assertMsg
|
||||
(config.systemd.services.btcpayserver.serviceConfig.MemoryDenyWriteExecute == false)
|
||||
"btcpayserver needs MemoryDenyWriteExecute = false";
|
||||
assert lib.assertMsg
|
||||
(lib.hasInfix "network=mainnet" nbxplorerConfig
|
||||
&& lib.hasInfix "btcrpcuser=btcpayserver" nbxplorerConfig
|
||||
&& lib.hasInfix "btcnodeendpoint=127.0.0.1:8335" nbxplorerConfig
|
||||
&& lib.hasInfix "bind=127.0.0.1" nbxplorerConfig
|
||||
&& lib.hasInfix "port=24444" nbxplorerConfig
|
||||
&& lib.hasInfix "postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" nbxplorerConfig)
|
||||
"nbxplorer base config must contain the expected non-secret settings";
|
||||
assert lib.assertMsg
|
||||
(lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
|
||||
&& lib.hasInfix "btcexplorercookiefile=/build/nbxplorer/Main/.cookie" btcpayConfig)
|
||||
"btcpayserver config must contain btcexplorerurl and btcexplorercookiefile";
|
||||
assert lib.assertMsg
|
||||
(lib.hasSuffix "/lib/btcpayserver" btcpayWorkingDir)
|
||||
"btcpayserver WorkingDirectory must end with /lib/btcpayserver";
|
||||
assert lib.assertMsg
|
||||
(!lib.hasInfix "/build/btcpayserver/settings.config" btcpayExecStart
|
||||
&& lib.hasInfix "--datadir='/build/btcpayserver'" btcpayExecStart)
|
||||
"btcpayserver must use a deterministic config file plus --datadir";
|
||||
assert lib.assertMsg
|
||||
(lib.hasInfix "network=mainnet" btcpayConfig
|
||||
&& lib.hasInfix "bind=127.0.0.1" btcpayConfig
|
||||
&& lib.hasInfix "port=23000" btcpayConfig
|
||||
&& lib.hasInfix "btcexplorerurl=http://127.0.0.1:24444/" btcpayConfig
|
||||
&& lib.hasInfix "explorer.postgres=User ID=nbxplorer;Host=/run/postgresql;Database=nbxplorer" btcpayConfig
|
||||
&& lib.hasInfix "postgres=User ID=btcpayserver;Host=/run/postgresql;Database=btcpayserver" btcpayConfig
|
||||
&& lib.hasInfix "btclightning=type=lnd-rest;server=https://127.0.0.1:8080/;macaroonfilepath=/run/lnd/btcpayserver.macaroon;certfilepath=/build/secrets/lnd-cert" btcpayConfig)
|
||||
"btcpayserver config must preserve BTCPay, NBXplorer, database, and LND settings";
|
||||
assert lib.assertMsg
|
||||
(lib.hasInfix "readValidatedRpcHmac()" bitcoindPreStart
|
||||
&& lib.hasInfix ''if [[ ! -e "$hmacFile" ]]; then'' bitcoindPreStart
|
||||
&& lib.hasInfix ''if [[ ! -r "$hmacFile" ]]; then'' bitcoindPreStart
|
||||
&& lib.hasInfix ''if [[ -z "$hmacPayload" ]]; then'' bitcoindPreStart
|
||||
&& lib.hasInfix ''^[[:xdigit:]]+\$[[:xdigit:]]+$'' bitcoindPreStart
|
||||
&& lib.hasInfix ''Bitcoin RPC HMAC file has invalid format'' bitcoindPreStart
|
||||
&& lib.hasInfix ''hmacPayload="$(readValidatedRpcHmac '/build/secrets/bitcoin-HMAC-btcpayserver')" || exit 1'' bitcoindPreStart)
|
||||
"bitcoind preStart must validate missing, unreadable, empty, and malformed HMAC files";
|
||||
pkgs.runCommand "bitcoin-btcpay-hardening" {} ''
|
||||
mkdir -p /build/secrets /build/nbxplorer
|
||||
|
||||
printf '%s' 'first-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
|
||||
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
|
||||
|
||||
test "$(stat -c '%a' /build/nbxplorer/settings.config)" = "600"
|
||||
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
|
||||
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
|
||||
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
|
||||
|
||||
printf '%s' 'rotated-password' > /build/secrets/bitcoin-rpcpassword-btcpayserver
|
||||
bash -euo pipefail -c ${lib.escapeShellArg nbxplorerPreStart}
|
||||
|
||||
test "$(grep -c '^btcrpcuser=' /build/nbxplorer/settings.config)" = "1"
|
||||
test "$(grep -c '^btcrpcpassword=' /build/nbxplorer/settings.config)" = "1"
|
||||
test "$(grep -c '^postgres=' /build/nbxplorer/settings.config)" = "1"
|
||||
! grep -q 'first-password' /build/nbxplorer/settings.config
|
||||
grep -q 'rotated-password' /build/nbxplorer/settings.config
|
||||
|
||||
touch "$out"
|
||||
''
|
||||
@@ -27,17 +27,12 @@ def _literal_assignment(source: str, name: str):
|
||||
|
||||
|
||||
class TestBitcoinTorGossipNixWiring(unittest.TestCase):
|
||||
def test_bitcoind_loopback_listener_is_always_enabled(self):
|
||||
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
||||
self.assertIn("listen = true;", ecosystem)
|
||||
self.assertIn("peerbloomfilters=1", ecosystem)
|
||||
|
||||
def test_gossip_is_opt_in(self):
|
||||
ecosystem = _read("modules", "bitcoinecosystem.nix")
|
||||
self.assertIn(
|
||||
"public = config.sovran_systemsOS.features.bitcoin-tor-gossip;",
|
||||
ecosystem,
|
||||
)
|
||||
def test_integration_layer_maps_tor_gossip_option(self):
|
||||
"""The integration layer must bridge sovran_systemsOS.features.bitcoin-tor-gossip
|
||||
to sovran-bitcoin.bitcoindTorGossip."""
|
||||
integration = _read("modules", "sovran-bitcoin-integration.nix")
|
||||
self.assertIn("bitcoindTorGossip", integration)
|
||||
self.assertIn("bitcoin-tor-gossip", integration)
|
||||
|
||||
def test_hub_option_and_evaluated_state_are_declared(self):
|
||||
roles = _read("modules", "core", "roles.nix")
|
||||
|
||||
@@ -333,33 +333,6 @@ class TestSshPubkeyValidation(unittest.TestCase):
|
||||
_validate_ssh_pubkey("ssh-ed25519")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LND macaroon command-line safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLndMacaroonCommandLineSafety(unittest.TestCase):
|
||||
"""The LND admin macaroon must never be exposed in curl's argv."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
path = os.path.join(_REPO_ROOT, "modules", "bitcoin", "lnd.nix")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
cls.lnd_module = f.read()
|
||||
|
||||
def test_admin_macaroon_not_interpolated_into_header_argument(self):
|
||||
self.assertNotIn(
|
||||
'-H "Grpc-Metadata-macaroon: $(',
|
||||
self.lnd_module,
|
||||
)
|
||||
|
||||
def test_admin_macaroon_header_is_passed_via_file_descriptor(self):
|
||||
self.assertIn("adminMacaroonHex=$(", self.lnd_module)
|
||||
self.assertIn(
|
||||
"""-H @<(printf 'Grpc-Metadata-macaroon: %s\\n' "$adminMacaroonHex")""",
|
||||
self.lnd_module,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-exempt paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Regression tests for the Hub Zeus Connect QR.
|
||||
|
||||
The LND-only rewrite of modules/bitcoin/lndconnect.nix shipped a wrapper
|
||||
that Zeus cannot use. These tests lock the contract the Hub QR depends on
|
||||
without needing lnd / tor / qrencode at test time.
|
||||
The Zeus Connect setup service (modules/wallet-autoconnect.nix) and the Hub QR
|
||||
encoding are tested here. The lndconnect wrapper itself is now part of the
|
||||
Sovran_Bitcoin flake — its contract tests live in that repository.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -17,41 +17,6 @@ def _read(relpath: str) -> str:
|
||||
return fh.read()
|
||||
|
||||
|
||||
class TestLndconnectWrapper(unittest.TestCase):
|
||||
"""The system `lndconnect` wrapper must emit a Zeus-scannable URI."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.src = _read("modules/bitcoin/lndconnect.nix")
|
||||
|
||||
def test_uses_official_lndconnect_flags(self):
|
||||
self.assertIn("--adminmacaroonpath=", self.src)
|
||||
self.assertIn("--configfile=/dev/null", self.src)
|
||||
self.assertIn("--nocert", self.src)
|
||||
self.assertIn("--tlscertpath=", self.src)
|
||||
|
||||
def test_does_not_pass_unknown_short_flags(self):
|
||||
# The broken rewrite called `lndconnect --cert … --macaroon …`.
|
||||
# Those flags do not exist; Zeus then never got a valid URI.
|
||||
self.assertIsNone(re.search(r"--cert=", self.src))
|
||||
self.assertIsNone(re.search(r"--macaroon=", self.src))
|
||||
|
||||
def test_uses_dedicated_lnd_rest_onion(self):
|
||||
self.assertIn("lnd-rest", self.src)
|
||||
self.assertIn('onionServices.lnd-rest', self.src)
|
||||
# Must not collide with the LND P2P onion named `lnd`.
|
||||
self.assertNotIn("onionServices.lnd =", self.src)
|
||||
self.assertNotIn('onionService = "${operatorName}/lnd"', self.src)
|
||||
|
||||
def test_reads_onion_from_onion_addresses_dir(self):
|
||||
self.assertIn("onionAddresses.dataDir", self.src)
|
||||
self.assertNotIn("/var/lib/tor/onion/${onionService}/hostname", self.src)
|
||||
|
||||
def test_omits_tls_cert_over_tor(self):
|
||||
# Onion host + embedded localhost cert = Zeus rejects the QR.
|
||||
self.assertIn('then "--nocert"', self.src)
|
||||
|
||||
|
||||
class TestZeusConnectSetup(unittest.TestCase):
|
||||
"""zeus-connect-setup must wait for the REST onion and validate the URI."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user