diff --git a/README.md b/README.md index 7b1c1fb..6564db1 100644 --- a/README.md +++ b/README.md @@ -1,152 +1,1047 @@
-Sovran Systems +Sovran Systems logo # Sovran_SystemsOS -`Base Development` · NixOS Flake · AGPL-3.0 +### Your Bitcoin life. Your keys. Your node. Your machine. -[Sovran Systems](https://sovransystems.com) +Sovran_SystemsOS is a free and open-source Bitcoin operating system built for +self-custody, private peer-to-peer exchange, independent verification, and +digital sovereignty. + +Every installation includes a private NixOS desktop with **Sparrow Wallet, +Bisq, and Bisq 2** ready to use. Move beyond custodial exchanges, use Bitcoin +without making a centralized platform the center of your financial life, and +grow into running your own Bitcoin and Lightning infrastructure when you are +ready. + +**Privacy. Sovereignty. Bitcoin.** + +[Visit the Website](https://sovransystems.com) · +[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS.iso) · +[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS.iso.sha256) · +[Build from Source](#build-from-source) + +
+ +
+ +Sovran_SystemsOS private Bitcoin desktop + +*Bitcoin sovereignty from the first boot.*
--- -## Table of Contents +## Bitcoin software should help you leave the middleman behind -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) +Bitcoin gives people the ability to hold and transfer value without asking a +bank, exchange, or custodian for permission. But that freedom depends on the +software and infrastructure you choose to use. + +Sovran_SystemsOS brings the essential tools together in one operating system: + +- **Hold your own keys** with Sparrow Wallet. +- **Buy and sell Bitcoin peer-to-peer** with Bisq and Bisq 2. +- **Use non-KYC Bitcoin tools** without depending on a custodial exchange account. +- **Verify your own Bitcoin** with a full node. +- **Connect your wallets to your node**, not a stranger's. +- **Use Lightning** with LND and Ride The Lightning. +- **Accept Bitcoin directly** with BTCPay Server. +- **Route the Bitcoin stack through Tor** for stronger network privacy. +- **Control everything from the Sovran Hub.** + +No custodian needs to hold your Bitcoin. No outside node needs to tell your +wallet what happened on the Bitcoin network. No third-party cloud needs to +control your data or services. + +> 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. --- -## What This Repo Is +## Bitcoin tools included from day one -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. +You do not need the largest computer or a fully synchronized Bitcoin node to +begin taking control of your Bitcoin. -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. +Every Sovran_SystemsOS mode includes the standard desktop Bitcoin applications: +**Sparrow Wallet, Bisq, and Bisq 2**. -## Architecture +### Sparrow Wallet -``` - ┌─────────────────────────┐ - │ flake.nix │ - │ inputs: nixpkgs, │ - │ nix-bitcoin, nixvim, │ - │ btc-clients, bip110 │ - └───────────┬─────────────┘ - │ 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. │ - └────────────────────┘ -``` +Sparrow is a privacy-focused desktop Bitcoin wallet built for transparent, +secure self-custody. -- **`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`. +Use Sparrow to: -## Module Catalog +- Create and manage Bitcoin wallets +- Connect signing devices and hardware wallets +- Build and inspect transactions +- Manage UTXOs and coin selection +- Use multisignature wallets +- Connect directly to your own Electrs server in Node mode -Defaults follow the import order in `modules/modules.nix`. Toggles live in `custom.nix` (the Hub writes them) and `role-state.nix`. +Your keys remain under your control. -| Module | Default | Purpose | +### Bisq + +Bisq is a decentralized peer-to-peer Bitcoin exchange. + +It allows buyers and sellers to trade directly without depositing their +Bitcoin with a centralized exchange. There is no central company holding user +funds and no conventional exchange account standing between participants. + +### Bisq 2 + +Bisq 2 is the next generation of the Bisq peer-to-peer trading network. It is +installed alongside Bisq so users can access both generations of the Bisq +ecosystem from the Sovran_SystemsOS desktop. + +Together, Sparrow, Bisq, and Bisq 2 provide a practical path to obtaining, +holding, and using Bitcoin without making a custodial exchange the center of +your financial life. + +--- + +## Three modes, one path toward Bitcoin sovereignty + +Every mode includes the same private NixOS and GNOME foundation, the Sovran +Hub, Sparrow Wallet, Bisq, and Bisq 2. + +What changes is how much Bitcoin and self-hosting infrastructure runs on the +machine. + +| Mode | Best for | Bitcoin capability | |---|---|---| -| `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 | -| `bip110.nix` | off | Bitcoin Knots BIP-110 | -| `element-calling.nix` | off | LiveKit + JWT for E2E calling | -| `mempool.nix` | off | Mempool.space dashboard | -| `bitcoin-core.nix` | off | Standalone bitcoind | -| `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 Bitcoin Knots, Electrs, LND, RTL, BTCPay Server, Tor, and wallet-to-node connections | +| **Server + Desktop** | Bitcoiners who also want to reclaim their communications, cloud, passwords, and web services | The complete Node stack plus 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 -## The Three Modes (internal reference) +Desktop mode is not a reduced or Bitcoin-free edition. -Selected by `role-state.nix`, resolved by `modules/core/role-logic.nix`. All three configurations are produced from this same flake. +It is a private everyday computer that includes: -| 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.). | +- Sparrow Wallet +- Bisq +- Bisq 2 +- A clean GNOME desktop +- Privacy-respecting everyday applications +- Tor +- The Sovran Hub -## Build & Deploy Reference +This gives people a lower-hardware path to Bitcoin self-custody and +peer-to-peer, non-KYC Bitcoin tools from the first boot. -Internal commands. Run from the flake root. +You can begin with Desktop today and move to your own node when your hardware, +storage, and needs are ready. -| 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` | +**Recommended hardware:** -## Networking & Reverse Proxy +- 64-bit Intel or AMD processor, approximately 2015 or newer +- 8 GB RAM +- 256 GB SSD +- Any broadband connection -- **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 +### Node — verify your own money -Facts about the defaults, straight from `configuration.nix` and the modules: +Node mode includes the private desktop and adds the full Bitcoin stack. -- **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`. +Instead of asking someone else's server about your wallet and transactions, +you operate the infrastructure that performs the verification. -## Backups & Recovery +The Node stack includes: -`services.rsnapshot` snapshots hourly and daily to `/run/media/Second_Drive/BTCEcoandBackup/NixOS_Snapshot_Backup`: +- **Bitcoin Knots** — independently verify the Bitcoin timechain and consensus rules +- **Electrs** — connect compatible wallets to your own node +- **LND** — operate a Lightning node +- **Ride The Lightning** — manage Lightning through a web interface +- **BTCPay Server** — accept Bitcoin payments without a payment processor +- **Sparrow Wallet** — connect your wallet to your own infrastructure +- **Bisq and Bisq 2** — trade Bitcoin peer-to-peer +- **Tor integration** — improve network privacy across the Bitcoin stack +- **Sovran Hub** — launch, monitor, and reach everything from one place -``` -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 node verifies. Your wallet connects to it. Your keys remain yours. + +**Recommended hardware:** + +- x86 Intel or AMD processor, approximately three years old or newer +- 16 GB RAM +- 500 GB NVMe SSD for the operating system +- 2 TB NVMe SSD for the Bitcoin timechain +- Unmetered broadband +- Approximately 200 Mbps download and 50 Mbps upload + +### Server + Desktop — sovereignty beyond money + +Server + Desktop includes the complete Bitcoin Node stack and extends the same +ownership model to the rest of your digital life. + +Run your own: + +- Private cloud +- Files, calendars, and contacts +- Encrypted communications +- Password vault +- Website +- Nostr relay +- Bitcoin payment infrastructure + +Bitcoin sovereignty is the foundation. Server + Desktop applies that principle +to your data, communications, identity, and services. + +**Recommended hardware:** + +- x86 Intel or AMD processor, approximately three years old or newer +- 32 GB RAM +- 500 GB NVMe SSD for the operating system +- 2 TB NVMe SSD for the Bitcoin timechain +- Unmetered broadband +- Approximately 200 Mbps download and 50 Mbps upload +- A domain for publicly accessible self-hosted services + +--- + +## Use Sovran_SystemsOS your way + +You do not have to replace the operating system on your current computer to +benefit from Sovran_SystemsOS. + +### Use it as your everyday computer + +Install Sovran_SystemsOS on a desktop, laptop, or mini PC and use its clean +GNOME desktop as your daily operating system. + +You receive a private desktop, the Sovran Hub, everyday applications, Bitcoin +software, and the tools included with your selected mode. + +### Use it as a private Bitcoin and home server + +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. From another device connected to the same network, open a web +browser and visit: + +```text +http://sovransystemsos.local ``` -The second drive is mounted by label (`BTCEcoandBackup`) with `nofail` so a missing drive doesn't block boot. +Sign in to the Sovran Hub and manage the system from your: + +- Windows computer +- Mac +- Linux computer +- Phone +- Tablet +- Any other device with a modern web browser + +Your Sovran_SystemsOS machine can operate as a private, headless Bitcoin and +self-hosting appliance. It can run without a monitor during everyday use while +you control it from the devices you already know. + +Your existing devices remain familiar. Sovran_SystemsOS provides the +independent infrastructure behind them. + +> **Local access:** `sovransystemsos.local` is intended for devices connected +> to the same local network as the Sovran_SystemsOS machine. The Hub is +> protected by authentication and is not automatically exposed to the public +> internet. + +--- + +## 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. + +Use the Hub directly from the Sovran_SystemsOS desktop, or open it from another +device at: + +```text +http://sovransystemsos.local +``` + +From one place, the Hub helps you: + +- Open and monitor your services +- See what is running +- Start and stop supported services +- Configure system features +- Manage service domains +- Reach your Bitcoin tools +- Manage your private cloud and communications +- Perform supported system operations +- Control Sovran_SystemsOS without everyday terminal commands + +Nothing needs to be installed on the device opening the Hub. You only need a +modern browser and access to the same local network. + +### 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 │ + └──────────────────────────┘ +``` + +Keep using the devices you already own. Sovran_SystemsOS becomes the private +Bitcoin and digital infrastructure behind them. + +--- + +## What is included? + +Depending on the selected mode and enabled features, Sovran_SystemsOS brings +together a growing collection of private, open-source tools. + +### Your money — Bitcoin sovereignty + +- Bitcoin Knots +- Electrs +- LND +- Ride The Lightning +- BTCPay Server +- Sparrow Wallet +- Bisq +- Bisq 2 +- Tor integration +- Automatic wallet-to-node connections +- Optional Mempool explorer + +Run your own Bitcoin infrastructure. Verify your own money. Trust no one. + +### Your voice — private communications + +- Matrix Synapse +- Element +- Optional Element calling +- Optional Haven Nostr relay + +Communicate without making Big Tech the owner of your identity or +conversations. + +### Your cloud — self-hosting and storage + +- Nextcloud +- Vaultwarden +- WordPress +- Caddy +- Private service domains +- Optional remote desktop + +Keep your files, passwords, calendar, contacts, website, and services on +hardware you control. + +### Your desktop + +- GNOME desktop +- Brave +- Firefox +- File management +- Calendar and contacts +- Email client +- Office applications +- System monitoring tools +- Administration utilities + +Not every service is enabled in every mode. The Sovran Hub presents and +manages the features available on your system. + +--- + +## What makes Sovran_SystemsOS different? + +There are Linux distributions that can run a Bitcoin wallet. There are node +projects that can run Bitcoin services. There are self-hosting projects that +can replace individual cloud applications. + +Sovran_SystemsOS brings those worlds together. + +### Bitcoin from the first boot + +Sparrow, Bisq, and Bisq 2 are not optional suggestions buried in +documentation. They are installed as part of the operating system and +available from the desktop. + +### A complete path + +Begin with a private desktop and self-custody. Grow into peer-to-peer Bitcoin +exchange, your own node, Lightning, Bitcoin payments, and complete +self-hosting. + +### One control center + +The Sovran Hub brings the Bitcoin stack, private services, credentials, system +status, and supported configuration into one interface. + +### Your hardware + +The system runs on a computer you control. Your node, wallets, data, services, +and system configuration do not depend on a Sovran Systems cloud account. + +### Reproducible foundation + +Sovran_SystemsOS is built declaratively with NixOS and Nix flakes. Its inputs +are pinned, its configuration can be audited, and the system can be rebuilt +from source. + +--- + +# Download and install it yourself + +Sovran_SystemsOS is free and open source. You can download the installer, +verify it, write it to a USB drive, and install it yourself. + +You remain in control from the very first step. + +## What is an ISO? + +An ISO is a complete installation image containing the operating system and +the files required to boot the Sovran_SystemsOS installer. + +The ISO is not copied to a USB drive like an ordinary document. It must be +written to the USB drive with an imaging application such as Balena Etcher. + +## What you need + +Before beginning, you will need: + +- A compatible 64-bit computer +- A USB drive that can be erased +- Another computer for downloading and preparing the installer +- An internet connection +- A USB imaging application such as Balena Etcher +- A backup of anything important on the destination computer + +> **Important:** Installing an operating system can erase the selected +> destination drive. Back up important files and review every disk selection +> carefully before continuing. + +--- + +## Step 1: Download the ISO + +Download the Sovran_SystemsOS installer: + +### [Download Sovran_SystemsOS.iso](https://downloads.sovransystems.com/Sovran_SystemsOS.iso) + +Depending on your browser and internet connection, the download may take some +time. Do not rename or modify the file before verifying it. + +The downloaded file should be named: + +```text +Sovran_SystemsOS.iso +``` + +--- + +## Step 2: Download the checksum + +Download the matching SHA-256 checksum: + +### [Download Sovran_SystemsOS.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS.iso.sha256) + +A checksum is a digital fingerprint of a file. + +Verifying the checksum confirms that: + +- The complete ISO downloaded successfully +- The file was not accidentally corrupted +- The downloaded file matches the published image + +The checksum produced from your ISO must match the published checksum exactly. + +--- + +## Step 3: Verify the ISO + +Place the ISO and checksum file in the same folder, then follow the +instructions for your current operating system. + +### Linux + +Open a terminal in the download folder and run: + +```bash +sha256sum Sovran_SystemsOS.iso +``` + +You can also ask the checksum file to perform the comparison: + +```bash +sha256sum --check Sovran_SystemsOS.iso.sha256 +``` + +A successful comparison should report: + +```text +Sovran_SystemsOS.iso: OK +``` + +### macOS + +Open Terminal in the download folder and run: + +```bash +shasum -a 256 Sovran_SystemsOS.iso +``` + +Compare the value shown in Terminal with the value inside: + +```text +Sovran_SystemsOS.iso.sha256 +``` + +### Windows PowerShell + +Open PowerShell in the download folder and run: + +```powershell +Get-FileHash .\Sovran_SystemsOS.iso -Algorithm SHA256 +``` + +Compare the value under `Hash` with the published checksum. + +### If the values do not match + +Do not install the image if the checksums are different. + +1. Delete the downloaded ISO. +2. Download it again. +3. Repeat the verification. +4. Continue only after the values match exactly. + +--- + +## Step 4: Write the ISO to a USB drive + +Balena Etcher is available for Linux, macOS, and Windows. + +1. Download and install Balena Etcher. +2. Connect the USB drive. +3. Open Balena Etcher. +4. Choose **Flash from file**. +5. Select `Sovran_SystemsOS.iso`. +6. Choose **Select target**. +7. Select the correct USB drive. +8. Review the selected drive carefully. +9. Choose **Flash**. +10. Wait for the writing and verification process to finish. + +> **Warning:** Flashing the ISO erases the selected USB drive. Verify that you +> selected the USB drive and not another storage device. + +After flashing, your current operating system may report that it cannot read +the USB drive or may show several unfamiliar partitions. This can be normal for +a bootable Linux installer. + +Do not format the USB drive after flashing it. + +--- + +## Step 5: Boot from the USB drive + +1. Leave the prepared USB drive connected. +2. Shut down or restart the destination computer. +3. Open the computer's boot-device menu. +4. Select the USB drive. +5. Start the Sovran_SystemsOS installer. + +Common boot-menu keys include: + +- `F12` +- `F11` +- `F10` +- `F9` +- `Esc` +- `Delete` + +The correct key depends on the computer manufacturer. It is often briefly +shown on screen when the computer first powers on. + +If the normal operating system starts instead, restart and try the boot-menu +key again. + +--- + +## Step 6: Install Sovran_SystemsOS + +Follow the on-screen installer. + +Before confirming the installation: + +- Verify that you selected the correct destination drive. +- Back up any important data on that 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: + +1. Restart the computer. +2. Remove the USB drive when instructed. +3. Allow Sovran_SystemsOS to start from the installed drive. +4. Complete the initial setup. + +--- + +## Step 7: Open the Sovran Hub + +You can open the Hub directly from the Sovran_SystemsOS desktop. + +You can also open it from another device on the same local network. + +On your laptop, phone, tablet, or other computer, open a browser and visit: + +```text +http://sovransystemsos.local +``` + +Sign in using your Sovran_SystemsOS credentials. + +You can now use the Hub without sitting in front of the Sovran_SystemsOS +computer. + +### If `sovransystemsos.local` does not open + +Check the following: + +1. Make sure the Sovran_SystemsOS machine is powered on. +2. Make sure both devices are connected to the same local network. +3. Confirm that you entered the full address: + + ```text + http://sovransystemsos.local + ``` + +4. Avoid guest Wi-Fi networks, which may prevent devices from seeing one another. +5. Allow the Sovran_SystemsOS machine a few minutes to finish starting. +6. Temporarily disconnect a VPN that may be interfering with local-network access. +7. 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. + +--- + +## Two paths to get started + +### Download and install + +Sovran_SystemsOS is free, public, and open source. + +Download the ISO, verify the checksum, flash it to USB, and install it on your +own hardware. + +[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS.iso) + +### Guided help + +Not technical? You do not have to figure everything out alone. + +Visit the Sovran Systems website to learn about guided setup, supported +hardware, and Royal Membership. + +[Visit Sovran Systems](https://sovransystems.com) + +--- + +# For developers + +Sovran_SystemsOS combines the reproducibility of NixOS, the Bitcoin service +modules of nix-bitcoin, the desktop Bitcoin packages provided by +`btc-clients-nix`, and the Sovran Hub into a complete Bitcoin operating system. + +The operating system configuration, installer, Hub, desktop integration, +Bitcoin services, and optional self-hosting services are maintained in this +repository. + +## Technology + +- **NixOS and Nix flakes** for reproducible system configuration +- **nix-bitcoin** for declarative Bitcoin and Lightning services +- **btc-clients-nix** for Sparrow, Bisq, and Bisq 2 packages +- **Python and FastAPI** for the Sovran Hub backend +- **JavaScript, HTML, and CSS** for the Hub interface +- **GNOME** for the desktop environment +- **Caddy** for local and public service routing +- **Tor** for Bitcoin network privacy +- **AGPL-3.0** licensing + +--- + +## Build from source + +### Prerequisites + +You need a system with Nix installed and flakes enabled. + +Clone the repository: + +```bash +git clone https://github.com/naturallaw777/sovran-systems.git +cd sovran-systems +``` + +Build the installer: + +```bash +nix build \ + .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage +``` + +The resulting build output will be available through the `result` symlink. + +--- + +## Common development commands + +Run these commands from the flake root. + +### Build the installer + +```bash +nix build \ + .#nixosConfigurations.sovran_systemsos-iso.config.system.build.isoImage +``` + +### Build the system configuration + +```bash +nixos-rebuild build --flake .#nixos +``` + +### Test without making the change permanent + +```bash +sudo nixos-rebuild test --flake .#nixos +``` + +### Activate the new configuration + +```bash +sudo nixos-rebuild switch --flake .#nixos +``` + +### Stage the configuration for the next boot + +```bash +sudo nixos-rebuild boot --flake .#nixos +``` + +### Update pinned flake inputs + +```bash +nix flake update +``` + +Review and test input updates before committing the modified `flake.lock`. + +### Roll back the last activated generation + +```bash +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, Bitcoin services, self-hosted services, and optional features | +| `modules/core/` | Roles, Hub integration, Caddy, desktop, support, and other core behavior | +| `app/` | Sovran Hub backend, templates, static assets, scripts, and web interface | +| `docs/wallet-connections.md` | Wallet Connections architecture, API, CLI, and security/operations notes | +| `iso/` | Installer configuration, installer code, and installer assets | +| `assets/` | Repository documentation images | +| `custom.template.nix` | Template for local features and service overrides | +| `role-state.nix` | Selected Sovran_SystemsOS mode or role | + +--- + +## 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 + +### Core modules + +Core modules provide the base Sovran_SystemsOS experience, including: + +- Role selection and role logic +- Sovran Hub +- GNOME desktop integration +- Caddy +- Local Hub access +- Network and domain support +- Local SSH operations +- Remote deployment support +- Technical support controls +- Performance and power behavior + +### Bitcoin services + +The Bitcoin stack includes: + +- Bitcoin Knots +- Electrs +- LND +- Ride The Lightning +- BTCPay Server +- Sparrow Wallet +- Bisq +- Bisq 2 +- Wallet-to-node connections +- Tor integration + +### Private services + +Self-hosted services include: + +- Matrix Synapse +- Nextcloud +- Vaultwarden +- WordPress + +### Optional features + +Optional features include: + +- Haven Nostr relay +- Element calling +- Mempool explorer +- Bitcoin Core selection +- Remote desktop +- Public-network SSH + +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. + +--- + +## Security approach + +Sovran_SystemsOS is designed around local ownership and explicit control. + +Its security foundations include: + +- Reproducible builds from pinned flake inputs +- Firewall enabled by default +- Public SSH disabled by default +- Remote desktop disabled by default +- Hub authentication +- Local-network Hub access through `sovransystemsos.local` +- Tor integration for the Bitcoin stack +- User-controlled service exposure +- Declarative system configuration +- Restricted technical-support access +- Auditable open-source code + +No operating system can guarantee complete security. Users should still apply +updates, protect credentials, maintain backups, secure their local network, +and review any services they choose to expose publicly. + +Bitcoin users must also securely back up wallet seed phrases, descriptors, +channel backups, and other recovery information. Never store your only wallet +backup on the same computer that holds the wallet. + +--- + +## Acknowledgements + +Sovran_SystemsOS stands on the work of exceptional free and open-source +projects and contributors. + +### NixOS + +Deep gratitude goes to the **NixOS team, the Nixpkgs maintainers, and the +broader Nix community**. + +NixOS provides the reproducible, declarative foundation that makes +Sovran_SystemsOS possible. Its module system, package ecosystem, 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 + +Special thanks go to the **nix-bitcoin project and its contributors**. + +nix-bitcoin provides the declarative foundation for building and operating +Bitcoin and Lightning services on NixOS. Its work makes it possible to +configure complex Bitcoin infrastructure—including nodes, Electrs, Lightning, +Tor integration, and related services—in a reproducible and auditable way. + +Sovran_SystemsOS builds upon that foundation to make this infrastructure +approachable through an integrated desktop, installer, and Sovran Hub. + +### Emmanuel Rosa and btc-clients-nix + +Special thanks go to **Emmanuel Rosa** 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 +- Bisq +- Bisq 2 + +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 +- Bitcoin Core +- Sparrow Wallet +- Bisq +- Bisq 2 +- Electrs +- LND +- Ride The Lightning +- BTCPay Server +- Tor +- NixOS +- GNOME +- Caddy +- Nextcloud +- Matrix Synapse +- Element +- Vaultwarden +- 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 the licenses of independent upstream projects. + +Read the complete license terms in [`LICENSE`](LICENSE). + +--- + +
+ +## Privacy. Sovereignty. Bitcoin. + +### Your Bitcoin life. Your keys. Your node. Your machine. + +[Visit Sovran Systems](https://sovransystems.com) · +[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS.iso) · +[View the License](LICENSE) + +
diff --git a/app/icons/nwc.svg b/app/icons/nwc.svg new file mode 100644 index 0000000..d995567 --- /dev/null +++ b/app/icons/nwc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/sovran_systemsos_web/nwc_hub_manager.py b/app/sovran_systemsos_web/nwc_hub_manager.py new file mode 100644 index 0000000..5ef708d --- /dev/null +++ b/app/sovran_systemsos_web/nwc_hub_manager.py @@ -0,0 +1,764 @@ +""" +Alby Hub manager — shared backend for 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 threading +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +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 + + # ── 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." + ) + + 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.", + ) + + 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='')}", + ) + + 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.", + ) + + 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 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 diff --git a/app/sovran_systemsos_web/nwc_lnurl_service.py b/app/sovran_systemsos_web/nwc_lnurl_service.py new file mode 100644 index 0000000..f6d358d --- /dev/null +++ b/app/sovran_systemsos_web/nwc_lnurl_service.py @@ -0,0 +1,232 @@ +""" +Dedicated LNURL service for 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= + +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 urllib.parse +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import TYPE_CHECKING + +from . import nwc_hub_manager as _mgr_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}$") + +# ── Helpers ─────────────────────────────────────────────────────── + + +def _read_domain() -> str | None: + try: + with open(DOMAIN_FILE, "r") as fh: + raw = fh.read(256).strip().lower() + # Basic validation: must look like a hostname + if re.match(r"^[a-z0-9][a-z0-9.\-]{1,253}$", raw): + return raw + except OSError: + pass + return None + + +def _lnurl_discovery(alias: str, manager: "AlbyHubManager") -> 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=(",", ":")) + + 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" +) -> tuple[dict, int]: + payload, status_code = _lnurl_discovery(alias, manager) + 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 + + 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 do_GET(self) -> None: # noqa: N802 + parsed = urllib.parse.urlparse(self.path) + path = parsed.path + qs = urllib.parse.parse_qs(parsed.query) + + # /.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) + 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) + 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() diff --git a/app/sovran_systemsos_web/nwc_wallet_cli.py b/app/sovran_systemsos_web/nwc_wallet_cli.py new file mode 100644 index 0000000..8e60762 --- /dev/null +++ b/app/sovran_systemsos_web/nwc_wallet_cli.py @@ -0,0 +1,115 @@ +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") + + 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 == "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()) diff --git a/app/sovran_systemsos_web/scripts/sovran-hub-backup.sh b/app/sovran_systemsos_web/scripts/sovran-hub-backup.sh index d6a285f..5a20db2 100755 --- a/app/sovran_systemsos_web/scripts/sovran-hub-backup.sh +++ b/app/sovran_systemsos_web/scripts/sovran-hub-backup.sh @@ -1,6 +1,6 @@ #!/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 @@ -8,6 +8,15 @@ # This script creates an additional copy on an external USB drive — # storing your data in a third location for maximum protection. # +# 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// bash sovran-hub-backup.sh # (or run with no env var to auto-detect the first external USB drive) @@ -17,18 +26,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 +56,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 +109,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 +147,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 +162,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 +177,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 +188,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