-
+
# 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)
+
+
+
+
+
+
+
+*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