Compare commits
22
Commits
c33457fff2
..
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d59a995e8 | ||
|
|
f1f0bf0d11 | ||
|
|
7b5f25d599 | ||
|
|
497ac49cfd | ||
|
|
8fd0318083 | ||
|
|
aed1ed5bd6 | ||
|
|
8f5e5394e6 | ||
|
|
36bd2b4e78 | ||
|
|
4a25ef24d1 | ||
|
|
5e56b07465 | ||
|
|
2867954808 | ||
|
|
c20a47bab7 | ||
|
|
708b502a17 | ||
|
|
cc566016d1 | ||
|
|
3a87302645 | ||
|
|
b79a6fd7f2 | ||
|
|
3694ea6489 | ||
|
|
6987d9bf2c | ||
|
|
ebcc17ae3c | ||
|
|
78bfc5b408 | ||
|
|
361b25a8bd | ||
|
|
7d784eb653 |
No files matched your search
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [1.2.1] - 2026-10-18
|
||||
|
||||
### Added
|
||||
- Mullvad: install the client on the Desktop Role only
|
||||
- Password-model.nix: fix Nix string escaping in the generate fallback
|
||||
- Password & keyring cleanup: one password, one truth, no prompts
|
||||
- Update security details in SECURITY.md
|
||||
- Enhance README formatting and emphasis on features
|
||||
- Revise SECURITY.md for clarity on Hub operations
|
||||
|
||||
### Changed
|
||||
- Update flake.lock (nixpkgs, Sovran_Bitcoin 1.1.1, Bisq2 2.1.14) and integration
|
||||
|
||||
### Fixed
|
||||
- Fix README formatting and clarify service exposure
|
||||
- Fix formatting and clarify server access instructions
|
||||
[1.2.1]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.2.1
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Mullvad: install the VPN app and daemon, as NixOS ships them, on the Desktop Role only
|
||||
|
||||
### Changed
|
||||
- Updated `flake.lock` (`nixpkgs`, `nixpkgs-stable`, `nixvim`, `btc-clients` with Bisq2 `2.1.14`, and `Sovran_Bitcoin` `1.1.1` with RTL `0.15.13` and Nix-native `sovran-nwc` `2.0.0`)
|
||||
- Updated fallback service version map (`app/sovran_systemsos_web/versions.json`)
|
||||
- Fixed deprecated `pkgs.system` reference in `flake.nix` and guarded `config.networking.domain` in `modules/sovran-bitcoin-integration.nix` when `haven` and `nwc-wallets` are both enabled
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2026-10-02
|
||||
|
||||
### Added
|
||||
- Ssh: don't open port 22 for the loopback-only sshd
|
||||
- Bitcoin: drop the stray UDP 3051 firewall rule
|
||||
- Installer: raise generated password entropy from ~23 to ~33 bits
|
||||
- Caddy: stop filtering the RTL and Mempool sites by client address
|
||||
- Hub: serve the Hub on its own port instead of through Caddy
|
||||
- Hub: answer the local network only, whichever way a client arrives
|
||||
- Hub: make the login lockout that LOGIN_FAIL_MAX described
|
||||
- Caddy: serve the Hub, RTL and Mempool sites to local clients only
|
||||
- Docs, hub, installer: say Server + Desktop makes the home IP public
|
||||
- Ddns: take the public IP from Njal.la only and give it to LiveKit
|
||||
|
||||
### Changed
|
||||
- Updated nixpkgs and Sovran_Bitcoin update and the new Bisq 1.10.9
|
||||
[1.2.0]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.2.0
|
||||
|
||||
|
||||
## [1.1.7] - 2026-09-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -14,16 +14,16 @@ digital life — your files, communications, passwords, and websites — all on
|
||||
hardware you control, running auditable open-source software you can trust.
|
||||
|
||||
Every installation is a private [NixOS](https://nixos.org) desktop with
|
||||
[Sparrow Wallet](https://sparrowwallet.com), [Bisq](https://bisq.network), and
|
||||
[Bisq 2](https://github.com/bisq-network/bisq2) ready to use. Move beyond
|
||||
**[Sparrow Wallet](https://sparrowwallet.com)**, **[Bisq](https://bisq.network)**,
|
||||
and **[Bisq 2](https://github.com/bisq-network/bisq2)** ready to use. Move beyond
|
||||
custodial exchanges from the first boot, and grow into your own Bitcoin node,
|
||||
Lightning infrastructure, private cloud, and communications platform when you
|
||||
are ready.
|
||||
|
||||
[Visit the Website](https://sovransystems.com) ·
|
||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.7.iso) ·
|
||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso) ·
|
||||
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.7.iso.sha256) ·
|
||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso.sha256) ·
|
||||
[Build from Source](#build-from-source)
|
||||
|
||||
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||
@@ -61,18 +61,18 @@ are ready.
|
||||
## Why Sovran_SystemsOS?
|
||||
|
||||
Bitcoin lets you hold and transfer value without asking a bank, exchange, or
|
||||
custodian for permission. But that freedom depends on the software and
|
||||
custodian for permission, but that freedom depends on the software and
|
||||
infrastructure you choose. Your wider digital life works the same way: files,
|
||||
messages, and passwords kept on someone else's servers are never fully yours.
|
||||
|
||||
Sovran_SystemsOS solves both with one operating system:
|
||||
|
||||
- **Hold your own keys** with Sparrow Wallet. Create and manage wallets,
|
||||
- **Hold your own keys** with **Sparrow Wallet**. Create and manage wallets,
|
||||
connect hardware signing devices, use multisignature setups, build and
|
||||
inspect transactions, and control UTXOs and coin selection.
|
||||
- **Buy and sell Bitcoin peer-to-peer** with Bisq and Bisq 2. No central
|
||||
company holds user funds, and no exchange account stands between buyers and
|
||||
sellers.
|
||||
- **Buy and sell Bitcoin peer-to-peer** with **Bisq** and **Bisq 2**. No
|
||||
central company holds user funds, and no exchange account stands between
|
||||
buyers and sellers.
|
||||
- **Verify your own Bitcoin** with a full node: [Bitcoin Core](https://bitcoin.org) and
|
||||
[Electrs](https://github.com/romanz/electrs), so your wallets connect to
|
||||
*your* node instead of a stranger's.
|
||||
@@ -90,16 +90,11 @@ Sovran_SystemsOS solves both with one operating system:
|
||||
- **Control everything from the Sovran Hub**, on the desktop or from any
|
||||
browser on your local network.
|
||||
|
||||
No custodian needs to hold your Bitcoin. No outside node needs to tell your
|
||||
wallet what happened on the network. No third-party cloud needs to control
|
||||
your data or services.
|
||||
|
||||
Other projects solve one piece of this puzzle: a Linux distribution that can
|
||||
run a wallet, a node project that runs Bitcoin services, a self-hosting stack
|
||||
that replaces a cloud app. Sovran_SystemsOS brings those worlds together and
|
||||
makes them approachable: Bitcoin tools from the first boot, a complete path
|
||||
from desktop to node to self-hosting, one control center, hardware you own,
|
||||
and a reproducible, auditable NixOS foundation.
|
||||
Other projects solve one piece of this puzzle: a distribution that runs a
|
||||
wallet, a node project that runs Bitcoin services, a self-hosting stack that
|
||||
replaces a cloud app. Sovran_SystemsOS brings them together on a reproducible,
|
||||
auditable NixOS foundation: Bitcoin tools from the first boot, one control
|
||||
center, and hardware you own.
|
||||
|
||||
> Sovran_SystemsOS provides tools for self-custody and peer-to-peer Bitcoin
|
||||
> use. Users remain responsible for protecting their keys, understanding their
|
||||
@@ -110,34 +105,24 @@ and a reproducible, auditable NixOS foundation.
|
||||
## Try it first in a virtual machine
|
||||
|
||||
**Curious, but not ready to replace your current operating system?** Start with
|
||||
Sovran_SystemsOS in a virtual machine (VM). A VM runs Sovran_SystemsOS in a
|
||||
window on your existing Windows, macOS, or Linux computer, using a virtual disk
|
||||
file instead of your computer's internal drive. You can explore the desktop,
|
||||
Sovran Hub, Sparrow, Bisq, and the installation experience before changing how
|
||||
you use any physical machine.
|
||||
Sovran_SystemsOS in a virtual machine (VM): a window on your existing Windows,
|
||||
macOS, or Linux computer, using a virtual disk file instead of your internal
|
||||
drive. Explore the desktop, Sovran Hub, Sparrow, Bisq, and the installation
|
||||
experience before changing how you use any physical machine. Closing or
|
||||
deleting the VM leaves your current operating system untouched, and installing
|
||||
on a dedicated computer later is still open to you.
|
||||
|
||||
This is a low-commitment way to decide whether Sovran_SystemsOS is right for
|
||||
you:
|
||||
|
||||
- **Keep your current OS.** Closing or deleting the VM leaves the host operating
|
||||
system in place.
|
||||
- **Learn at your own pace.** Familiarize yourself with the desktop and tools
|
||||
without needing to make it your daily computer on day one.
|
||||
- **Choose your next step with confidence.** When you are ready, install it on
|
||||
a dedicated computer, or keep using the VM as a learning environment.
|
||||
|
||||
You can use the same ISO with [VirtualBox](https://www.virtualbox.org), VMware,
|
||||
Use the same ISO with [VirtualBox](https://www.virtualbox.org), VMware,
|
||||
QEMU/KVM, Proxmox, and similar x86_64 VM software. For a first look, select
|
||||
**Desktop Only**, allocate at least **8 GB RAM**, and create a **256 GB or
|
||||
larger dynamically allocated virtual disk**. The full VM setup and installer
|
||||
requirements are in [Installing in a virtual machine](#installing-in-a-virtual-machine-optional).
|
||||
larger dynamically allocated virtual disk**. Full requirements are in
|
||||
[Installing in a virtual machine](#installing-in-a-virtual-machine-optional).
|
||||
|
||||
> **A VM is for evaluation and learning, not a substitute for a dedicated,
|
||||
> hardened setup.** Do not use a trial VM to hold meaningful Bitcoin, recovery
|
||||
> phrases, passwords, or other sensitive data. Avoid attaching physical drives
|
||||
> to the VM, and be deliberate about shared folders, clipboard sharing, and
|
||||
> network settings. The installer only changes the disk you select, but you
|
||||
> should always review VM disk selections before confirming an install.
|
||||
> hardened setup.** Do not hold meaningful Bitcoin, recovery phrases,
|
||||
> passwords, or other sensitive data in a trial VM. Avoid attaching physical
|
||||
> drives, and be deliberate about shared folders, clipboard sharing, and
|
||||
> network settings. Review VM disk selections before confirming an install.
|
||||
|
||||
---
|
||||
|
||||
@@ -187,6 +172,7 @@ hardware you control.
|
||||
- [GNOME](https://www.gnome.org) desktop
|
||||
- [Brave Origin](https://brave.com/origin/) and
|
||||
[Firefox](https://www.mozilla.org/firefox) browsers
|
||||
- [Mullvad VPN](https://mullvad.net) desktop app and daemon, on Desktop Only
|
||||
- File management, email, calendar, and office applications
|
||||
- System monitoring and administration tools
|
||||
|
||||
@@ -200,25 +186,38 @@ Bitcoin and self-hosting infrastructure runs on the machine.
|
||||
|
||||
| Mode | Best for | What you get |
|
||||
|---|---|---|
|
||||
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
|
||||
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections |
|
||||
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use, plus the Mullvad VPN app |
|
||||
| **Node** | People ready to verify and operate their own Bitcoin infrastructure | Everything in Desktop, plus the full Bitcoin stack: Bitcoin Core, Electrs, LND, Ride The Lightning, BTCPay Server, and wallet-to-node connections. **Publishes nothing unless you enable *Put BTCPay Server Online* or *Lightning Wallet Connections*:** [read this first](#server--desktop-and-your-home-ip-address) |
|
||||
| **Server + Desktop** | Bitcoiners who want the same sovereignty over their communications, cloud, passwords, and web services | The complete Node stack, plus the private self-hosted services. **Makes your home IP address public:** [read this first](#server--desktop-and-your-home-ip-address) |
|
||||
|
||||
**Desktop: start with your keys.** Desktop is not a reduced or Bitcoin-free
|
||||
edition. It is a complete, private everyday computer with a clean GNOME
|
||||
desktop, Tor, and the Sovran Hub, giving you a lower-hardware path to
|
||||
self-custody and non-KYC Bitcoin tools from the first boot. You do not need a
|
||||
fully synchronized node to begin; move to Node mode when your hardware,
|
||||
edition: it is a complete, private everyday computer with a clean GNOME
|
||||
desktop, Tor, the Sovran Hub, and the Mullvad VPN app and daemon — which no
|
||||
other role installs — and a lower-hardware path to self-custody and non-KYC
|
||||
Bitcoin tools from the first boot. Move to Node mode when your hardware,
|
||||
storage, and needs are ready.
|
||||
|
||||
**Node: verify your own money.** Instead of asking someone else's server
|
||||
about your wallet and transactions, you operate the infrastructure that
|
||||
performs the verification. Your node verifies. Your wallet connects to it.
|
||||
Your keys remain yours.
|
||||
Mullvad is installed the way NixOS ships it and nothing in it is configured for
|
||||
you: create an account at [mullvad.net](https://mullvad.net), sign in from the
|
||||
app, and set the relay and the app's own switches to taste. It is the one thing
|
||||
that reaches the network from this machine as an application rather than as a
|
||||
service, so Server + Desktop and Bitcoin Node — built to serve — do not carry
|
||||
it. On Desktop it is simply there; remove it with
|
||||
`services.mullvad-vpn.enable = lib.mkForce false;` in `custom.nix` if you would
|
||||
rather not have it.
|
||||
|
||||
**Server + Desktop: sovereignty beyond money.** Bitcoin sovereignty is the
|
||||
foundation. Server + Desktop applies the same principle to your data,
|
||||
communications, identity, and services.
|
||||
**Node: verify your own money.** You operate the infrastructure that performs
|
||||
the verification instead of asking someone else's server: your node verifies,
|
||||
your wallet connects to it, your keys stay yours.
|
||||
|
||||
Node publishes nothing by default. Two Node features need a public domain and
|
||||
make your home IP address public when enabled: **Put BTCPay Server Online** and
|
||||
**Lightning Wallet Connections**. Everything else, Mempool and Ride The
|
||||
Lightning included, stays on your home network or reaches you through Tor — see
|
||||
[Server + Desktop and your home IP address](#server--desktop-and-your-home-ip-address).
|
||||
|
||||
**Server + Desktop: sovereignty beyond money.** The same principle, applied
|
||||
to your data, communications, identity, and services.
|
||||
|
||||
### Recommended hardware
|
||||
|
||||
@@ -234,13 +233,7 @@ communications, identity, and services.
|
||||
> the public internet, which means opening specific ports on your home network.
|
||||
> Before you start, confirm three things: you can log in to your router's admin
|
||||
> panel, the panel includes a port-forwarding section, and your internet
|
||||
> provider allows port forwarding. Most home routers and providers already
|
||||
> support this. If you are unsure, a quick search for your router model and
|
||||
> "port forwarding" will usually turn up a step-by-step guide.
|
||||
>
|
||||
> **This mode also makes your home IP address public.** Read
|
||||
> [what that means](#server--desktop-and-your-home-ip-address) before you
|
||||
> choose it.
|
||||
> provider allows port forwarding.
|
||||
|
||||
### Server + Desktop and your home IP address
|
||||
|
||||
@@ -251,50 +244,42 @@ communications, identity, and services.
|
||||
> [Njal.la](https://njal.la) and keeps it up to date, and you forward ports 80
|
||||
> and 443 on your router to this computer. From then on:
|
||||
>
|
||||
> - **Anyone can look up your domain and see your home IP address.** An IP
|
||||
> address typically reveals your internet provider and your approximate
|
||||
> location, and it ties everything you publish on that domain to your home
|
||||
> connection.
|
||||
> - **Domain privacy does not hide it.** Registrar privacy protects the
|
||||
> registrant's identity, not the IP address in your DNS records.
|
||||
> - **Anyone can look up your domain and see your home IP address,** which
|
||||
> reveals your internet provider and approximate location and ties everything
|
||||
> you publish on that domain to your home connection. Domain privacy does not
|
||||
> hide it: registrar privacy protects the registrant's identity, not the IP
|
||||
> address in your DNS records.
|
||||
> - **Your connection is open to the whole internet on those ports.** Scanners
|
||||
> and bots constantly probe public IP addresses, so expect automated probing
|
||||
> and login attempts against every service you publish.
|
||||
> - **Your service names are discoverable.** Public HTTPS certificates are
|
||||
> listed in public Certificate Transparency logs, so hostnames such as
|
||||
> `vault.yourdomain.com` can be found, and then resolved to your IP address,
|
||||
> even if you never share them.
|
||||
> listed in Certificate Transparency logs, so hostnames such as
|
||||
> `vault.yourdomain.com` can be found and resolved to your IP address, even
|
||||
> if you never share them.
|
||||
|
||||
Nothing is published until you finish domain setup and port forwarding, but that
|
||||
setup is the point of this mode, so assume your IP address will be public.
|
||||
**Desktop** publishes nothing. **Node** publishes nothing unless you turn on a
|
||||
feature that needs a domain: *Put BTCPay Server Online* or *Lightning Wallet
|
||||
Connections*.
|
||||
**Desktop** publishes nothing. **Node** publishes nothing unless you enable
|
||||
*Put BTCPay Server Online* or *Lightning Wallet Connections* — see
|
||||
[Three modes](#three-modes).
|
||||
|
||||
**If you do not want your home IP address to be public,** choose Desktop or
|
||||
Node. Advanced users can put a VPS, reverse proxy, or tunnel in front of their
|
||||
services so DNS points there instead of at their home. Sovran_SystemsOS does not
|
||||
set this up for you, and the Hub's domain checks currently expect DNS to point
|
||||
at your home IP address.
|
||||
services so DNS points there instead of at their home; Sovran_SystemsOS does
|
||||
not set that up, and the Hub's domain checks expect DNS to point at your home
|
||||
IP address.
|
||||
|
||||
<details>
|
||||
<summary><strong>What happens technically</strong></summary>
|
||||
|
||||
- You create a **Dynamic** DNS record at Njal.la and paste its update command
|
||||
into the Hub. The Hub only accepts `njal.la` update URLs.
|
||||
- The `sovran-ddns-update` timer asks Njal.la to point your record at the
|
||||
address the request came from. It does this right after you save a domain,
|
||||
two minutes after boot, and then every 15 minutes.
|
||||
- Njal.la reports that address back, and Sovran_SystemsOS keeps it for Element
|
||||
calling and the Hub. Nothing else looks up your public IP address: no STUN
|
||||
server, public DNS resolver, or "what is my IP" service is involved. See
|
||||
into the Hub, which only accepts `njal.la` update URLs.
|
||||
- The `sovran-ddns-update` timer points your record at the address the request
|
||||
came from: after you save a domain, two minutes after boot, then every 15
|
||||
minutes. Element calling and the Hub use that same address. See
|
||||
`modules/core/njalla.nix`.
|
||||
- Once a service that needs a domain is turned on, the firewall opens TCP and
|
||||
UDP ports 80 and 443 for Caddy, which requests public HTTPS certificates for
|
||||
the domains you configure. See `modules/core/caddy.nix`.
|
||||
- Optional features can need more ports. Element calling, for example, needs
|
||||
TCP 7881 and UDP 3478, 7882, and 40000–40099. The Hub lists the ports each
|
||||
feature needs.
|
||||
- Once a service that needs a domain is on, the firewall opens TCP and UDP
|
||||
ports 80 and 443 for Caddy, which requests public HTTPS certificates for the
|
||||
domains you configure. The Hub lists any additional ports a feature needs.
|
||||
See `modules/core/caddy.nix`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -316,11 +301,8 @@ the tools of your selected mode already in place.
|
||||
Prefer to keep using Windows, macOS, Linux, Android, or iOS? Install
|
||||
Sovran_SystemsOS on a separate computer and let it run quietly on your local
|
||||
network, with or without a monitor. From any other device on the same network,
|
||||
open a browser, visit `http://sovransystemsos.local`, and manage everything
|
||||
from [The Sovran Hub](#the-sovran-hub).
|
||||
|
||||
Your existing devices stay familiar. Sovran_SystemsOS provides the independent
|
||||
infrastructure behind them.
|
||||
open a browser, visit `http://sovransystemsos.local:8937`, and manage
|
||||
everything from [the Hub](#the-hub).
|
||||
|
||||
---
|
||||
|
||||
@@ -328,16 +310,15 @@ infrastructure behind them.
|
||||
|
||||
### Your private infrastructure, controlled from any screen.
|
||||
|
||||
The Hub is the command center built into Sovran_SystemsOS. It is both a
|
||||
local desktop application and a private web interface served directly by your
|
||||
Sovran_SystemsOS machine. Nothing needs to be installed on the device opening
|
||||
the Hub: you only need a modern browser and access to the same local network.
|
||||
The Hub is the command center built into Sovran_SystemsOS: a local desktop
|
||||
application and a private web interface served by your Sovran_SystemsOS
|
||||
machine. The device opening it needs only a modern browser and access to the
|
||||
same local network.
|
||||
|
||||
From one place, the Hub helps you:
|
||||
From one place, the Hub lets you:
|
||||
|
||||
- Open, monitor, start, and stop your services
|
||||
- See what is running and configure system features
|
||||
- Manage service domains and credentials
|
||||
- Configure system features and manage service domains and credentials
|
||||
- Reach your Bitcoin tools, private cloud, and communications
|
||||
- Perform supported system operations without everyday terminal commands
|
||||
|
||||
@@ -354,7 +335,7 @@ From one place, the Hub helps you:
|
||||
│ │ │
|
||||
Windows laptop Phone or tablet Mac or Linux
|
||||
│ │ │
|
||||
└──────── Browser: sovransystemsos.local ────┘
|
||||
└─────── Browser: sovransystemsos.local:8937 ─┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
@@ -372,21 +353,36 @@ From one place, the Hub helps you:
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
Keep using the devices you already own. Sovran_SystemsOS becomes the private
|
||||
Bitcoin and digital infrastructure behind them.
|
||||
|
||||
> **Local access:** the Hub is available at
|
||||
> `http://sovransystemsos.local` to devices connected to the same local
|
||||
> network. It is protected by authentication and is not automatically exposed
|
||||
> to the public internet.
|
||||
> `http://sovransystemsos.local:8937` to devices connected to the same local
|
||||
> network (not on Desktop, which publishes nothing). It is protected by
|
||||
> authentication and answers only your local network.
|
||||
|
||||
### Tech Support
|
||||
|
||||
The Hub can give a Sovran Systems technician temporary access to your machine.
|
||||
Support runs over SSH, and **Tech Support requires SSH Remote Access, which is
|
||||
off by default.**
|
||||
|
||||
Turning SSH on makes the machine listen on all interfaces and opens **TCP port
|
||||
22** in the firewall. Letting support in from outside your home means
|
||||
forwarding port 22 on your router, which puts SSH on the public internet where
|
||||
scanners and brute-force attempts will find it. Turn SSH off when the session
|
||||
ends; the Hub prompts you to.
|
||||
|
||||
Support signs in as the restricted `sovran-support` account, not as root, with
|
||||
one key that expires after 24 hours. Wallet paths stay locked unless you unlock
|
||||
them for a limited time, and every session event is written to
|
||||
`/var/log/sovran-support-audit.log`. See
|
||||
[Restricted support access](SECURITY.md#restricted-support-access).
|
||||
|
||||
---
|
||||
|
||||
## Install Sovran_SystemsOS
|
||||
|
||||
Sovran_SystemsOS is free and open source. You can download the installer,
|
||||
verify it, write it to a USB drive, and install it yourself, staying in
|
||||
control from the very first step.
|
||||
Sovran_SystemsOS is free and open source: download the installer, verify it,
|
||||
write it to a USB drive, and install it yourself, in control from the first
|
||||
step.
|
||||
|
||||
An ISO is a complete installation image containing the operating system and
|
||||
installer. It is not copied to a USB drive like a document; it must be written
|
||||
@@ -405,18 +401,17 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
||||
|
||||
### 1. Download the ISO and checksum
|
||||
|
||||
- [Download Sovran_SystemsOS-1.1.7.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.7.iso)
|
||||
- [Download Sovran_SystemsOS-1.1.7.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.7.iso.sha256)
|
||||
- [Download Sovran_SystemsOS-1.2.1.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso)
|
||||
- [Download Sovran_SystemsOS-1.2.1.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso.sha256)
|
||||
|
||||
The download may take some time. Do not rename or modify the ISO before
|
||||
verifying it, and keep both files in the same folder.
|
||||
|
||||
### 2. Verify the checksum
|
||||
|
||||
A checksum is a digital fingerprint of a file. Verifying it confirms that the
|
||||
ISO downloaded completely, was not accidentally corrupted, and matches the
|
||||
published image. The checksum produced from your ISO must match the published
|
||||
checksum exactly.
|
||||
A checksum is a digital fingerprint of a file. Verifying it confirms the ISO
|
||||
downloaded completely and without corruption. The checksum produced from your
|
||||
ISO must match the published one exactly.
|
||||
|
||||
<details>
|
||||
<summary><strong>Linux</strong></summary>
|
||||
@@ -424,18 +419,15 @@ checksum exactly.
|
||||
Open a terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
sha256sum --check Sovran_SystemsOS-1.1.7.iso.sha256
|
||||
sha256sum --check Sovran_SystemsOS-1.2.1.iso.sha256
|
||||
```
|
||||
|
||||
A successful comparison reports:
|
||||
|
||||
```text
|
||||
Sovran_SystemsOS-1.1.7.iso: OK
|
||||
Sovran_SystemsOS-1.2.1.iso: OK
|
||||
```
|
||||
|
||||
You can also run `sha256sum Sovran_SystemsOS-1.1.7.iso` and compare the output
|
||||
against the checksum file manually.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -444,11 +436,11 @@ against the checksum file manually.
|
||||
Open Terminal in the download folder and run:
|
||||
|
||||
```bash
|
||||
shasum -a 256 Sovran_SystemsOS-1.1.7.iso
|
||||
shasum -a 256 Sovran_SystemsOS-1.2.1.iso
|
||||
```
|
||||
|
||||
Compare the value shown in Terminal with the value inside
|
||||
`Sovran_SystemsOS-1.1.7.iso.sha256`.
|
||||
`Sovran_SystemsOS-1.2.1.iso.sha256`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -458,7 +450,7 @@ Compare the value shown in Terminal with the value inside
|
||||
Open PowerShell in the download folder and run:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\Sovran_SystemsOS-1.1.7.iso -Algorithm SHA256
|
||||
Get-FileHash .\Sovran_SystemsOS-1.2.1.iso -Algorithm SHA256
|
||||
```
|
||||
|
||||
Compare the value under `Hash` with the published checksum.
|
||||
@@ -473,7 +465,7 @@ match exactly.
|
||||
|
||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||
connect the USB drive.
|
||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.7.iso`.
|
||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.2.1.iso`.
|
||||
3. Choose **Select target**, select the USB drive, and review your selection
|
||||
carefully.
|
||||
4. Choose **Flash** and wait for the writing and verification process to
|
||||
@@ -508,14 +500,14 @@ similar x86_64 virtual machines. Use production-like resources where possible:
|
||||
allocated disks are fine; they do not consume the full size immediately.
|
||||
- Use NAT or bridged networking with internet access before opening the
|
||||
installer.
|
||||
- UEFI/EFI firmware is preferred. In UEFI VMs, the installer avoids depending
|
||||
on VM NVRAM boot-entry writes. If a VM boots the ISO in legacy BIOS mode,
|
||||
the installer automatically switches the installed system to GRUB.
|
||||
- UEFI/EFI firmware is preferred: in UEFI VMs the installer avoids depending
|
||||
on VM NVRAM boot-entry writes, and a VM that boots the ISO in legacy BIOS
|
||||
mode automatically installs GRUB instead.
|
||||
- For Node or Server + Desktop, attach a **second 2 TB virtual data disk**.
|
||||
If you only attach one disk, choose **Desktop Only**.
|
||||
- Present the install target as a normal virtual disk such as VirtIO, SATA,
|
||||
SCSI, or NVMe. USB-attached target disks are intentionally hidden by the
|
||||
installer to avoid erasing the installer USB by mistake.
|
||||
SCSI, or NVMe. USB-attached disks are hidden by the installer so you cannot
|
||||
erase the installer USB by mistake.
|
||||
|
||||
### 5. Install
|
||||
|
||||
@@ -536,28 +528,26 @@ Open the Hub directly from the Sovran_SystemsOS desktop, or from any other
|
||||
device on the same local network at:
|
||||
|
||||
```text
|
||||
http://sovransystemsos.local
|
||||
http://sovransystemsos.local:8937
|
||||
```
|
||||
|
||||
Sign in with your Sovran_SystemsOS credentials.
|
||||
Sign in with your Sovran_SystemsOS credentials. Desktop does not publish the Hub
|
||||
on the network, so in that mode open it from the desktop.
|
||||
|
||||
<details>
|
||||
<summary><strong>If sovransystemsos.local does not open</strong></summary>
|
||||
<summary><strong>If sovransystemsos.local:8937 does not open</strong></summary>
|
||||
|
||||
1. Make sure the Sovran_SystemsOS machine is powered on, and allow it a few
|
||||
minutes to finish starting.
|
||||
2. Make sure both devices are connected to the same local network, and that
|
||||
you entered the full address `http://sovransystemsos.local`.
|
||||
you entered the full address `http://sovransystemsos.local:8937`,
|
||||
including the `:8937`.
|
||||
3. Avoid guest Wi-Fi networks, which may prevent devices from seeing one
|
||||
another.
|
||||
4. Temporarily disconnect any VPN that may interfere with local-network
|
||||
access.
|
||||
5. Try another browser or device on the same network.
|
||||
|
||||
Some networks or devices may not support `.local` address discovery correctly.
|
||||
Network isolation, custom DNS settings, VPNs, and some routers can interfere
|
||||
with local-device discovery.
|
||||
|
||||
</details>
|
||||
|
||||
### Prefer guided help?
|
||||
@@ -609,6 +599,8 @@ and `/etc/nix-bitcoin-secrets` remain for compatibility.
|
||||
- [GNOME](https://www.gnome.org) desktop environment
|
||||
- [Caddy](https://caddyserver.com) for local and public service routing
|
||||
- [Tor](https://www.torproject.org) for Bitcoin network privacy
|
||||
- [Mullvad VPN](https://mullvad.net), installed as the stock NixOS service on
|
||||
the Desktop Role only (`pkgs.mullvad` daemon and `pkgs.mullvad-vpn` app)
|
||||
- [AGPL-3.0](LICENSE) licensing
|
||||
|
||||
### Build from source
|
||||
@@ -758,10 +750,10 @@ rebuilds the machine into the selected declarative state.
|
||||
| Optional Haven Nostr relay | `modules/haven.nix` |
|
||||
| Nextcloud, Vaultwarden, WordPress | `modules/nextcloud.nix`, `modules/vaultwarden.nix`, `modules/wordpress.nix`, `modules/php.nix` |
|
||||
| Optional remote desktop and public SSH | `modules/rdp.nix`, `modules/sshd.nix` |
|
||||
| Mullvad VPN app (Desktop Role only) | `modules/mullvad.nix` |
|
||||
|
||||
Feature availability and defaults may change as Sovran_SystemsOS develops.
|
||||
Review the relevant Nix module before relying on a specific default in a
|
||||
production environment.
|
||||
Feature availability and defaults may change as Sovran_SystemsOS develops:
|
||||
review the relevant Nix module before relying on a default in production.
|
||||
|
||||
---
|
||||
|
||||
@@ -771,11 +763,11 @@ Wallet recovery words control the funds. Never share them with a website,
|
||||
support technician, cloud service, or chat application.
|
||||
|
||||
For meaningful balances, prefer a well-reviewed hardware signer and follow its
|
||||
verified backup process. A BIP39 passphrase is optional, advanced protection;
|
||||
it is not a replacement for the recovery words. If you use one, back it up
|
||||
separately—losing either item can make the wallet unrecoverable.
|
||||
verified backup process. A BIP39 passphrase is optional, advanced protection,
|
||||
not a replacement for recovery words: back it up separately, because losing
|
||||
either item can make the wallet unrecoverable.
|
||||
|
||||
Keep durable offline backups in separate secure locations. Test recovery before
|
||||
Keep durable offline backups in separate secure locations, test recovery before
|
||||
relying on a wallet, and begin with a small amount.
|
||||
|
||||
---
|
||||
@@ -785,19 +777,17 @@ relying on a wallet, and begin with a small amount.
|
||||
Sovran_SystemsOS uses layered controls:
|
||||
|
||||
- Pinned flake inputs and hash-pinned source archives
|
||||
- Bitcoin and Lightning modules maintained in this repository
|
||||
- Firewall enabled; public SSH and remote desktop disabled by default
|
||||
- Firewall enabled; SSH Remote Access and remote desktop disabled by default.
|
||||
Enabling SSH opens TCP port 22 and the Hub then asks you to forward it
|
||||
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
||||
- Tor enforcement for supported Bitcoin services
|
||||
- Restricted, time-limited support access with scoped `sudo`
|
||||
- Operator-controlled public service exposure (Server + Desktop
|
||||
- Operator-controlled public service exposure (Server + Desktop Role or Node Role when enabled BTCpayserver and/or Lightning Wallet Connections
|
||||
[makes your home IP address public](#server--desktop-and-your-home-ip-address)
|
||||
once you set up a domain)
|
||||
|
||||
See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
|
||||
and operator guidance. No operating system can protect funds after recovery
|
||||
words, administrator credentials, or the root account are compromised. Apply
|
||||
updates and keep tested offline backups.
|
||||
and operator guidance.
|
||||
|
||||
---
|
||||
|
||||
@@ -807,11 +797,9 @@ Sovran_SystemsOS stands on the work of exceptional free and open-source projects
|
||||
|
||||
### NixOS
|
||||
|
||||
Deep gratitude goes to the [NixOS](https://nixos.org) team, the [Nixpkgs](https://github.com/NixOS/nixpkgs) maintainers, and the broader [Nix community](https://github.com/nix-community).
|
||||
Gratitude to the [NixOS](https://nixos.org) team, the [Nixpkgs](https://github.com/NixOS/nixpkgs) maintainers, and the broader [Nix community](https://github.com/nix-community).
|
||||
|
||||
NixOS provides the reproducible, declarative foundation that makes Sovran_SystemsOS possible. Its module system, package ecosystem, [flakes](https://nixos.wiki/wiki/Flakes), and generation-based system management allow an entire Bitcoin operating system to be described, audited, rebuilt, upgraded, and rolled back from source.
|
||||
|
||||
Sovran_SystemsOS would not have the same reliability, transparency, or reproducibility without their years of work.
|
||||
NixOS provides the reproducible, declarative foundation that makes Sovran_SystemsOS possible: its module system, package ecosystem, [flakes](https://nixos.wiki/wiki/Flakes), and generation-based system management allow an entire Bitcoin operating system to be described, audited, rebuilt, upgraded, and rolled back from source.
|
||||
|
||||
### nix-bitcoin
|
||||
|
||||
@@ -819,24 +807,17 @@ The in-repository Bitcoin stack began with code adapted from
|
||||
[nix-bitcoin](https://github.com/fort-nix/nix-bitcoin), primarily from commit
|
||||
[`360e30f`](https://github.com/fort-nix/nix-bitcoin/commit/360e30fee5ba32f9fecc89bc35628195d9d2dbbe).
|
||||
It has since been narrowed to Sovran's supported services and is maintained in
|
||||
this repository. nix-bitcoin is no longer a flake input or build dependency.
|
||||
|
||||
We remain grateful to the nix-bitcoin contributors for the declarative and
|
||||
security-focused foundation. Its MIT notice is retained in
|
||||
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||
this repository; nix-bitcoin is no longer a flake input or build dependency.
|
||||
Its MIT notice is retained in [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
### Emmanuel Rosa and btc-clients-nix
|
||||
|
||||
Special thanks go to [Emmanuel Rosa](https://github.com/emmanuelrosa) for [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix).
|
||||
|
||||
The project provides Nix packages for the Bitcoin desktop software central to the Sovran_SystemsOS experience:
|
||||
Thanks to [Emmanuel Rosa](https://github.com/emmanuelrosa) for [btc-clients-nix](https://github.com/emmanuelrosa/btc-clients-nix), which packages the Bitcoin desktop software included in every installation:
|
||||
|
||||
- [Sparrow Wallet](https://github.com/sparrowwallet/sparrow)
|
||||
- [Bisq](https://github.com/bisq-network/bisq)
|
||||
- [Bisq 2](https://github.com/bisq-network/bisq2)
|
||||
|
||||
This work helps make it possible for Sovran_SystemsOS to deliver self-custody and peer-to-peer Bitcoin tools as part of every installation.
|
||||
|
||||
### The upstream Bitcoin ecosystem
|
||||
|
||||
Sovran_SystemsOS also depends on the work of the developers and communities behind:
|
||||
@@ -864,27 +845,16 @@ Sovran_SystemsOS also depends on the work of the developers and communities behi
|
||||
- [WordPress](https://github.com/WordPress/WordPress)
|
||||
- Every other upstream project included in the system
|
||||
|
||||
Sovran Systems did not create these foundations. Our work is to bring them together into a cohesive operating system that helps more people use Bitcoin privately, independently, and with confidence.
|
||||
|
||||
Thank you to every developer, maintainer, reviewer, tester, documentarian, and user who keeps this ecosystem alive.
|
||||
Sovran Systems did not create these foundations; our work is to bring them together into one operating system that helps more people use Bitcoin privately and independently. Thank you to every developer, maintainer, reviewer, tester, documentarian, and user who keeps this ecosystem alive.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
[GNU Affero General Public License v3.0](LICENSE). You may use, study, modify,
|
||||
share, and self-host it; 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
|
||||
@@ -892,14 +862,9 @@ to interact with over a network must offer those users access to its
|
||||
corresponding source code, as required by the AGPL-3.0.
|
||||
|
||||
Sovran_SystemsOS is provided **without warranty**, as described in the full
|
||||
license.
|
||||
|
||||
> Individual upstream applications, packages, artwork, fonts, and other
|
||||
> components included with or built by Sovran_SystemsOS may have their own
|
||||
> licenses and copyright holders. The AGPL-3.0 license for this repository does
|
||||
> not replace those licenses.
|
||||
|
||||
Read [`LICENSE`](LICENSE) and
|
||||
license. Individual upstream applications, packages, artwork, fonts, and other
|
||||
components may have their own licenses and copyright holders, which the
|
||||
AGPL-3.0 does not replace. Read [`LICENSE`](LICENSE) and
|
||||
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
|
||||
|
||||
---
|
||||
@@ -918,7 +883,7 @@ primary location for collaboration. Please read our
|
||||
## Privacy. Sovereignty. Bitcoin.
|
||||
|
||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.7.iso) ·
|
||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.1.iso) ·
|
||||
[View the License](LICENSE)
|
||||
|
||||
</div>
|
||||
+59
-37
@@ -27,78 +27,100 @@ time for a fix and coordinated disclosure.
|
||||
|
||||
### Local-first operation
|
||||
|
||||
The Hub and core data run on operator-owned hardware. The Hub is intended for a
|
||||
trusted local network and must not be port-forwarded to the internet. Public
|
||||
services, DDNS, software updates, and optional third-party relays require
|
||||
external networks and are outside a “fully offline” model.
|
||||
The Hub and core data run on operator-owned hardware. The Hub is for a trusted
|
||||
local network and must not be port-forwarded to the internet. It uses HTTP, so
|
||||
authentication does not encrypt local network traffic.
|
||||
|
||||
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
||||
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
||||
The Hub is served on port 8937 and is not fronted by Caddy. Server + Desktop and
|
||||
Bitcoin Node Only open that port in the firewall, so local devices reach the Hub
|
||||
at `http://sovransystemsos.local:8937`. On Desktop Only the Hub is not published
|
||||
at all: reachable only from the machine itself, on localhost, with no TCP port
|
||||
open in the firewall (UDP 5353 for mDNS only).
|
||||
`sovran_systemsOS.hub.directPort = true` in `custom.nix` opens port 8937 if you
|
||||
want to reach a Desktop Only Hub from another device.
|
||||
|
||||
Caddy serves the Hub (`sovransystemsos.local`), Ride The Lightning (port 3051),
|
||||
and Mempool (port 60847) only to this computer and to clients on your local
|
||||
network (private, link-local, and VPN addresses), even when ports 80 and 443 are
|
||||
forwarded to this computer for public services. Other IPv4 clients get the
|
||||
connection closed. IPv6 global addresses are not filtered.
|
||||
The Hub checks every client before showing a login page. It runs as root, so it
|
||||
answers only loopback, private, VPN and link-local addresses and turns everyone
|
||||
else away. The check goes by the address a connection comes from, so it is a
|
||||
second lock and not a reason to forward port 8937: don't. Addresses outside the
|
||||
local ranges go in `sovran_systemsOS.hub.extraLanNetworks` in `custom.nix`;
|
||||
`sovran_systemsOS.hub.lanOnly = false` turns the check off.
|
||||
|
||||
### Public services and your home IP address
|
||||
|
||||
Server + Desktop publishes services under your own domain. The Dynamic DNS
|
||||
record at Njal.la then points at your home's public IP address, which anyone
|
||||
can look up (domain privacy does not hide it), and ports 80 and 443 are open
|
||||
to the whole internet. Public HTTPS certificates also list your service
|
||||
Publishing public services on Server + Desktop points a DDNS record at your
|
||||
home's public IP address, which anyone can look up, and lists your service
|
||||
hostnames in Certificate Transparency logs. Desktop publishes nothing. Node
|
||||
publishes nothing unless *Put BTCPay Server Online* or *Lightning Wallet
|
||||
Connections* is on. See
|
||||
[Server + Desktop and your home IP address](README.md#server--desktop-and-your-home-ip-address)
|
||||
for what this means and the alternatives.
|
||||
|
||||
Sovran_SystemsOS does not ask a STUN server, public DNS resolver, or “what is
|
||||
my IP” service for your address. The DDNS update asks Njal.la to use the
|
||||
address the request came from, and the address Njal.la reports back is the one
|
||||
Element calling and the Hub use.
|
||||
|
||||
### Bitcoin stack
|
||||
|
||||
Bitcoin and Lightning modules are maintained in the standalone
|
||||
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository
|
||||
and consumed as a flake input. OS-specific customizations (Second_Drive paths,
|
||||
operator user, Hub integration) are bridged by
|
||||
`modules/sovran-bitcoin-integration.nix`. The `nix-bitcoin.*` option namespace
|
||||
and `/etc/nix-bitcoin-secrets` path remain only for upgrade compatibility.
|
||||
Bitcoin and Lightning modules live in the standalone
|
||||
[Sovran_Bitcoin](https://github.com/naturallaw777/Sovran_Bitcoin) repository,
|
||||
consumed as a flake input and bridged by
|
||||
`modules/sovran-bitcoin-integration.nix`. The `nix-bitcoin.*` namespace and
|
||||
`/etc/nix-bitcoin-secrets` path remain only for upgrade compatibility.
|
||||
|
||||
### Supply chain and integrity
|
||||
|
||||
`flake.lock` pins flake inputs, and fetched source archives use fixed hashes.
|
||||
Builds still depend on pinned Nixpkgs, NixVim, btc-clients-nix, upstream source
|
||||
archives, and any configured binary cache. Keeping the Bitcoin modules in this
|
||||
repository reduces an external dependency; it does not remove supply-chain
|
||||
risk.
|
||||
Builds still depend on pinned Nixpkgs and other inputs, upstream source
|
||||
archives, and any configured binary cache.
|
||||
|
||||
The Hub integrity check verifies Nix store contents and compares the running
|
||||
system with a build from local `/etc/nixos`. It does not authenticate the release
|
||||
publisher or protect against an attacker who already controls root and can
|
||||
change both the system and local configuration.
|
||||
The Hub integrity check verifies Nix store contents against a build from local
|
||||
`/etc/nixos`. It does not authenticate the release publisher or protect against
|
||||
an attacker who already controls root.
|
||||
|
||||
### Access and service isolation
|
||||
|
||||
- Firewall enabled by default
|
||||
- Public SSH and remote desktop disabled by default
|
||||
- SSH Remote Access and remote desktop disabled by default
|
||||
- Separate service users and systemd sandboxing where supported
|
||||
- Administrative service ports bound to loopback where practical
|
||||
- Tor enforced for supported Bitcoin traffic and onion services
|
||||
- Public web services exposed only when enabled by the operator (this makes
|
||||
your home IP address public)
|
||||
|
||||
SSH Remote Access is the only supported administrative port that faces the
|
||||
network, and it is off by default. Enabling it listens on all interfaces and
|
||||
opens TCP port 22; the Hub then asks you to forward port 22, which puts SSH on
|
||||
the public internet. Key authentication only. Turn it off when you are done.
|
||||
|
||||
Tor reduces network exposure for configured Bitcoin services. It is not a
|
||||
guarantee against every IP leak, application bug, or traffic-analysis attack.
|
||||
|
||||
### Mullvad VPN (Desktop Only)
|
||||
|
||||
The Mullvad VPN client — daemon and app — is installed on the Desktop Role and
|
||||
on no other role: Server + Desktop and Bitcoin Node keep the one network
|
||||
configuration their services need. `modules/mullvad.nix` is a role gate around
|
||||
the stock NixOS service, with nothing configured by this system on top of it.
|
||||
|
||||
Two behaviours are the app's own, and worth knowing before you connect:
|
||||
|
||||
- While the tunnel is up, Mullvad's kill switch blocks everything outside it,
|
||||
the local network included, so Remote Desktop and other local devices stop
|
||||
answering until you switch local network sharing on in the app.
|
||||
- Name resolution follows the tunnel for as long as the client is enabled: the
|
||||
NixOS service turns on systemd-resolved and points NetworkManager at it.
|
||||
|
||||
Neither is set or restricted here; both are visible and changeable in the app.
|
||||
The firewall rules, the Hub's exposure checks, the Bitcoin stack and Tor are
|
||||
untouched by this module.
|
||||
|
||||
### Restricted support access
|
||||
|
||||
Support uses a per-session SSH key on the non-root `sovran-support` account.
|
||||
Tech Support runs over SSH, and the Hub refuses to start a session until SSH
|
||||
Remote Access is enabled (see above). Support uses a per-session SSH key on the
|
||||
non-root `sovran-support` account.
|
||||
|
||||
Sessions expire after 24 hours and have a small allowlist of `sudo` commands.
|
||||
Wallet paths receive deny ACLs unless the operator explicitly removes them.
|
||||
Disabling support removes the key and reapplies the ACLs.
|
||||
Disabling support removes the key and reapplies the ACLs but leaves SSH
|
||||
enabled.
|
||||
|
||||
Support events are written to `/var/log/sovran-support-audit.log`. This is a
|
||||
local audit log, not a cryptographically tamper-evident record.
|
||||
|
||||
@@ -15,6 +15,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
@@ -245,6 +246,218 @@ def _validate_ssh_pubkey(key: str) -> str:
|
||||
return key
|
||||
|
||||
|
||||
# ── Login throttling ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# Delays applied after each failed login, and the lockout that follows once an
|
||||
# address has tripped LOGIN_FAIL_MAX inside the window.
|
||||
#
|
||||
# LOGIN_FAIL_WINDOW has to be longer than the time it takes to reach
|
||||
# LOGIN_FAIL_MAX failures under the ramping delay: with a 2s ramp capped at
|
||||
# LOGIN_FAIL_MAX_DELAY, 10 attempts take about 80 seconds, so a 60 second
|
||||
# window would silently expire the earliest failures and the counter could
|
||||
# never reach the limit. 900s (15 minutes) keeps the whole ramp inside it.
|
||||
LOGIN_FAIL_DELAY = 2.0 # base delay; the nth failure waits n x this
|
||||
LOGIN_FAIL_MAX_DELAY = 10.0 # ceiling for a single delay
|
||||
LOGIN_FAIL_WINDOW = 900.0 # rolling window failures are counted in
|
||||
LOGIN_FAIL_MAX = 10 # failures in the window that trigger a lockout
|
||||
LOGIN_LOCKOUT_SECONDS = 300.0 # how long the lockout lasts
|
||||
|
||||
# Cap on how many addresses are tracked, so a distributed sweep cannot grow
|
||||
# the table without bound.
|
||||
_LOGIN_THROTTLE_MAX_IPS = 4096
|
||||
|
||||
|
||||
class LoginThrottle:
|
||||
"""Per-address failed-login tracking with a ramping delay and a lockout.
|
||||
|
||||
The delay ramps so a script hammering the login form slows down as it goes,
|
||||
and once LOGIN_FAIL_MAX failures land inside the window the address is
|
||||
refused outright for LOGIN_LOCKOUT_SECONDS. A successful login clears the
|
||||
address so a legitimate user who fumbles a password is not penalised later.
|
||||
|
||||
``sleep`` and ``clock`` are injectable so tests run without waiting.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fail_delay=LOGIN_FAIL_DELAY,
|
||||
max_delay=LOGIN_FAIL_MAX_DELAY,
|
||||
window=LOGIN_FAIL_WINDOW,
|
||||
max_failures=LOGIN_FAIL_MAX,
|
||||
lockout=LOGIN_LOCKOUT_SECONDS,
|
||||
max_tracked_ips=_LOGIN_THROTTLE_MAX_IPS,
|
||||
sleep=None,
|
||||
clock=None,
|
||||
):
|
||||
self._fail_delay = float(fail_delay)
|
||||
self._max_delay = float(max_delay)
|
||||
self._window = float(window)
|
||||
self._max_failures = int(max_failures)
|
||||
self._lockout = float(lockout)
|
||||
self._max_tracked_ips = int(max_tracked_ips)
|
||||
self._sleep = sleep if sleep is not None else time.sleep
|
||||
self._clock = clock if clock is not None else time.monotonic
|
||||
self._lock = threading.Lock()
|
||||
self._failures: dict[str, list[float]] = {}
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────────────
|
||||
|
||||
def _prune(self, ip, now):
|
||||
"""Drop timestamps outside the window; return what is left."""
|
||||
keep = [t for t in self._failures.get(ip, ()) if now - t < self._window]
|
||||
if keep:
|
||||
self._failures[ip] = keep
|
||||
else:
|
||||
self._failures.pop(ip, None)
|
||||
return keep
|
||||
|
||||
def _evict(self, now):
|
||||
"""Forget addresses that can no longer affect anything."""
|
||||
horizon = max(self._window, self._lockout)
|
||||
for ip in [i for i, ts in self._failures.items()
|
||||
if ts and now - max(ts) > horizon]:
|
||||
self._failures.pop(ip, None)
|
||||
while len(self._failures) > self._max_tracked_ips:
|
||||
oldest = min(self._failures, key=lambda i: max(self._failures[i]))
|
||||
self._failures.pop(oldest, None)
|
||||
|
||||
# ── public API ───────────────────────────────────────────────────────────
|
||||
|
||||
def delay_for(self, count):
|
||||
"""Return the delay owed after *count* failures in the current window."""
|
||||
if count <= 0:
|
||||
return 0.0
|
||||
return min(self._fail_delay * count, self._max_delay)
|
||||
|
||||
def failure_count(self, ip):
|
||||
"""Return the failures currently counted against *ip*."""
|
||||
with self._lock:
|
||||
return len(self._prune(ip, self._clock()))
|
||||
|
||||
def is_locked_out(self, ip):
|
||||
"""Return True while *ip* is inside a lockout."""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
failures = self._prune(ip, now)
|
||||
if len(failures) < self._max_failures:
|
||||
return False
|
||||
return (now - failures[-1]) < self._lockout
|
||||
|
||||
def remaining_lockout(self, ip):
|
||||
"""Return the seconds left in *ip*'s lockout, or 0.0 if not locked out."""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
failures = self._prune(ip, now)
|
||||
if len(failures) < self._max_failures:
|
||||
return 0.0
|
||||
return max(0.0, self._lockout - (now - failures[-1]))
|
||||
|
||||
def record_failure(self, ip):
|
||||
"""Record a failure for *ip* and serve out the delay it has earned.
|
||||
|
||||
Returns the delay that was applied. The lock is never held across the
|
||||
sleep, so one slow client cannot stall every other login.
|
||||
"""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
failures = list(self._prune(ip, now))
|
||||
failures.append(now)
|
||||
self._failures[ip] = failures
|
||||
count = len(failures)
|
||||
self._evict(now)
|
||||
delay = self.delay_for(count)
|
||||
if delay > 0:
|
||||
self._sleep(delay)
|
||||
return delay
|
||||
|
||||
def clear(self, ip):
|
||||
"""Forget *ip*, e.g. after a successful login."""
|
||||
with self._lock:
|
||||
self._failures.pop(ip, None)
|
||||
|
||||
def tracked_addresses(self):
|
||||
"""Return how many addresses are currently being tracked."""
|
||||
with self._lock:
|
||||
return len(self._failures)
|
||||
|
||||
|
||||
# ── Local-network client policy ───────────────────────────────────────────────
|
||||
#
|
||||
# The Hub runs as root: it can display stored credentials, reboot the machine
|
||||
# and rebuild the system. It answers this computer and the local network and
|
||||
# nobody else. Whether a packet may reach its port is the firewall's and the
|
||||
# router's business; this is the second lock, applied by the application itself
|
||||
# so that a port forward, a firewall mistake, or a machine that has a public
|
||||
# address does not put the login page in front of the internet.
|
||||
#
|
||||
# The Hub listens on IPv4 only (see sovran-hub.nix), so IPv6 clients never
|
||||
# reach it directly and the IPv6 ranges below only matter if that bind is ever
|
||||
# widened. Global IPv6 addresses (2000::/3) are deliberately not listed: a
|
||||
# global address belonging to a laptop on the LAN cannot be told apart from a
|
||||
# stranger's by the address alone, and allowing the range would let the whole
|
||||
# IPv6 internet through.
|
||||
LAN_ONLY_IPV4 = (
|
||||
"127.0.0.0/8", # this computer
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"100.64.0.0/10", # Tailscale and other VPN/CGNAT ranges
|
||||
"169.254.0.0/16", # link-local
|
||||
)
|
||||
LAN_ONLY_IPV6 = (
|
||||
"::1/128",
|
||||
"fc00::/7", # unique-local (covers fd00::/8)
|
||||
"fe80::/10", # link-local
|
||||
)
|
||||
|
||||
|
||||
class LanPolicy:
|
||||
"""Decides whether a client address counts as local.
|
||||
|
||||
``extra_networks`` are CIDR blocks an operator has declared local in
|
||||
addition to the built-in ranges, of either address family. ``enabled=False``
|
||||
turns the check off entirely; it is the one explicit way to do that.
|
||||
"""
|
||||
|
||||
def __init__(self, extra_networks=(), enabled=True):
|
||||
self.enabled = bool(enabled)
|
||||
nets = [ipaddress.ip_network(c, strict=False)
|
||||
for c in LAN_ONLY_IPV4 + LAN_ONLY_IPV6]
|
||||
for cidr in (extra_networks or ()):
|
||||
try:
|
||||
net = ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError:
|
||||
# A malformed entry must never widen the policy. Ignore it and
|
||||
# stay at the strictest interpretation.
|
||||
continue
|
||||
if net.prefixlen == 0:
|
||||
# 0.0.0.0/0 and ::/0 are "everyone". That is lan_only = false,
|
||||
# and it should be asked for by name, not arrive as a "network".
|
||||
continue
|
||||
nets.append(net)
|
||||
self._nets = tuple(nets)
|
||||
|
||||
def allows(self, ip):
|
||||
"""Return True if *ip* may reach the service."""
|
||||
if not self.enabled:
|
||||
return True
|
||||
if not ip:
|
||||
return False
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
# A dual-stack socket reports IPv4 clients as ::ffff:a.b.c.d. The
|
||||
# address that matters is the IPv4 one inside it.
|
||||
if addr.version == 6 and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped
|
||||
return any(addr.version == n.version and addr in n for n in self._nets)
|
||||
|
||||
@property
|
||||
def networks(self):
|
||||
return self._nets
|
||||
|
||||
|
||||
# ── Persistent Hub session store ─────────────────────────────────────────────
|
||||
|
||||
def load_session_store(path: str) -> dict[str, float]:
|
||||
|
||||
+145
-265
@@ -5,9 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import glob
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
@@ -38,6 +36,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from .config import load_config, load_versions
|
||||
from . import systemctl as sysctl
|
||||
from . import system_password as sp
|
||||
from sovran_nwc import nwc_hub_manager as _nwc_mgr
|
||||
from . import support_ops as _support_ops
|
||||
from .ddns_update import normalise_url as _normalise_ddns_url
|
||||
@@ -55,6 +54,13 @@ from .security_helpers import (
|
||||
_bech32_convertbits_decode,
|
||||
load_session_store,
|
||||
save_session_store,
|
||||
LoginThrottle,
|
||||
LanPolicy,
|
||||
LOGIN_FAIL_DELAY,
|
||||
LOGIN_FAIL_MAX_DELAY,
|
||||
LOGIN_FAIL_WINDOW,
|
||||
LOGIN_FAIL_MAX,
|
||||
LOGIN_LOCKOUT_SECONDS,
|
||||
)
|
||||
from .update_state import effective_update_status
|
||||
|
||||
@@ -143,9 +149,7 @@ AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled"
|
||||
|
||||
# ── Hub web authentication ────────────────────────────────────────
|
||||
|
||||
FREE_PASSWORD_FILE = "/var/lib/secrets/free-password"
|
||||
FREE_PASSWORD_FILE_WEB = "/var/lib/secrets/free-password-web"
|
||||
MIGRATION_NEWPASS_FILE = "/var/lib/secrets/free-password-migration-newpass"
|
||||
# free/root password verify/set/display lives in system_password.py
|
||||
HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret"
|
||||
SESSION_COOKIE_NAME = "hub_session"
|
||||
MANUAL_LOGOUT_COOKIE_NAME = "hub_manual_logout"
|
||||
@@ -175,11 +179,19 @@ _sessions_lock = Lock()
|
||||
_SESSION_PERSIST_MIN_INTERVAL = 30.0 # seconds
|
||||
_sessions_last_persist = 0.0
|
||||
|
||||
# Failed login tracking: ip → list of failure timestamps
|
||||
_login_failures: dict[str, list[float]] = {}
|
||||
LOGIN_FAIL_DELAY = 2.0 # seconds to sleep after a failed attempt
|
||||
LOGIN_FAIL_WINDOW = 60.0 # rolling window (seconds) for counting failures
|
||||
LOGIN_FAIL_MAX = 10 # max failures in window before extra delay
|
||||
# Failed login tracking.
|
||||
#
|
||||
# LOGIN_FAIL_MAX used to be declared here and never read anywhere: the only
|
||||
# thing a failed attempt cost an attacker was a flat 2 second delay, and there
|
||||
# was no lockout, no escalation and no ban. The throttling now lives in
|
||||
# security_helpers.LoginThrottle, which ramps the delay and refuses an address
|
||||
# outright once it has tripped LOGIN_FAIL_MAX inside the window.
|
||||
#
|
||||
# The window moved from 60s to 900s. With the ramping delay, reaching
|
||||
# LOGIN_FAIL_MAX takes about 80 seconds, so a 60 second window expired the
|
||||
# earliest failures before the limit could ever be reached — the old constant
|
||||
# could not have worked even if it had been wired up.
|
||||
_login_throttle = LoginThrottle()
|
||||
|
||||
# Public paths that are accessible without a valid session
|
||||
_AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/auto-login", "/api/ping"}
|
||||
@@ -709,58 +721,20 @@ def _is_authenticated(request: Request) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _read_free_password() -> str | None:
|
||||
"""Return the contents of the free-password secrets file, or None."""
|
||||
try:
|
||||
with open(FREE_PASSWORD_FILE, "r") as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
"""Return a scrypt-hash with 16-byte salt, formatted salt_hex:hash_hex."""
|
||||
salt = os.urandom(16)
|
||||
hashed = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1)
|
||||
return salt.hex() + ":" + hashed.hex()
|
||||
|
||||
|
||||
def _check_password(submitted: str) -> bool:
|
||||
"""Constant-time comparison against stored scrypt hash or legacy plaintext."""
|
||||
for fp in (FREE_PASSWORD_FILE_WEB, FREE_PASSWORD_FILE):
|
||||
try:
|
||||
with open(fp, "r") as f:
|
||||
stored = f.read().strip()
|
||||
except Exception:
|
||||
continue
|
||||
if not stored:
|
||||
continue
|
||||
if ":" in stored:
|
||||
try:
|
||||
salt_hex, hash_hex = stored.split(":", 1)
|
||||
salt = bytes.fromhex(salt_hex)
|
||||
submitted_hash = hashlib.scrypt(submitted.encode(), salt=salt, n=16384, r=8, p=1)
|
||||
if hmac.compare_digest(submitted_hash.hex().encode(), hash_hex.encode()):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
if hmac.compare_digest(submitted.encode(), stored.encode()):
|
||||
try:
|
||||
with open(FREE_PASSWORD_FILE_WEB, "w") as f:
|
||||
f.write(_hash_password(submitted))
|
||||
os.chmod(FREE_PASSWORD_FILE_WEB, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
return False
|
||||
"""Verify against /etc/shadow -- the same password GDM accepts.
|
||||
|
||||
On success, heal a stale display copy with the password the user just
|
||||
proved (e.g. changed in GNOME Settings or the pre-upgrade Hub)."""
|
||||
if not sp.verify_system_password(submitted):
|
||||
return False
|
||||
sp.record_known_password(submitted, only_if_stale=True)
|
||||
return True
|
||||
|
||||
|
||||
def _ensure_onboarding_reopened_for_migration() -> None:
|
||||
"""Re-open onboarding when a migration password disclosure is pending."""
|
||||
if not os.path.isfile(MIGRATION_NEWPASS_FILE):
|
||||
if not sp.ensure_migration_pending(_generate_diceware_password):
|
||||
return
|
||||
try:
|
||||
os.remove(ONBOARDING_FLAG)
|
||||
@@ -770,19 +744,20 @@ def _ensure_onboarding_reopened_for_migration() -> None:
|
||||
logger.warning("Could not clear onboarding flag for migration flow: %s", exc)
|
||||
|
||||
|
||||
def _record_failure(client_ip: str) -> None:
|
||||
"""Record a failed login attempt and apply a rate-limit delay.
|
||||
def _record_failure(client_ip: str) -> float:
|
||||
"""Record a failed login attempt and apply the throttling delay.
|
||||
|
||||
Must always be called via loop.run_in_executor() so that the blocking
|
||||
time.sleep() does not stall the asyncio event loop.
|
||||
|
||||
Returns the delay that was applied.
|
||||
"""
|
||||
now = time.time()
|
||||
failures = _login_failures.setdefault(client_ip, [])
|
||||
# Prune old entries outside the window
|
||||
_login_failures[client_ip] = [t for t in failures if now - t < LOGIN_FAIL_WINDOW]
|
||||
_login_failures[client_ip].append(now)
|
||||
# Sleep in the thread-pool thread to slow brute-force without blocking the loop
|
||||
time.sleep(LOGIN_FAIL_DELAY)
|
||||
return _login_throttle.record_failure(client_ip)
|
||||
|
||||
|
||||
def _is_locked_out(client_ip: str) -> bool:
|
||||
"""Return True while *client_ip* is inside a lockout."""
|
||||
return _login_throttle.is_locked_out(client_ip)
|
||||
|
||||
|
||||
# ── Authentication middleware ─────────────────────────────────────
|
||||
@@ -806,8 +781,61 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ── Local-network middleware ───────────────────────────────────
|
||||
#
|
||||
# The Hub runs as root. Whether a packet may reach its port is up to the
|
||||
# firewall and the router; this is the second lock, so a port forward or a
|
||||
# firewall mistake does not put the login page in front of the internet. It
|
||||
# runs before authentication: a client that is not on the local network never
|
||||
# sees the login page at all.
|
||||
#
|
||||
# Built from the Nix-generated config. lan_only defaults to True, so a Hub built
|
||||
# without the key still turns off-network clients away rather than failing open.
|
||||
_hub_cfg = load_config()
|
||||
_lan_policy = LanPolicy(
|
||||
enabled=bool(_hub_cfg.get("lan_only", True)),
|
||||
extra_networks=tuple(_hub_cfg.get("lan_extra_networks") or ()),
|
||||
)
|
||||
|
||||
|
||||
class LanOnlyMiddleware(BaseHTTPMiddleware):
|
||||
"""Refuse clients that are not on this computer or the local network."""
|
||||
|
||||
# Each refused address is logged once, and the list is capped: a scanner
|
||||
# must not be able to fill the journal or the process's memory.
|
||||
_MAX_LOGGED = 256
|
||||
|
||||
def __init__(self, app, policy):
|
||||
super().__init__(app)
|
||||
self._policy = policy
|
||||
self._logged: set = set()
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
client_ip = request.client.host if request.client else None
|
||||
if not self._policy.allows(client_ip):
|
||||
self._note_refusal(client_ip)
|
||||
return JSONResponse(
|
||||
{"detail": "Not available from this network"}, status_code=403,
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
def _note_refusal(self, client_ip):
|
||||
if client_ip in self._logged or len(self._logged) >= self._MAX_LOGGED:
|
||||
return
|
||||
self._logged.add(client_ip)
|
||||
logger.warning(
|
||||
"Refused a Hub request from %r: not this computer or a local "
|
||||
"network. If that address is yours, add its network to "
|
||||
"sovran_systemsOS.hub.extraLanNetworks.",
|
||||
client_ip,
|
||||
)
|
||||
|
||||
|
||||
app.add_middleware(AuthMiddleware)
|
||||
app.add_middleware(NoCacheMiddleware)
|
||||
# Registered last so it runs outermost: a client that is not on the local
|
||||
# network is turned away before authentication is considered at all.
|
||||
app.add_middleware(LanOnlyMiddleware, policy=_lan_policy)
|
||||
|
||||
_ICONS_DIR = os.environ.get(
|
||||
"SOVRAN_HUB_ICONS",
|
||||
@@ -1777,6 +1805,18 @@ def _resolve_credential(cred: dict) -> dict | None:
|
||||
if not filepath:
|
||||
return None
|
||||
|
||||
# free/root login passwords: shown only if the display copy really is the
|
||||
# current one (system_password verifies it against /etc/shadow).
|
||||
if filepath in (sp.DISPLAY_FILE, sp.ROOT_DISPLAY_FILE):
|
||||
pw = (sp.read_display_password() if filepath == sp.DISPLAY_FILE
|
||||
else sp.read_root_display_password())
|
||||
if pw is None:
|
||||
hint = ("Changed outside the Hub — finish the password step in onboarding to see it here."
|
||||
if sp.migration_pending() else
|
||||
"Changed outside the Hub — sign out and back in to show it here.")
|
||||
return {"label": label, "value": "", "unknown": True, "hint": hint}
|
||||
return {"label": label, "value": prefix + pw + suffix, "multiline": multiline}
|
||||
|
||||
try:
|
||||
with open(filepath, "r") as f:
|
||||
raw = f.read().strip()
|
||||
@@ -2638,10 +2678,24 @@ async def api_login(req: LoginRequest, request: Request):
|
||||
"""Validate the Hub password and issue a session cookie."""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Refuse outright while the address is locked out. This runs before the
|
||||
# scrypt hash, so a locked-out client costs almost nothing to reject.
|
||||
if _is_locked_out(client_ip):
|
||||
remaining = int(_login_throttle.remaining_lockout(client_ip) // 60) + 1
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Too many failed attempts. Try again in about {remaining} minute(s).",
|
||||
)
|
||||
|
||||
ok = await loop.run_in_executor(None, _check_password, req.password)
|
||||
if not ok:
|
||||
await loop.run_in_executor(None, _record_failure, client_ip)
|
||||
raise HTTPException(status_code=401, detail="Incorrect password")
|
||||
|
||||
# A real login clears the address, so fumbling a password once in a while
|
||||
# does not accumulate towards a lockout.
|
||||
_login_throttle.clear(client_ip)
|
||||
token = _create_session()
|
||||
response = JSONResponse({"ok": True})
|
||||
response.set_cookie(
|
||||
@@ -2780,69 +2834,19 @@ async def api_onboarding_complete():
|
||||
@app.get("/api/migration/password-status")
|
||||
async def api_migration_password_status():
|
||||
"""Return whether a migration-generated password is awaiting acknowledgement."""
|
||||
try:
|
||||
with open(MIGRATION_NEWPASS_FILE, "r") as f:
|
||||
return {"pending": True, "password": f.read().strip()}
|
||||
except FileNotFoundError:
|
||||
return {"pending": False}
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not read migration password: {exc}")
|
||||
pw = sp.migration_pending()
|
||||
return {"pending": True, "password": pw} if pw else {"pending": False}
|
||||
|
||||
|
||||
@app.post("/api/migration/password-acknowledge")
|
||||
async def api_migration_password_acknowledge():
|
||||
"""Acknowledge the migration password and update /etc/shadow to match."""
|
||||
# Read the new password before deleting the file
|
||||
new_password = None
|
||||
"""Acknowledge the migration password: apply it to /etc/shadow and the display copy."""
|
||||
try:
|
||||
with open(MIGRATION_NEWPASS_FILE, "r") as f:
|
||||
new_password = f.read().strip()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not read migration password: {exc}")
|
||||
|
||||
# Update /etc/shadow so GDM accepts the new password going forward
|
||||
if new_password:
|
||||
chpasswd_bin = (
|
||||
shutil.which("chpasswd")
|
||||
or ("/run/current-system/sw/bin/chpasswd"
|
||||
if os.path.isfile("/run/current-system/sw/bin/chpasswd") else None)
|
||||
)
|
||||
if chpasswd_bin:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[chpasswd_bin],
|
||||
input=f"free:{new_password}",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"chpasswd failed during migration acknowledge (rc=%d): %s",
|
||||
result.returncode,
|
||||
(result.stderr or result.stdout).strip(),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("chpasswd exception during migration acknowledge: %s", exc)
|
||||
|
||||
# Clear only the locked keyring databases, leaving the directory and 'default' pointer intact.
|
||||
keyring_dir = "/home/free/.local/share/keyrings"
|
||||
keyring_files = glob.glob(os.path.join(keyring_dir, "*.keyring"))
|
||||
for kf in keyring_files:
|
||||
try:
|
||||
os.remove(kf)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not remove old keyring file %s: %s", kf, exc)
|
||||
|
||||
# Clear the pending marker
|
||||
try:
|
||||
os.remove(MIGRATION_NEWPASS_FILE)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not clear migration password: {exc}")
|
||||
|
||||
ok = await asyncio.get_event_loop().run_in_executor(None, sp.acknowledge_migration)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise HTTPException(status_code=500, detail=(exc.stderr or "chpasswd failed").strip())
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="No migration password pending.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -5475,86 +5479,16 @@ async def api_security_reset():
|
||||
new_free_password = _generate_diceware_password()
|
||||
new_root_password = _generate_diceware_password()
|
||||
|
||||
# Locate chpasswd
|
||||
chpasswd_bin = (
|
||||
shutil.which("chpasswd")
|
||||
or ("/run/current-system/sw/bin/chpasswd"
|
||||
if os.path.isfile("/run/current-system/sw/bin/chpasswd") else None)
|
||||
)
|
||||
|
||||
if chpasswd_bin:
|
||||
# Set free user password
|
||||
# Set the new passwords (shadow first, then the plaintext display copies the
|
||||
# credentials view shows). No keyring handling: the login keyring is
|
||||
# password-independent and does not need re-keying or wiping.
|
||||
for setter, pw, tag in ((sp.set_free_password, new_free_password, "free"),
|
||||
(sp.set_root_password, new_root_password, "root")):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[chpasswd_bin],
|
||||
input=f"free:{new_free_password}",
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("chpasswd free failed: %s", (result.stderr or result.stdout).strip())
|
||||
errors.append("chpasswd_free_failed")
|
||||
setter(pw)
|
||||
except Exception as exc:
|
||||
logger.warning("chpasswd free error: %s", exc)
|
||||
errors.append("chpasswd_free_failed")
|
||||
|
||||
# Set root password
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[chpasswd_bin],
|
||||
input=f"root:{new_root_password}",
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("chpasswd root failed: %s", (result.stderr or result.stdout).strip())
|
||||
errors.append("chpasswd_root_failed")
|
||||
except Exception as exc:
|
||||
logger.warning("chpasswd root error: %s", exc)
|
||||
errors.append("chpasswd_root_failed")
|
||||
else:
|
||||
logger.warning("chpasswd not found; passwords not reset")
|
||||
errors.append("chpasswd_not_found")
|
||||
|
||||
# Write new passwords to secrets files
|
||||
try:
|
||||
os.makedirs("/var/lib/secrets", exist_ok=True)
|
||||
try:
|
||||
import subprocess
|
||||
subprocess.run(
|
||||
["chpasswd"],
|
||||
input=f"free:{new_free_password}\n".encode(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open(FREE_PASSWORD_FILE_WEB, "w") as f:
|
||||
f.write(_hash_password(new_free_password))
|
||||
os.chmod(FREE_PASSWORD_FILE_WEB, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("write free-password error: %s", exc)
|
||||
errors.append("write_free_password_failed")
|
||||
|
||||
try:
|
||||
os.makedirs("/var/lib/secrets", exist_ok=True)
|
||||
with open("/var/lib/secrets/root-password", "w") as f:
|
||||
f.write(_hash_password(new_root_password))
|
||||
os.chmod("/var/lib/secrets/root-password", 0o600)
|
||||
except Exception as exc:
|
||||
logger.warning("write root-password error: %s", exc)
|
||||
errors.append("write_root_password_failed")
|
||||
|
||||
# Clear only the locked keyring databases, leaving the directory and 'default' pointer intact.
|
||||
keyring_dir = "/home/free/.local/share/keyrings"
|
||||
keyring_files = glob.glob(os.path.join(keyring_dir, "*.keyring"))
|
||||
for kf in keyring_files:
|
||||
try:
|
||||
os.remove(kf)
|
||||
except OSError as exc:
|
||||
logger.warning("keyring wipe error for %s: %s", kf, exc)
|
||||
errors.append("keyring_wipe_failed")
|
||||
logger.warning("security reset: set %s password failed: %s", tag, exc)
|
||||
errors.append(f"set_{tag}_password_failed")
|
||||
|
||||
# The user performed a full security reset — the banner's purpose is served.
|
||||
try:
|
||||
@@ -5670,83 +5604,29 @@ async def api_security_verify_integrity():
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
confirm_password: str
|
||||
|
||||
|
||||
@app.post("/api/change-password")
|
||||
async def api_change_password(req: ChangePasswordRequest):
|
||||
"""Change the system 'free' user password.
|
||||
"""Change the system 'free' user password (also the Hub login password).
|
||||
|
||||
Updates /etc/shadow via chpasswd and writes the new password to
|
||||
/var/lib/secrets/free-password so the Hub credentials view stays in sync.
|
||||
"""
|
||||
Updates /etc/shadow via system_password.set_free_password, which also keeps
|
||||
the /var/lib/secrets/free-password display copy in sync."""
|
||||
if not sp.verify_system_password(req.current_password):
|
||||
raise HTTPException(status_code=403, detail="Current password is incorrect.")
|
||||
if not req.new_password:
|
||||
raise HTTPException(status_code=400, detail="New password must not be empty.")
|
||||
if req.new_password != req.confirm_password:
|
||||
raise HTTPException(status_code=400, detail="Passwords do not match.")
|
||||
if len(req.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters long.")
|
||||
|
||||
# Locate chpasswd binary (NixOS puts it in the Nix store, not /usr/bin)
|
||||
chpasswd_bin = (
|
||||
shutil.which("chpasswd")
|
||||
or ("/run/current-system/sw/bin/chpasswd"
|
||||
if os.path.isfile("/run/current-system/sw/bin/chpasswd") else None)
|
||||
)
|
||||
if chpasswd_bin is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="chpasswd binary not found. Cannot update system password.",
|
||||
)
|
||||
|
||||
# Update /etc/shadow via chpasswd
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[chpasswd_bin],
|
||||
input=f"free:{req.new_password}",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip() or "chpasswd failed."
|
||||
raise HTTPException(status_code=500, detail=detail)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Failed to update system password: %s", exc)
|
||||
raise HTTPException(status_code=500, detail="Failed to update system password.")
|
||||
|
||||
# Write new password to secrets file so Hub credentials stay in sync
|
||||
try:
|
||||
os.makedirs(os.path.dirname(FREE_PASSWORD_FILE), exist_ok=True)
|
||||
# Write secure web-only hash; sync system shadow via chpasswd (memory)
|
||||
try:
|
||||
import subprocess
|
||||
subprocess.run(
|
||||
["chpasswd"],
|
||||
input=f"free:{req.new_password}\n".encode(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Log but do not block; web hash is the critical fix
|
||||
pass
|
||||
with open(FREE_PASSWORD_FILE_WEB, "w") as f:
|
||||
f.write(_hash_password(req.new_password))
|
||||
os.chmod(FREE_PASSWORD_FILE_WEB, 0o600)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to write secrets file: %s", exc)
|
||||
raise HTTPException(status_code=500, detail="Failed to write secrets file.")
|
||||
|
||||
# Clear only the locked keyring databases, leaving the directory and 'default' pointer intact.
|
||||
keyring_dir = "/home/free/.local/share/keyrings"
|
||||
for kf in glob.glob(os.path.join(keyring_dir, "*.keyring")):
|
||||
try:
|
||||
os.remove(kf)
|
||||
except OSError:
|
||||
pass # Non-fatal: keyring will be re-created on next login regardless
|
||||
|
||||
await asyncio.get_event_loop().run_in_executor(None, sp.set_free_password, req.new_password)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise HTTPException(status_code=500, detail=(exc.stderr or "chpasswd failed").strip())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
||||
@@ -1520,3 +1520,6 @@
|
||||
margin-top: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* password cleanup: display copy changed outside the Hub */
|
||||
.creds-value.creds-unknown { font-style: italic; opacity: 0.75; }
|
||||
@@ -34,6 +34,10 @@ function _renderCredsHtml(credentials, unit) {
|
||||
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '</div>';
|
||||
continue;
|
||||
}
|
||||
if (cred.unknown) {
|
||||
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div><div class="creds-value-wrap"><div class="creds-value creds-unknown">' + escHtml(cred.hint || "Changed outside the Hub — sign out and back in to show it here.") + '</div></div></div>';
|
||||
continue;
|
||||
}
|
||||
var displayValue = linkify(cred.value);
|
||||
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
|
||||
}
|
||||
@@ -1293,6 +1297,11 @@ function openSystemChangePasswordModal(unit, name, icon) {
|
||||
'<div class="sys-chpw-title">🔑 Change Free Account & Hub Login Password</div>' +
|
||||
'<div class="sys-chpw-desc">This updates the password for the <strong>free</strong> user account. <strong>This is also your Sovran Hub login password</strong> — both will change.</div>' +
|
||||
'</div>' +
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-current">Current Password</label>' +
|
||||
'<div class="pw-input-wrap">' +
|
||||
'<input class="matrix-form-input" type="password" id="sys-chpw-current" placeholder="Current password" autocomplete="current-password">' +
|
||||
'<button type="button" class="pw-toggle-btn" id="sys-chpw-current-toggle" aria-label="Toggle password visibility">👁</button>' +
|
||||
'</div></div>' +
|
||||
'<div class="matrix-form-group"><label class="matrix-form-label" for="sys-chpw-new">New Password</label>' +
|
||||
'<div class="pw-input-wrap">' +
|
||||
'<input class="matrix-form-input" type="password" id="sys-chpw-new" placeholder="New strong password" autocomplete="new-password">' +
|
||||
@@ -1315,6 +1324,13 @@ function openSystemChangePasswordModal(unit, name, icon) {
|
||||
openServiceDetailModal(unit, name, icon);
|
||||
});
|
||||
|
||||
document.getElementById("sys-chpw-current-toggle").addEventListener("click", function() {
|
||||
var inp = document.getElementById("sys-chpw-current");
|
||||
var isHidden = inp.type === "password";
|
||||
inp.type = isHidden ? "text" : "password";
|
||||
this.textContent = isHidden ? "👁🗨" : "👁";
|
||||
});
|
||||
|
||||
document.getElementById("sys-chpw-new-toggle").addEventListener("click", function() {
|
||||
var inp = document.getElementById("sys-chpw-new");
|
||||
var isHidden = inp.type === "password";
|
||||
@@ -1332,12 +1348,13 @@ function openSystemChangePasswordModal(unit, name, icon) {
|
||||
document.getElementById("sys-chpw-submit-btn").addEventListener("click", async function() {
|
||||
var submitBtn = document.getElementById("sys-chpw-submit-btn");
|
||||
var resultEl = document.getElementById("sys-chpw-result");
|
||||
var currentPassword = document.getElementById("sys-chpw-current").value || "";
|
||||
var newPassword = document.getElementById("sys-chpw-new").value || "";
|
||||
var confirmPassword = document.getElementById("sys-chpw-confirm").value || "";
|
||||
|
||||
if (!newPassword || !confirmPassword) {
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
resultEl.className = "matrix-form-result error";
|
||||
resultEl.textContent = "Both password fields are required.";
|
||||
resultEl.textContent = "All password fields are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1362,7 +1379,7 @@ function openSystemChangePasswordModal(unit, name, icon) {
|
||||
await apiFetch("/api/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ new_password: newPassword, confirm_password: confirmPassword })
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword, confirm_password: confirmPassword })
|
||||
});
|
||||
resultEl.className = "matrix-form-result success";
|
||||
resultEl.textContent = "✅ Free account & Hub login password changed successfully.";
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""system_password.py -- the one place that knows about the 'free' (and root) password.
|
||||
|
||||
Rules
|
||||
* /etc/shadow is the ONLY source of truth for authentication: Hub login == GDM login.
|
||||
* /var/lib/secrets/free-password (0600 root) is a DISPLAY copy for the Hub's credentials
|
||||
view. Only set_free_password() writes it, and it is verified against /etc/shadow before
|
||||
it is shown, so the Hub can never show a stale password.
|
||||
* Legacy / unknown-password machines go through the *migration* flow the onboarding already
|
||||
has: generate a new diceware password, show it, and apply it on acknowledgement. Until
|
||||
then the old password keeps working everywhere (shadow is untouched).
|
||||
* No keyring code anywhere: the GNOME login keyring is decoupled from the login password
|
||||
(blank-password keyring, pam_gnome_keyring off), so nothing has to be wiped or re-keyed.
|
||||
|
||||
Needs libxcrypt. On NixOS the Hub service gets: environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1"
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
USER = "free"
|
||||
SECRETS_DIR = "/var/lib/secrets"
|
||||
DISPLAY_FILE = f"{SECRETS_DIR}/free-password"
|
||||
ROOT_DISPLAY_FILE = f"{SECRETS_DIR}/root-password"
|
||||
MIGRATION_FILE = f"{SECRETS_DIR}/free-password-migration-newpass" # same path the onboarding UI already uses
|
||||
LIVE_ISO_MARKER = "/iso" # NixOS live media mounts the image here; never start a migration there
|
||||
|
||||
_lock = threading.Lock() # crypt() returns a static buffer; serialise calls
|
||||
_libcrypt = None
|
||||
|
||||
|
||||
# ── low level ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _crypt():
|
||||
global _libcrypt
|
||||
if _libcrypt is None:
|
||||
path = os.environ.get("LIBCRYPT") or ctypes.util.find_library("crypt") or "libcrypt.so.1"
|
||||
lib = ctypes.CDLL(path)
|
||||
lib.crypt.restype = ctypes.c_char_p
|
||||
lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
_libcrypt = lib
|
||||
return _libcrypt
|
||||
|
||||
|
||||
def shadow_hash(user: str = USER) -> str | None:
|
||||
"""Password hash from /etc/shadow, or None if the account has none / is locked."""
|
||||
try:
|
||||
with open("/etc/shadow", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
fields = line.rstrip("\n").split(":")
|
||||
if fields[0] == user:
|
||||
h = fields[1]
|
||||
return h if h and h[0] not in "!*" else None
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def verify_system_password(password: str, user: str = USER) -> bool:
|
||||
"""True iff `password` is the account's current login password
|
||||
(yescrypt, sha512crypt, ... whatever chpasswd produced)."""
|
||||
h = shadow_hash(user)
|
||||
if not h or not password:
|
||||
return False
|
||||
with _lock:
|
||||
out = _crypt().crypt(password.encode(), h.encode())
|
||||
return out is not None and hmac.compare_digest(out, h.encode())
|
||||
|
||||
|
||||
def _write_secret(path: str, value: str) -> None:
|
||||
os.makedirs(SECRETS_DIR, mode=0o700, exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w", encoding="utf-8") as f:
|
||||
f.write(value + "\n")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _read_secret(path: str) -> str | None:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
v = f.read().rstrip("\n")
|
||||
return v or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _chpasswd(user: str, password: str) -> None:
|
||||
import shutil
|
||||
bin_ = shutil.which("chpasswd") or "/run/current-system/sw/bin/chpasswd"
|
||||
subprocess.run([bin_], input=f"{user}:{password}\n", text=True, check=True, capture_output=True)
|
||||
|
||||
|
||||
# ── the single writers ─────────────────────────────────────────────────────
|
||||
|
||||
def set_free_password(new_password: str) -> None:
|
||||
"""System password first, then the display copy. Used by: change-password, reset,
|
||||
migration acknowledgement, the CLI."""
|
||||
if not new_password:
|
||||
raise ValueError("empty password")
|
||||
_chpasswd(USER, new_password)
|
||||
_write_secret(DISPLAY_FILE, new_password)
|
||||
|
||||
|
||||
def set_root_password(new_password: str) -> None:
|
||||
"""Same contract for root (the credentials view lists it too)."""
|
||||
if not new_password:
|
||||
raise ValueError("empty password")
|
||||
_chpasswd("root", new_password)
|
||||
_write_secret(ROOT_DISPLAY_FILE, new_password)
|
||||
|
||||
|
||||
# ── what the Hub shows ─────────────────────────────────────────────────────
|
||||
|
||||
def read_display_password() -> str | None:
|
||||
"""The 'free' password to SHOW -- only if it really is the current one."""
|
||||
pw = _read_secret(DISPLAY_FILE)
|
||||
return pw if pw and verify_system_password(pw) else None
|
||||
|
||||
|
||||
def read_root_display_password() -> str | None:
|
||||
pw = _read_secret(ROOT_DISPLAY_FILE)
|
||||
if not pw or ":" in pw: # ':' = scrypt hash written by older reset code -> not showable
|
||||
return None
|
||||
return pw if verify_system_password(pw, "root") else None
|
||||
|
||||
|
||||
def _display_state(path: str) -> str:
|
||||
""""missing" | "legacy" | "present" -- "present" only means a plaintext copy
|
||||
exists; it can still be stale (use read_display_password() to check that)."""
|
||||
pw = _read_secret(path)
|
||||
if pw is None:
|
||||
return "missing"
|
||||
return "legacy" if ":" in pw else "present"
|
||||
|
||||
|
||||
# ── migration flow (legacy machines) ────────────────────────────────────────
|
||||
|
||||
def migration_pending() -> str | None:
|
||||
"""The generated password awaiting acknowledgement, if any."""
|
||||
return _read_secret(MIGRATION_FILE)
|
||||
|
||||
|
||||
def ensure_migration_pending(generate: Callable[[], str]) -> bool:
|
||||
"""Legacy detection. Call it where onboarding status is computed.
|
||||
|
||||
Condition: the account HAS a password (so this is not the first boot) but the Hub
|
||||
has NO display copy of it at all (file missing or still the old hash format).
|
||||
Then generate a new password once and park it for the onboarding "migration
|
||||
password" step. Nothing is applied until the user acknowledges, so the old
|
||||
password keeps working at GDM and in the Hub. Returns True when a migration is
|
||||
pending (caller re-opens onboarding).
|
||||
|
||||
A copy that exists but merely fails to verify (password changed in GNOME
|
||||
Settings, `passwd`, or the pre-upgrade Hub) is DRIFT, not a legacy install:
|
||||
it never triggers a migration and never changes any password -- it is healed
|
||||
automatically at the next login via record_known_password(pw, only_if_stale=True).
|
||||
|
||||
Never fires on the live ISO (/iso) or while shadow is still locked."""
|
||||
if os.path.exists(LIVE_ISO_MARKER):
|
||||
return False
|
||||
if migration_pending():
|
||||
return True
|
||||
if shadow_hash() is None:
|
||||
return False
|
||||
if _display_state(DISPLAY_FILE) == "present":
|
||||
return False # copy exists: current, or just stale (drift)
|
||||
_write_secret(MIGRATION_FILE, generate())
|
||||
return True
|
||||
|
||||
|
||||
def acknowledge_migration() -> bool:
|
||||
"""User has written the new password down: make it real, then clear the pending file."""
|
||||
pw = migration_pending()
|
||||
if not pw:
|
||||
return False
|
||||
set_free_password(pw)
|
||||
os.remove(MIGRATION_FILE)
|
||||
return True
|
||||
|
||||
|
||||
def record_known_password(password: str, only_if_stale: bool = False) -> bool:
|
||||
""""Keep my current password" (migration step): if the user types the correct
|
||||
current password, store it as the display copy and cancel the migration.
|
||||
|
||||
only_if_stale=True is the login-handler heal: write ONLY when an existing
|
||||
plaintext copy is stale, never create a missing one (a missing copy is the
|
||||
legacy case and belongs to the migration flow)."""
|
||||
if not verify_system_password(password):
|
||||
return False
|
||||
if only_if_stale and _display_state(DISPLAY_FILE) != "present":
|
||||
return False
|
||||
if _read_secret(DISPLAY_FILE) != password:
|
||||
_write_secret(DISPLAY_FILE, password)
|
||||
try:
|
||||
os.remove(MIGRATION_FILE)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def generate_diceware_password(words: list[str] | None = None, n: int = 4) -> str:
|
||||
"""word-word-word-word-NN, same shape the installer/reset produce (CSPRNG)."""
|
||||
words = words or ("apple barn brook cabin cedar cloud coral crane delta eagle ember fern field "
|
||||
"flame flora flint frost grove haven hedge holly heron jade juniper kelp larch "
|
||||
"lemon lilac linden loch lotus maple marsh meadow mist mossy mount oak ocean "
|
||||
"olive petal pine pixel plum pond prism quartz raven ridge river robin rocky "
|
||||
"rose rowan sage sand sierra silver slate snow solar spark spruce stone storm "
|
||||
"summit swift thorn tide timber torch trout vale vault vine walnut wave willow "
|
||||
"wren amber aspen birch blaze bloom bluff coast copper crest dune elder fjord "
|
||||
"forge glade glen glow gulf").split()
|
||||
return "-".join(secrets.choice(words) for _ in range(n)) + f"-{secrets.randbelow(100):02d}"
|
||||
|
||||
|
||||
if __name__ == "__main__": # tiny CLI for credentials.nix: echo NEW | system_password.py set-free
|
||||
import sys
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
if cmd in ("set-free", "set-root"):
|
||||
pw = sys.stdin.readline().rstrip("\n")
|
||||
(set_free_password if cmd == "set-free" else set_root_password)(pw)
|
||||
print("ok")
|
||||
elif cmd == "verify":
|
||||
sys.exit(0 if verify_system_password(sys.stdin.readline().rstrip("\n")) else 1)
|
||||
else:
|
||||
print("usage: system_password.py set-free|set-root|verify (password on stdin)", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
@@ -264,7 +264,7 @@
|
||||
<h3>Network</h3>
|
||||
<div class="net-row"><span class="net-k">LAN</span><span class="ip-value" id="ip-internal">…</span></div>
|
||||
<div class="net-row"><span class="net-k">WAN</span><span class="ip-value" id="ip-external">…</span></div>
|
||||
<div class="sub">sovransystemsos.local</div>
|
||||
<div class="sub">sovransystemsos.local:8937</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcome-dyn" id="wc-more"></div>
|
||||
@@ -520,7 +520,7 @@
|
||||
<div class="security-reset-password-box" id="security-reset-new-password"> </div>
|
||||
<p class="security-reset-password-warning">
|
||||
✍️ <strong>Write this down now.</strong><br />
|
||||
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>.
|
||||
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local:8937</em>.
|
||||
</p>
|
||||
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
||||
I have written down my new password — Restart Entire System
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"caddy.service": "2.8.4",
|
||||
"tor.service": "0.4.8.12",
|
||||
"gnome-remote-desktop.service": "46.0",
|
||||
"bitcoind.service": "27.1.0",
|
||||
"electrs.service": "0.10.6",
|
||||
"lnd.service": "0.18.0",
|
||||
"rtl.service": "0.15.12",
|
||||
"btcpayserver.service": "2.4.2",
|
||||
"albyhub.service": "1.24.0",
|
||||
"mempool.service": "3.2.1",
|
||||
"matrix-synapse.service": "1.115.0",
|
||||
"livekit.service": "1.13.6",
|
||||
"vaultwarden.service": "1.32.0",
|
||||
"haven-relay.service": "0.1.0"
|
||||
"caddy.service": "2.11.7",
|
||||
"tor.service": "0.4.9.14",
|
||||
"gnome-remote-desktop.service": "50.2",
|
||||
"bitcoind.service": "31.1",
|
||||
"electrs.service": "0.12.0",
|
||||
"lnd.service": "0.21.4-beta",
|
||||
"rtl.service": "0.15.13",
|
||||
"btcpayserver.service": "2.4.5",
|
||||
"albyhub.service": "2.0.0",
|
||||
"mempool.service": "3.3.1",
|
||||
"matrix-synapse.service": "1.162.0",
|
||||
"livekit.service": "1.13.8",
|
||||
"vaultwarden.service": "1.37.4",
|
||||
"haven-relay.service": "1.2.2"
|
||||
}
|
||||
@@ -74,18 +74,6 @@
|
||||
services.printing.enable = true;
|
||||
systemd.enableEmergencyMode = false;
|
||||
environment.gnome.excludePackages = [ pkgs.gnome-tour ];
|
||||
security.pam.services.gdm-password.enableGnomeKeyring = true;
|
||||
security.pam.services.gdm-autologin.enableGnomeKeyring = true;
|
||||
|
||||
# Declaratively guarantee the GNOME Keyring default pointer exists.
|
||||
# Defining the full path ensures root doesn't accidentally lock the user out of .local
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /home/free/.local 0700 free users -"
|
||||
"d /home/free/.local/share 0700 free users -"
|
||||
"d /home/free/.local/share/keyrings 0700 free users -"
|
||||
"f /home/free/.local/share/keyrings/default 0600 free users - login\n"
|
||||
];
|
||||
|
||||
|
||||
# ── Audio ──────────────────────────────────────────────────
|
||||
services.pulseaudio.enable = false;
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
|
||||
# ─── Add your custom NixOS configuration below ───────────
|
||||
|
||||
# ─── Mullvad VPN (Desktop Only) ───────────────────────────
|
||||
# Installed on the Desktop Role as the stock NixOS service; account, relay
|
||||
# and settings belong to the app. To take it off a Desktop machine:
|
||||
# services.mullvad-vpn.enable = lib.mkForce false;
|
||||
|
||||
# ─── Custom Caddy virtual hosts ──────────────────────────
|
||||
# Uncomment and edit below to add your own Caddy sites:
|
||||
#
|
||||
|
||||
Generated
+30
-30
@@ -5,11 +5,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1790862821,
|
||||
"narHash": "sha256-IcLn2hPlxsbn2PKVlFD6gsDzkVurQ7b0KPtyORUGFcs=",
|
||||
"lastModified": 1792157233,
|
||||
"narHash": "sha256-XQnyEc+Pj0Tg21ckbPMri0fiK/exCzhBw6wNgd1INqw=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "btc-clients-nix",
|
||||
"rev": "99b0442dcc15198efcb62f3c1e7561a361749a6a",
|
||||
"rev": "990d29e23437f30b8573f640d3e323167de1dc7b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -26,11 +26,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1788450739,
|
||||
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
||||
"lastModified": 1790898470,
|
||||
"narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
|
||||
"rev": "024633cd702b10285db5cb19b40ad48d2399ba60",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -41,11 +41,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1790861130,
|
||||
"narHash": "sha256-cA8TrQntLbNO14wivJx7Gi2pZBhhBcMplgzIA3sk3TA=",
|
||||
"lastModified": 1792155732,
|
||||
"narHash": "sha256-3Dk56anLQ1V1VklQFKFD6ul3cKQk40sThurHlPHIhOo=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3d23ea05a8a3be3f078845c2753af10cef80e1a5",
|
||||
"rev": "7694c4f6b8537d50062ff36c21ef36de3c9322d7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -56,11 +56,11 @@
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1790750587,
|
||||
"narHash": "sha256-VfjaoJ1Uyb7JZTrBgE5Jf2nhjtQPmD9KOd19HJwmZwM=",
|
||||
"lastModified": 1792030654,
|
||||
"narHash": "sha256-OaQwAtPMTjQiY2sT/2/62UbSPhzP/0aDnWzMm/NCiUw=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "78e9c786dc08cd4f3420c2395cd977206a9b1da2",
|
||||
"rev": "26f4107e47e3ad0ffd0db220469ba83b6f6d96e8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -72,11 +72,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1790822859,
|
||||
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||
"lastModified": 1792094642,
|
||||
"narHash": "sha256-zp8ToDMnqZcu4o4IH9pdwXFXGp7yLm/M0dEnNWnyd7I=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||
"rev": "fe13242281a67641c0102cdb1087f0ebe586c7df",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -88,11 +88,11 @@
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1789724158,
|
||||
"narHash": "sha256-nlKgrm0dsVhOSopKheVBCcOpiIticufPPVLdVOI0euA=",
|
||||
"lastModified": 1791487292,
|
||||
"narHash": "sha256-tqM7xDEsaeK1p6VvRygeZ9LkQRpUUkbRT02BHv6hnTM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0a3468a402c449992505b6a9fc5b06580141b750",
|
||||
"rev": "8edc0c72e3a38faf5434e40d1f19431125fe4b30",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -104,11 +104,11 @@
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1790822859,
|
||||
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||
"lastModified": 1791956154,
|
||||
"narHash": "sha256-jBYGPi4udQ9uo4KQp9m2J0546nJVQitG15gvZSaVVAs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||
"rev": "55bfb4d7437bd4e09ab7cba58a43bd68eb310044",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -125,11 +125,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1790541651,
|
||||
"narHash": "sha256-/496IQz8qNWHHLnc3Zvr0l4uxJ34igNhJhn7ZKAefFk=",
|
||||
"lastModified": 1792203519,
|
||||
"narHash": "sha256-yJ072hxngo/srNdLTp4BlFeo0hedwnKicI3TGlSXLFE=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "5980a626794486abad69fca7667f9c11dfbc3bd7",
|
||||
"rev": "453dc9c5037e6d1bec81d2a51ddacd58a2f44e9c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -152,11 +152,11 @@
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1790877969,
|
||||
"narHash": "sha256-Ia88keP9t3B6ECVw+LP4xasPdvzw1TIWya1K+3dySu0=",
|
||||
"lastModified": 1792361662,
|
||||
"narHash": "sha256-EySy/HbufH9trjH0kEuZ2nVVk0+Sn5FoxJI7YtHrspo=",
|
||||
"owner": "naturallaw777",
|
||||
"repo": "Sovran_Bitcoin",
|
||||
"rev": "b0da63bd81f1a1b1a970068b3061c6c8389db9aa",
|
||||
"rev": "5c13bf5e01beccc4a225a36cde833f00b2595718",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -168,11 +168,11 @@
|
||||
"systems": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1774449309,
|
||||
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
|
||||
"lastModified": 1791244623,
|
||||
"narHash": "sha256-uuanbkFk8a313Zfrxdcz8Us/yQtaxNnKJtX8v0ZPpd8=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
|
||||
"rev": "d36eeed142f88b5a636f2e3856a53c61654cf29b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -51,9 +51,9 @@
|
||||
];
|
||||
config = {
|
||||
environment.systemPackages = with pkgs; [
|
||||
btc-clients.packages.${pkgs.system}.bisq
|
||||
btc-clients.packages.${pkgs.system}.bisq2
|
||||
btc-clients.packages.${pkgs.system}.sparrow
|
||||
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.bisq
|
||||
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.bisq2
|
||||
btc-clients.packages.${pkgs.stdenv.hostPlatform.system}.sparrow
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
+9
-4
@@ -54,9 +54,14 @@ DICEWARE_WORDS = [
|
||||
]
|
||||
|
||||
def generate_diceware_password():
|
||||
words = [secrets.choice(DICEWARE_WORDS) for _ in range(3)]
|
||||
digit = secrets.randbelow(10)
|
||||
return "-".join(words) + f"-{digit}"
|
||||
# 4 words from a 96 word list plus 2 digits: 96^4 x 100 = ~8.5e9, about
|
||||
# 33 bits. The old 3 words plus 1 digit was 96^3 x 10 = ~8.8e6, about 23
|
||||
# bits, for a password that is simultaneously the desktop login, the
|
||||
# 'free' account password and the only thing in front of a Hub that runs
|
||||
# as root and hands out every stored credential.
|
||||
words = [secrets.choice(DICEWARE_WORDS) for _ in range(4)]
|
||||
digits = f"{secrets.randbelow(100):02d}"
|
||||
return "-".join(words) + f"-{digits}"
|
||||
|
||||
try:
|
||||
logfile = open(LOG, "a")
|
||||
@@ -474,7 +479,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
"Full sovereignty: host your own websites, cloud, chat, passwords, and Bitcoin services instead of relying on Big Tech. Sovran_SystemsOS walks you through getting your domain from Njal.la and connecting everything. One router task is required: forward ports 80 and 443 to this computer. Heads-up: this makes your home IP address public.",
|
||||
"Server+Desktop"),
|
||||
("Desktop Only",
|
||||
"A beautiful, easy-to-use desktop without the background server applications.",
|
||||
"A beautiful, easy-to-use desktop without the background server applications. Includes the Mullvad VPN app.",
|
||||
"Desktop Only"),
|
||||
("Node Only",
|
||||
"Full Bitcoin node with Lightning and non-KYC buying and selling. No desktop.",
|
||||
|
||||
+50
-58
@@ -14,13 +14,54 @@ let
|
||||
|| config.sovran_systemsOS.features.haven
|
||||
|| config.sovran_systemsOS.features."nwc-wallets"
|
||||
|| config.sovran_systemsOS.features.element-calling;
|
||||
|
||||
# RTL and Mempool listen on loopback only: Sovran_Bitcoin binds them to
|
||||
# 127.0.0.1, and RTL's unit is sandboxed to loopback besides. Caddy is how
|
||||
# the local network reaches them (:3051 and :60847), so it has to run
|
||||
# wherever they do. That includes Bitcoin Node Only, which has no
|
||||
# domain-based service and so no other reason to run Caddy.
|
||||
#
|
||||
# The Hub is not one of these. It listens on 0.0.0.0:8937 itself, so it is
|
||||
# served on its own port rather than through Caddy: the one service that
|
||||
# runs as root has nothing in front of it that it does not need, and the
|
||||
# public sites on ports 80/443 cannot be asked for it by Host header.
|
||||
servesRtl = config.sovran_systemsOS.services.bitcoin;
|
||||
servesMempool = servesRtl && config.sovran_systemsOS.features.mempool;
|
||||
|
||||
caddyEnabled = needsHttpsPorts || extraVhosts != "" || servesRtl;
|
||||
|
||||
# Sites for the local network, one per loopback-only service. Written after
|
||||
# the public domain sites; each exists only where its service does.
|
||||
#
|
||||
# They do not filter by client address. A request can only reach them on
|
||||
# their own ports, which no setup step asks you to forward, so forwarding
|
||||
# 80/443 for public services does not expose them (a Host header on those
|
||||
# ports cannot select a site that listens elsewhere). RTL has its own
|
||||
# password and lockout, and Mempool shows public chain data. An address
|
||||
# check here could not be made right for IPv6 anyway: a laptop's global
|
||||
# address on the LAN looks exactly like a stranger's.
|
||||
bitcoinUiSites =
|
||||
lib.optionalString servesRtl ''
|
||||
|
||||
:3051 {
|
||||
reverse_proxy :3050
|
||||
encode gzip zstd
|
||||
}
|
||||
''
|
||||
+ lib.optionalString servesMempool ''
|
||||
|
||||
:60847 {
|
||||
reverse_proxy :60845
|
||||
encode gzip zstd
|
||||
}
|
||||
'';
|
||||
in
|
||||
{
|
||||
services.caddy = {
|
||||
# Only enable Caddy when at least one domain-based service needs it or
|
||||
# the operator has defined custom vhosts. This prevents Caddy from
|
||||
# running on Desktop Only installs that have no web services configured.
|
||||
enable = needsHttpsPorts || extraVhosts != "";
|
||||
# Caddy runs when a domain-based service needs it, when the operator has
|
||||
# defined custom vhosts, or when it is the way to reach RTL and Mempool.
|
||||
# Desktop Only has none of those, so Caddy stays off there.
|
||||
enable = caddyEnabled;
|
||||
user = "caddy";
|
||||
group = "root";
|
||||
};
|
||||
@@ -89,26 +130,6 @@ EOF
|
||||
EOF
|
||||
''}
|
||||
|
||||
# ── LAN-only guard ──────────────────────────────
|
||||
# The Hub, RTL and Mempool sites below are meant for this home network
|
||||
# only. Forwarding ports 80/443 on the router also lets other clients
|
||||
# reach Caddy, so these sites check where a request comes from, not just
|
||||
# which Host it asks for. Anyone else gets the connection closed.
|
||||
# private_ranges 10/8, 172.16/12, 192.168/16, 127/8, fd00::/8, ::1
|
||||
# 100.64.0.0/10 Tailscale and other VPN addresses
|
||||
# 169.254.0.0/16, fe80::/10, fc00::/7 link-local and unique-local
|
||||
# 2000::/3 IPv6 global addresses. Computers on this network
|
||||
# often connect over their own global address, which
|
||||
# looks the same as one from the internet, so IPv6
|
||||
# global addresses are not filtered.
|
||||
cat >> /run/caddy/Caddyfile <<'EOF'
|
||||
|
||||
(sovran_lan_only) {
|
||||
@outside not remote_ip private_ranges 100.64.0.0/10 169.254.0.0/16 fe80::/10 fc00::/7 2000::/3
|
||||
abort @outside
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── Matrix ──────────────────────────────────────
|
||||
if [ -n "$MATRIX" ]; then
|
||||
if [ -f /run/caddy/element-calling.snippet ]; then
|
||||
@@ -222,40 +243,11 @@ $LIGHTNING {
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── Sovran Hub (LAN access via mDNS) ────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
http://sovransystemsos.local {
|
||||
import sovran_lan_only
|
||||
reverse_proxy localhost:8937
|
||||
header {
|
||||
Clear-Site-Data "\"cache\""
|
||||
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
|
||||
Pragma "no-cache"
|
||||
Expires "0"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── RTL (LAN access) ────────────────────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
:3051 {
|
||||
import sovran_lan_only
|
||||
reverse_proxy :3050
|
||||
encode gzip zstd
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── Mempool (LAN access) ────────────────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
:60847 {
|
||||
import sovran_lan_only
|
||||
reverse_proxy :60845
|
||||
encode gzip zstd
|
||||
}
|
||||
EOF
|
||||
# ── RTL and Mempool (local network) ─────────────
|
||||
# Only where those services run; see bitcoinUiSites above.
|
||||
cat >> /run/caddy/Caddyfile <<'LAN_SITES_EOF'
|
||||
${bitcoinUiSites}
|
||||
LAN_SITES_EOF
|
||||
|
||||
# ── Custom vhosts from custom.nix ──────────────
|
||||
cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF'
|
||||
|
||||
@@ -61,6 +61,67 @@
|
||||
sshd = lib.mkEnableOption "SSH remote access";
|
||||
};
|
||||
|
||||
# ── Hub ───────────────────────────────────────────────────
|
||||
hub = {
|
||||
lanOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Refuse Hub requests from clients that are not on this computer or on
|
||||
the local network: loopback, private (10.0.0.0/8, 172.16.0.0/12,
|
||||
192.168.0.0/16), VPN/CGNAT (100.64.0.0/10) and link-local addresses,
|
||||
plus anything listed in sovran_systemsOS.hub.extraLanNetworks.
|
||||
|
||||
The Hub runs as root and can display stored credentials and reboot
|
||||
the machine. Whether a packet may reach its port is up to the
|
||||
firewall and your router; this check is the second lock, so that a
|
||||
port forward or a firewall mistake does not put the Hub's login page
|
||||
in front of the internet.
|
||||
|
||||
Set it to false only if this computer sits on a network that hands
|
||||
out public addresses to your own devices and you would rather not
|
||||
list them.
|
||||
'';
|
||||
};
|
||||
|
||||
directPort = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = !config.sovran_systemsOS.roles.desktop;
|
||||
defaultText = lib.literalExpression "!config.sovran_systemsOS.roles.desktop";
|
||||
description = ''
|
||||
Open port 8937 on the firewall, so that other devices on the local
|
||||
network can reach the Hub at http://sovransystemsos.local:8937.
|
||||
|
||||
On by default for Server + Desktop and Bitcoin Node Only. Off on
|
||||
Desktop Only, the role most likely to be used away from home: there
|
||||
nothing is published, and the Hub is reachable only from this
|
||||
computer, through the desktop application window on localhost. Set it
|
||||
to true in custom.nix if you do want to reach a Desktop Only Hub from
|
||||
another device.
|
||||
|
||||
The Hub runs as root, so it checks every client itself (see
|
||||
sovran_systemsOS.hub.lanOnly); the firewall opening only decides
|
||||
whether a packet may reach it at all.
|
||||
'';
|
||||
};
|
||||
|
||||
extraLanNetworks = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "203.0.113.0/28" ];
|
||||
description = ''
|
||||
Extra networks, in CIDR notation, that the Hub should treat as local
|
||||
in addition to the built-in ranges. Needed only if devices on your
|
||||
local network use addresses outside the private ranges, for example a
|
||||
public IPv4 block your provider routes onto your LAN.
|
||||
|
||||
Keep each entry as narrow as you can: every address inside it is let
|
||||
through. To let everything through, set sovran_systemsOS.hub.lanOnly
|
||||
to false instead; 0.0.0.0/0 and ::/0 are not accepted here.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# ── Web exposure (controls Caddy vhosts) ──────────────────
|
||||
web = {
|
||||
btcpayserver = lib.mkOption {
|
||||
|
||||
@@ -115,6 +115,15 @@ let
|
||||
else if cfg.roles.node then "node"
|
||||
else "server_plus_desktop";
|
||||
|
||||
# IPv4 a.b.c.d[/0-32] or IPv6 [/0-128], and never a /0 (that is "everyone").
|
||||
octet = "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])";
|
||||
lanNetworkOk = p:
|
||||
(
|
||||
builtins.match "${octet}(\\.${octet}){3}(/(3[0-2]|[12]?[0-9]))?" p != null
|
||||
|| builtins.match "[0-9a-fA-F:]*:[0-9a-fA-F:]*(/(12[0-8]|1[01][0-9]|[1-9]?[0-9]))?" p != null
|
||||
)
|
||||
&& builtins.match ".*/0" p == null;
|
||||
|
||||
generatedConfig = pkgs.writeText "sovran-hub-config.json"
|
||||
(builtins.toJSON {
|
||||
refresh_interval = 5;
|
||||
@@ -122,6 +131,9 @@ let
|
||||
role = activeRole;
|
||||
services = monitoredServices;
|
||||
feature_manager = true;
|
||||
# Read by LanOnlyMiddleware in server.py.
|
||||
lan_only = cfg.hub.lanOnly;
|
||||
lan_extra_networks = cfg.hub.extraLanNetworks;
|
||||
feature_states = {
|
||||
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
|
||||
};
|
||||
@@ -475,6 +487,12 @@ os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "i
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"sovran_systemsos_web.server:app",
|
||||
# IPv4 only, on purpose. The desktop launcher uses "localhost", which
|
||||
# falls back to 127.0.0.1, and other devices reach the Hub over IPv4 too.
|
||||
# An IPv6 listener would admit clients whose global addresses the Hub's own
|
||||
# check cannot tell from a stranger's (see LanPolicy). Which devices may
|
||||
# connect is up to the firewall (hub.directPort) and that check
|
||||
# (hub.lanOnly), not this bind.
|
||||
host="0.0.0.0",
|
||||
port=8937,
|
||||
log_level="info",
|
||||
@@ -503,6 +521,22 @@ in
|
||||
};
|
||||
|
||||
config = {
|
||||
# Catch a typo'd network at build time. The Hub ignores an entry it cannot
|
||||
# parse (it must never widen its policy by guessing), so without this the
|
||||
# only symptom would be a client that is refused for no visible reason.
|
||||
assertions = [
|
||||
{
|
||||
assertion = builtins.all lanNetworkOk cfg.hub.extraLanNetworks;
|
||||
message = ''
|
||||
sovran_systemsOS.hub.extraLanNetworks must be a list of IPv4 or IPv6
|
||||
networks in CIDR notation, for example [ "203.0.113.0/28" ]. A /0
|
||||
prefix is not accepted; set sovran_systemsOS.hub.lanOnly = false to
|
||||
let every client through. Got:
|
||||
${builtins.toJSON cfg.hub.extraLanNetworks}
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
systemd.services.sovran-hub-web = {
|
||||
description = "Sovran_SystemsOS Hub Web Interface";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
@@ -571,7 +605,14 @@ in
|
||||
|
||||
environment.systemPackages = [ sovran-hub-web ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 8937 60847 ];
|
||||
# The Hub is served on its own port, not through Caddy (see caddy.nix).
|
||||
# Nothing here filters by client address: that is the Hub's own check
|
||||
# (sovran_systemsOS.hub.lanOnly), and which networks can route to this
|
||||
# computer at all is the router's call.
|
||||
# 60847 is where Caddy serves Mempool, so it is open only when Mempool is.
|
||||
networking.firewall.allowedTCPPorts =
|
||||
lib.optionals cfg.hub.directPort [ 8937 ]
|
||||
++ lib.optionals (cfg.services.bitcoin && cfg.features.mempool) [ 60847 ];
|
||||
|
||||
# ── Auto-launch Hub in browser on login ───────────────────────
|
||||
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
# sshd listens on 127.0.0.1 only here, so there is nothing for the firewall
|
||||
# to let in. NixOS opens sshd's ports by default (openFirewall = true)
|
||||
# whether or not sshd listens on them, which left port 22 open on every
|
||||
# role, Desktop Only included. The roles that do publish SSH open it
|
||||
# themselves: the sshd feature (sshd.nix) and remote deploy
|
||||
# (remote-deploy.nix) both add 22 explicitly.
|
||||
openFirewall = lib.mkDefault false;
|
||||
listenAddresses = lib.mkDefault [
|
||||
{ addr = "127.0.0.1"; port = 22; }
|
||||
];
|
||||
|
||||
@@ -33,9 +33,6 @@ let
|
||||
echo "$NEW_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "Password for 'free' updated and saved."
|
||||
# Delete the old GNOME Keyring databases so a fresh one is created on next GDM login.
|
||||
rm -f /home/free/.local/share/keyrings/*.keyring
|
||||
echo "GNOME Keyring files cleared — a fresh keyring will be created on next login."
|
||||
'';
|
||||
in
|
||||
{
|
||||
@@ -76,169 +73,4 @@ in
|
||||
command passwd $argv
|
||||
end
|
||||
'';
|
||||
|
||||
# ── 1. Auto-Generate Root Password (Runs once) ─────────────
|
||||
systemd.services.root-password-setup = {
|
||||
description = "Generate and set a random root password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.shadow pkgs.coreutils ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
SECRET_FILE="/var/lib/secrets/root-password"
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
# Generate a diceware-style passphrase: word-word-word-N
|
||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
|
||||
prism quartz raven ridge river robin rocky rose rowan sage sand \
|
||||
sierra silver slate snow solar spark spruce stone storm summit \
|
||||
swift thorn tide timber torch trout vale vault vine walnut wave \
|
||||
willow wren amber aspen birch blaze bloom bluff coast copper crest \
|
||||
dune elder fjord forge glade glen glow gulf"
|
||||
WORD_ARRAY=($WORDS)
|
||||
COUNT=''${#WORD_ARRAY[@]}
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
ROOT_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
fi
|
||||
# If the file contains a scrypt hash (salt:hash), skip chpasswd — the
|
||||
# password was already set via the Hub security reset endpoint and this
|
||||
# service is only re-running as a manual recovery step.
|
||||
CONTENT="$(cat "$SECRET_FILE")"
|
||||
if echo "$CONTENT" | grep -qE '^[0-9a-f]{32}:[0-9a-f]{64,}$'; then
|
||||
echo "root-password-setup: stored value is already hashed — skipping chpasswd" >&2
|
||||
else
|
||||
echo "root:$CONTENT" | chpasswd
|
||||
fi
|
||||
'';
|
||||
};
|
||||
|
||||
# ── 1b. Generate random 'free' password on first boot ──────
|
||||
systemd.services.free-password-setup = {
|
||||
description = "Generate and set a random 'free' user password";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "display-manager.service" ];
|
||||
after = [ "systemd-user-sessions.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.shadow pkgs.coreutils ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
PENDING_FILE="/var/lib/secrets/free-password-migration-pending"
|
||||
|
||||
if [ -f "$SECRET_FILE" ]; then
|
||||
echo "free:$(cat "$SECRET_FILE")" | chpasswd
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHADOW_HASH=""
|
||||
while IFS=: read -r user hash _; do
|
||||
if [ "$user" = "free" ]; then
|
||||
SHADOW_HASH="$hash"
|
||||
break
|
||||
fi
|
||||
done < /etc/shadow
|
||||
|
||||
HAS_REAL_HASH=0
|
||||
case "$SHADOW_HASH" in
|
||||
""|"!"|"*"|"!!"|"!"*|"*"*)
|
||||
HAS_REAL_HASH=0
|
||||
;;
|
||||
*)
|
||||
HAS_REAL_HASH=1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$HAS_REAL_HASH" -eq 1 ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
touch "$PENDING_FILE"
|
||||
chmod 600 "$PENDING_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p /var/lib/secrets
|
||||
# Generate a diceware-style passphrase: word-word-word-N
|
||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
|
||||
prism quartz raven ridge river robin rocky rose rowan sage sand \
|
||||
sierra silver slate snow solar spark spruce stone storm summit \
|
||||
swift thorn tide timber torch trout vale vault vine walnut wave \
|
||||
willow wren amber aspen birch blaze bloom bluff coast copper crest \
|
||||
dune elder fjord forge glade glen glow gulf"
|
||||
WORD_ARRAY=($WORDS)
|
||||
COUNT=''${#WORD_ARRAY[@]}
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
echo "$FREE_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "free:$FREE_PASS" | chpasswd
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.services.free-password-migration = {
|
||||
description = "Generate and set 'free' password for migrated machines";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "display-manager.service" ];
|
||||
after = [ "systemd-user-sessions.service" "free-password-setup.service" ];
|
||||
requires = [ "free-password-setup.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
path = [ pkgs.shadow pkgs.coreutils ];
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
PENDING_FILE="/var/lib/secrets/free-password-migration-pending"
|
||||
SECRET_FILE="/var/lib/secrets/free-password"
|
||||
NEWPASS_FILE="/var/lib/secrets/free-password-migration-newpass"
|
||||
|
||||
[ -f "$PENDING_FILE" ] || exit 0
|
||||
|
||||
mkdir -p /var/lib/secrets
|
||||
|
||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
meadow mist mossy mount oak ocean olive petal pine pixel plum pond \
|
||||
prism quartz raven ridge river robin rocky rose rowan sage sand \
|
||||
sierra silver slate snow solar spark spruce stone storm summit \
|
||||
swift thorn tide timber torch trout vale vault vine walnut wave \
|
||||
willow wren amber aspen birch blaze bloom bluff coast copper crest \
|
||||
dune elder fjord forge glade glen glow gulf"
|
||||
WORD_ARRAY=($WORDS)
|
||||
COUNT=''${#WORD_ARRAY[@]}
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
|
||||
printf '%s\n' "$FREE_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
|
||||
printf '%s\n' "$FREE_PASS" > "$NEWPASS_FILE"
|
||||
chmod 600 "$NEWPASS_FILE"
|
||||
rm -f "$PENDING_FILE"
|
||||
'';
|
||||
};
|
||||
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
# ── Always on (no flag) ───────────────────────────────────
|
||||
./php.nix
|
||||
./credentials.nix
|
||||
./password-model.nix
|
||||
|
||||
# ── Services (default ON — disable in custom.nix) ─────────
|
||||
./synapse.nix
|
||||
@@ -34,5 +35,8 @@
|
||||
./element-calling.nix
|
||||
./rdp.nix
|
||||
./sshd.nix
|
||||
|
||||
# ── Desktop Role only (gated inside the module) ───────────
|
||||
./mullvad.nix
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# ── modules/mullvad.nix ───────────────────────────────────────────────────────
|
||||
# Mullvad VPN client — Desktop Role only.
|
||||
#
|
||||
# A plain install: the daemon (pkgs.mullvad) and the graphical app
|
||||
# (pkgs.mullvad-vpn) exactly as NixOS ships them, enabled and nothing more. The
|
||||
# account, the relay, the kill switch and every other setting are the app's
|
||||
# business; nothing here configures them, and nothing here is wired into the
|
||||
# Hub or into any other part of the system.
|
||||
#
|
||||
# The role gate is the whole point: Server + Desktop and Bitcoin Node are
|
||||
# machines that serve, and a VPN client with its own DNS and routing policy is
|
||||
# not something their services asked for. Only roles.desktop builds it.
|
||||
#
|
||||
# Turning it off on a Desktop machine: uncomment
|
||||
# services.mullvad-vpn.enable = lib.mkForce false;
|
||||
# in custom.nix (the mkForce is needed because this module sets it too).
|
||||
|
||||
{ config, lib, ... }:
|
||||
|
||||
lib.mkIf config.sovran_systemsOS.roles.desktop {
|
||||
|
||||
services.mullvad-vpn = {
|
||||
# The daemon and its systemd service.
|
||||
enable = true;
|
||||
# The desktop app.
|
||||
gui.enable = true;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
# Sovran_SystemsOS -- password model cleanup (GNOME 50 / gnome-keyring 50)
|
||||
#
|
||||
# * /etc/shadow is the only source of truth for authentication (Hub login == GDM login).
|
||||
# * /var/lib/secrets/{free,root}-password are root-only DISPLAY copies for the Hub, written
|
||||
# by exactly one module (system_password.py) and verified against shadow before shown.
|
||||
# * The GNOME login keyring is decoupled from the login password: it is created with a
|
||||
# blank password and pam_gnome_keyring never hands the login password to the daemon.
|
||||
# => no "unlock your login keyring" prompts, ever (also on the autologin live ISO);
|
||||
# Brave's cookie/password key survives every password change; no *.keyring wiping.
|
||||
# * Legacy machines (password unknown to the Hub) are handled by the Hub's existing
|
||||
# onboarding "migration password" step -- not by a boot service any more.
|
||||
#
|
||||
# Import from configuration.nix and DELETE these lines there:
|
||||
# security.pam.services.gdm-password.enableGnomeKeyring = true;
|
||||
# security.pam.services.gdm-autologin.enableGnomeKeyring = true;
|
||||
# the four systemd.tmpfiles.rules for /home/free/.local/share/keyrings/{,default}
|
||||
# and in modules/credentials.nix delete root-password-setup, free-password-setup,
|
||||
# free-password-migration and the `rm -f /home/free/.local/share/keyrings/*.keyring` lines.
|
||||
|
||||
let
|
||||
user = "free";
|
||||
home = "/home/${user}";
|
||||
|
||||
# A login keyring with a blank password. gnome-keyring stores those in this textual
|
||||
# format and auto-unlocks them on demand without any prompt (verified on 48; the rule in
|
||||
# gkm-secret-collection.c that picks the textual format for a blank master is unchanged
|
||||
# in 50 and the 50.0 release notes contain no behavioural changes).
|
||||
blankLoginKeyring = pkgs.writeText "login.keyring" ''
|
||||
[keyring]
|
||||
display-name=login
|
||||
ctime=0
|
||||
mtime=0
|
||||
lock-on-idle=false
|
||||
lock-after=false
|
||||
'';
|
||||
|
||||
# Apply the installer's plaintext password ONCE: only while the account still has no
|
||||
# usable hash (nixos-install --no-root-password leaves both accounts locked). After that
|
||||
# the file is display-only and the Hub (system_password.py) is the only thing that
|
||||
# writes it. This replaces the old services, which re-applied the file on EVERY boot
|
||||
# and silently reverted any password changed elsewhere.
|
||||
# On already-installed machines the case below matches `*)` (real hash) -> no-op:
|
||||
# an upgrade can never change or generate a password.
|
||||
applyOnce = { account, fallback ? false }: ''
|
||||
set -euo pipefail
|
||||
f=/var/lib/secrets/${account}-password
|
||||
hash="$(getent shadow ${account} | cut -d: -f2 || true)"
|
||||
case "$hash" in
|
||||
""|"!"*|"*"*)
|
||||
if [ -s "$f" ] && ! grep -qE '^[0-9a-f]{32}:[0-9a-f]{64,}$' "$f"; then
|
||||
echo "${account}:$(head -n1 "$f")" | chpasswd
|
||||
${lib.optionalString fallback ''
|
||||
else
|
||||
# Installer left no usable file: generate one so GDM is always reachable
|
||||
# (the user reads it in the Hub). Never runs once the account has a password.
|
||||
W="apple barn brook cabin cedar cloud coral crane delta eagle ember fern field flame
|
||||
flora flint frost grove haven hedge holly heron jade juniper kelp larch lemon lilac
|
||||
linden loch lotus maple marsh meadow mist mossy mount oak ocean olive petal pine
|
||||
pixel plum pond prism quartz raven ridge river robin rocky rose rowan sage sand
|
||||
sierra silver slate snow solar spark spruce stone storm summit swift thorn tide
|
||||
timber torch trout vale vault vine walnut wave willow wren"
|
||||
pw=$(echo $W | tr ' ' '\n' | shuf -n4 | paste -sd- -)-$(printf '%02d' $((RANDOM % 100)))
|
||||
umask 077; printf '%s\n' "$pw" > "$f"
|
||||
echo "${account}:$pw" | chpasswd
|
||||
''}
|
||||
fi ;;
|
||||
*) ;; # password already set: never touch it here again
|
||||
esac
|
||||
'';
|
||||
in
|
||||
{
|
||||
# ── 1. Keyring: never learns the login password ───────────────────────────
|
||||
# nixpkgs' gnome-keyring module turns this on for `login` (gdm-password includes it).
|
||||
# If the daemon ever receives the login password it re-encrypts the blank keyring with
|
||||
# it on the next save -- so it must be off everywhere.
|
||||
security.pam.services.login.enableGnomeKeyring = lib.mkForce false;
|
||||
security.pam.services.gdm-password.enableGnomeKeyring = lib.mkForce false;
|
||||
security.pam.services.gdm-autologin.enableGnomeKeyring = lib.mkForce false;
|
||||
security.pam.services.passwd.enableGnomeKeyring = lib.mkForce false;
|
||||
|
||||
# Seed the blank keyring. `C` copies only if the file does not exist, so an existing
|
||||
# keyring is never overwritten -- which is also what makes a reset's `rm *.keyring`
|
||||
# harmless: the next boot re-seeds a fresh blank keyring.
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${home}/.local 0700 ${user} users -"
|
||||
"d ${home}/.local/share 0700 ${user} users -"
|
||||
"d ${home}/.local/share/keyrings 0700 ${user} users -"
|
||||
"C ${home}/.local/share/keyrings/login.keyring 0600 ${user} users - ${blankLoginKeyring}"
|
||||
];
|
||||
|
||||
# One-time migration for machines that already have a keyring encrypted with the
|
||||
# (old) login password: move it aside and seed the blank one. Runs before GDM; the old
|
||||
# file is kept. Same cost users pay today on every password change (Brave logs you out
|
||||
# of sites once) -- but only once, ever.
|
||||
system.activationScripts.sovranBlankKeyring = lib.stringAfter [ "users" ] ''
|
||||
d=${home}/.local/share/keyrings
|
||||
if [ -f "$d/login.keyring" ] && ! head -c 9 "$d/login.keyring" | grep -q '^\[keyring\]'; then
|
||||
mv "$d/login.keyring" "$d/login.keyring.encrypted-$(date +%Y%m%d)"
|
||||
install -o ${user} -g users -m 0600 ${blankLoginKeyring} "$d/login.keyring"
|
||||
fi
|
||||
'';
|
||||
|
||||
# ── 2. First-boot password hand-off (unit names kept: the Hub's "System Passwords"
|
||||
# service card is bound to root-password-setup.service) ───────────────────────
|
||||
systemd.services.free-password-setup = {
|
||||
description = "Apply the installer's 'free' password (first boot only)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "display-manager.service" ];
|
||||
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; };
|
||||
path = [ pkgs.shadow pkgs.coreutils pkgs.gnugrep pkgs.getent ];
|
||||
script = applyOnce { account = user; fallback = true; };
|
||||
};
|
||||
systemd.services.root-password-setup = {
|
||||
description = "Apply the installer's root password (first boot only)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = { Type = "oneshot"; RemainAfterExit = true; };
|
||||
path = [ pkgs.shadow pkgs.coreutils pkgs.gnugrep pkgs.getent ];
|
||||
script = applyOnce { account = "root"; };
|
||||
};
|
||||
# Legacy detection now lives in the Hub (system_password.ensure_migration_pending).
|
||||
systemd.services.free-password-migration.enable = false;
|
||||
|
||||
# ── 3. The Hub verifies passwords against /etc/shadow via libxcrypt ───────
|
||||
systemd.services.sovran-hub-web.environment.LIBCRYPT = "${pkgs.libxcrypt}/lib/libcrypt.so.1";
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
# 3. Applies OS-specific overrides (Second_Drive paths, operator "free",
|
||||
# forced wallet enable, Hub firewall port, domain requirements)
|
||||
# 4. Wires the Sovran Hub's NWC environment to the Alby Hub service
|
||||
{ config, pkgs, lib, ... }:
|
||||
{ config, pkgs, lib, options, ... }:
|
||||
|
||||
let
|
||||
cfg = config.sovran_systemsOS;
|
||||
@@ -32,7 +32,7 @@ let
|
||||
NWC_LND_CERT_FILE = lndCertPath;
|
||||
NWC_LND_MACAROON_FILE = "/run/lnd/albyhub.macaroon";
|
||||
NWC_RELAY =
|
||||
if cfg.features.haven
|
||||
if cfg.features.haven && config.networking.domain != null && config.networking.domain != ""
|
||||
then "wss://haven.${config.networking.domain}/nostr"
|
||||
else "wss://relay.getalby.com,wss://relay2.getalby.com";
|
||||
};
|
||||
@@ -55,11 +55,34 @@ in {
|
||||
};
|
||||
|
||||
# ── 2. Second_Drive data paths (OS-specific) ──────────────────
|
||||
services.bitcoind = lib.mkIf cfg.services.bitcoin {
|
||||
services.bitcoind = lib.mkIf cfg.services.bitcoin ({
|
||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||
# The OS always enables the bitcoind wallet — the Hub and BTCPay need it.
|
||||
disablewallet = lib.mkForce false;
|
||||
};
|
||||
# electrs 0.12.0 (bindex) requires bitcoind's REST interface (rest=1).
|
||||
extraConfig = lib.optionalString (!(options.services.bitcoind ? rest)) "rest=1";
|
||||
} // lib.optionalAttrs cfg.web.btcpayserver {
|
||||
rpc.users.btcpayserver.rpcwhitelist = lib.mkForce (lib.unique (config.services.bitcoind.rpc.users.public.rpcwhitelist ++ [
|
||||
"setban"
|
||||
"generatetoaddress"
|
||||
"getrpcinfo"
|
||||
"gettransaction"
|
||||
"getwalletinfo"
|
||||
"getnewaddress"
|
||||
"listunspent"
|
||||
"listtransactions"
|
||||
"listreceivedbyaddress"
|
||||
"importmulti"
|
||||
"importdescriptors"
|
||||
"importaddress"
|
||||
"importprivkey"
|
||||
"importpubkey"
|
||||
"rescanblockchain"
|
||||
"createwallet"
|
||||
"loadwallet"
|
||||
"signrawtransactionwithwallet"
|
||||
]));
|
||||
});
|
||||
|
||||
services.electrs = lib.mkIf cfg.services.bitcoin {
|
||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Electrs_Data";
|
||||
@@ -72,11 +95,38 @@ in {
|
||||
serviceConfig.PrivateUsers = lib.mkForce false;
|
||||
};
|
||||
|
||||
systemd.services.electrs = lib.mkIf cfg.services.bitcoin {
|
||||
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
|
||||
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
|
||||
wants = [ "bitcoind.service" ];
|
||||
};
|
||||
systemd.services.electrs = lib.mkIf cfg.services.bitcoin (
|
||||
let
|
||||
bitcoind = config.services.bitcoind;
|
||||
electrsCfg = config.services.electrs;
|
||||
nbLib = config.nix-bitcoin.lib;
|
||||
in {
|
||||
requires = lib.mkForce [ "run-media-Second_Drive.mount" ];
|
||||
after = [ "run-media-Second_Drive.mount" "bitcoind.service" ];
|
||||
wants = [ "bitcoind.service" ];
|
||||
} // lib.optionalAttrs (!(options.services.bitcoind ? rest)) {
|
||||
# Fallback when locked against Sovran_Bitcoin <= 1.1.1:
|
||||
# electrs 0.12.0 removed --daemon-p2p-addr and switched to bindex (format v1).
|
||||
preStart = lib.mkAfter ''
|
||||
dbNetDir='${electrsCfg.dataDir}/${bitcoind.makeNetworkName "bitcoin" "regtest"}'
|
||||
if [[ -d "$dbNetDir" ]] && grep -q 'prefix_extractor=rocksdb\.FixedPrefix\.8' "$dbNetDir"/OPTIONS-* 2>/dev/null; then
|
||||
echo "electrs: removing pre-0.12.0 database at $dbNetDir for bindex 0.12.0 reindex"
|
||||
rm -rf "$dbNetDir"
|
||||
fi
|
||||
'';
|
||||
serviceConfig.ExecStart = lib.mkForce ''
|
||||
${pkgs.electrs}/bin/electrs \
|
||||
--log-filters=INFO \
|
||||
--network=${bitcoind.makeNetworkName "bitcoin" "regtest"} \
|
||||
--db-dir='${electrsCfg.dataDir}' \
|
||||
--daemon-dir='${bitcoind.dataDir}' \
|
||||
--electrum-rpc-addr=${electrsCfg.address}:${toString electrsCfg.port} \
|
||||
--monitoring-addr=${electrsCfg.address}:${toString electrsCfg.monitoringPort} \
|
||||
--daemon-rpc-addr=${nbLib.addressWithPort bitcoind.rpc.address bitcoind.rpc.port} \
|
||||
${electrsCfg.extraArgs}
|
||||
'';
|
||||
}
|
||||
);
|
||||
|
||||
systemd.services.lnd = lib.mkIf cfg.services.bitcoin {
|
||||
wants = [ "bitcoind.service" ];
|
||||
@@ -105,9 +155,11 @@ in {
|
||||
'';
|
||||
};
|
||||
|
||||
# ── 5. Firewall — Hub management port ──────────────────────────
|
||||
# ── 5. Firewall — RTL ──────────────────────────────────────────
|
||||
# RTL is a web app served by Caddy over TCP on 3051. The matching UDP rule
|
||||
# that used to sit here was carried over from the TCP line and opened a port
|
||||
# nothing listens on.
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
||||
networking.firewall.allowedUDPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
||||
|
||||
# ── 6. NWC / LNURL — Sovran Hub integration ───────────────────
|
||||
# Sovran_Bitcoin's albyhub.nix and lnurl.nix handle the base services.
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Guards for the LAN-only Caddy sites.
|
||||
|
||||
The Hub (http://sovransystemsos.local), Ride The Lightning (:3051) and Mempool
|
||||
(:60847) sites are for the home network. Caddy has to check where a request comes
|
||||
from because forwarding ports 80/443 on the router lets other clients reach it too.
|
||||
The domain sites for the operator's own public services must stay public.
|
||||
|
||||
These read modules/core/caddy.nix like the nix-file checks in test_security.py:
|
||||
nothing is run and nothing touches the network.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# What Caddy's "private_ranges" shortcut expands to (see the remote_ip matcher docs).
|
||||
_PRIVATE_RANGES = ["192.168.0.0/16", "172.16.0.0/12", "10.0.0.0/8",
|
||||
"127.0.0.1/8", "fd00::/8", "::1"]
|
||||
|
||||
_LAN_SITES = ("http://sovransystemsos.local", ":3051", ":60847")
|
||||
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _site(src, address):
|
||||
m = re.search(r"^" + re.escape(address) + r" \{\n(.*?)^\}$", src, re.S | re.M)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _snippet(src):
|
||||
m = re.search(r"^\(sovran_lan_only\) \{\n(.*?)^\}$", src, re.S | re.M)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _allowed_networks(snippet):
|
||||
m = re.search(r"^\s*@outside not remote_ip (.+)$", snippet, re.M)
|
||||
assert m, "the @outside matcher is missing"
|
||||
nets = []
|
||||
for token in m.group(1).split():
|
||||
for cidr in (_PRIVATE_RANGES if token == "private_ranges" else [token]):
|
||||
nets.append(ipaddress.ip_network(cidr, strict=False))
|
||||
return nets
|
||||
|
||||
|
||||
def _is_allowed(nets, address):
|
||||
ip = ipaddress.ip_address(address)
|
||||
return any(ip.version == n.version and ip in n for n in nets)
|
||||
|
||||
|
||||
class LanOnlySites(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.src = _read("modules", "core", "caddy.nix")
|
||||
|
||||
def test_lan_sites_use_the_guard_before_they_proxy(self):
|
||||
for address in _LAN_SITES:
|
||||
with self.subTest(site=address):
|
||||
body = _site(self.src, address)
|
||||
self.assertIsNotNone(body, f"{address} site not found")
|
||||
self.assertIn("import sovran_lan_only", body)
|
||||
self.assertLess(body.index("import sovran_lan_only"),
|
||||
body.index("reverse_proxy"))
|
||||
|
||||
def test_only_the_lan_sites_use_the_guard(self):
|
||||
self.assertEqual(self.src.count("import sovran_lan_only"), len(_LAN_SITES))
|
||||
public = re.findall(r"^\$[A-Z]+ \{\n(.*?)^\}$", self.src, re.S | re.M)
|
||||
self.assertGreaterEqual(len(public), 6, "domain sites not found")
|
||||
for body in public:
|
||||
self.assertNotIn("sovran_lan_only", body)
|
||||
# the Matrix site that Element calling writes instead of the plain one
|
||||
self.assertNotIn("sovran_lan_only", _read("modules", "element-calling.nix"))
|
||||
|
||||
def test_guard_closes_the_connection_for_everyone_else(self):
|
||||
snippet = _snippet(self.src)
|
||||
self.assertIsNotNone(snippet, "(sovran_lan_only) snippet not found")
|
||||
self.assertRegex(snippet, r"(?m)^\s*abort @outside\s*$")
|
||||
# defined before the first site that imports it
|
||||
self.assertLess(self.src.index("(sovran_lan_only) {"),
|
||||
self.src.index("import sovran_lan_only"))
|
||||
|
||||
def test_guard_ranges(self):
|
||||
nets = _allowed_networks(_snippet(self.src))
|
||||
for address in ("127.0.0.1", "::1", "10.0.0.1", "172.16.0.1", "172.31.255.254",
|
||||
"192.168.1.10", "100.64.0.1", "100.127.255.254", "169.254.1.1",
|
||||
"fd12:3456::1", "fe80::1", "fc00::1"):
|
||||
with self.subTest(allowed=address):
|
||||
self.assertTrue(_is_allowed(nets, address))
|
||||
for address in ("8.8.8.8", "1.1.1.1", "203.0.113.9", "9.255.255.255", "11.0.0.1",
|
||||
"172.15.255.255", "172.32.0.1", "192.169.0.1", "100.63.255.255",
|
||||
"100.128.0.1", "169.253.255.255"):
|
||||
with self.subTest(refused=address):
|
||||
self.assertFalse(_is_allowed(nets, address))
|
||||
|
||||
def test_ipv6_global_addresses_are_not_filtered(self):
|
||||
# Computers on the home network often connect over their own global IPv6
|
||||
# address, which cannot be told apart from the internet's by address alone.
|
||||
# If this is ever tightened, LAN clients on IPv6 networks lose the Hub.
|
||||
nets = _allowed_networks(_snippet(self.src))
|
||||
self.assertTrue(_is_allowed(nets, "2001:db8::5"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Guards for serving the Hub on its own port instead of through Caddy.
|
||||
|
||||
The Hub is the one service that runs as root. It listens on 0.0.0.0:8937
|
||||
itself, so Caddy adds nothing it needs: not TLS (the site was plain http), not
|
||||
authentication, not cache headers (the app sets its own). What it did add was a
|
||||
second door: with ports 80/443 forwarded for public services, a Host header on
|
||||
those ports reached the Hub. The Hub is therefore served on port 8937 only, and
|
||||
Caddy keeps the two services it is actually needed for, because they listen on
|
||||
loopback only: Ride The Lightning (:3051) and Mempool (:60847).
|
||||
|
||||
Like the other nix-file checks these read the modules as text: nothing is run
|
||||
and nothing touches the network.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _without_comments(src):
|
||||
"""The Nix source with `#` comment lines removed."""
|
||||
return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
|
||||
|
||||
|
||||
def _binding(src, name):
|
||||
"""The right-hand side of a top-level `name = ...;` binding in a let/attrset."""
|
||||
m = re.search(r"^\s*" + re.escape(name) + r"\s*=\s*(?P<v>.*?);\s*$", src, re.M | re.S)
|
||||
assert m, f"{name} not found"
|
||||
return m.group("v")
|
||||
|
||||
|
||||
class HubIsNotACaddySite(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.caddy = _read("modules", "core", "caddy.nix")
|
||||
cls.code = _without_comments(cls.caddy)
|
||||
|
||||
def test_there_is_no_site_for_the_hub(self):
|
||||
self.assertNotRegex(self.code, r"sovransystemsos\.local")
|
||||
self.assertNotIn("8937", self.code)
|
||||
|
||||
def test_the_public_ports_still_belong_to_the_public_sites(self):
|
||||
# Caddy now also runs to bridge RTL and Mempool (even on Node Only),
|
||||
# which must not open 80/443 by itself: those follow the domain-based
|
||||
# services and nothing else.
|
||||
self.assertRegex(
|
||||
self.code,
|
||||
r"networking\.firewall\.allowedTCPPorts\s*=\s*lib\.mkIf\s+needsHttpsPorts\s*\[\s*80\s+443\s*\]",
|
||||
)
|
||||
|
||||
|
||||
class CaddyDoesNoAddressFiltering(unittest.TestCase):
|
||||
"""Caddy is a bridge for RTL and Mempool and a TLS front for public sites.
|
||||
|
||||
It used to carry a client-address guard (sovran_lan_only). That guard was
|
||||
never aimed at these two sites: the bug was a Host header on ports 80/443
|
||||
reaching the Hub, and RTL and Mempool sit on ports of their own. It also
|
||||
could not be made right for IPv6, where a laptop's global address on the
|
||||
LAN is indistinguishable from a stranger's.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.caddy = _read("modules", "core", "caddy.nix")
|
||||
cls.code = _without_comments(cls.caddy)
|
||||
|
||||
def test_there_is_no_address_filter(self):
|
||||
# (private_ranges is deliberately not on this list: the Nextcloud site
|
||||
# uses it for trusted_proxies, which is not a filter on who may connect.)
|
||||
for needle in ("sovran_lan_only", "remote_ip", "abort @"):
|
||||
with self.subTest(needle=needle):
|
||||
self.assertNotIn(needle, self.code)
|
||||
|
||||
def test_the_bitcoin_sites_are_plain_proxies(self):
|
||||
for site, upstream in ((":3051", ":3050"), (":60847", ":60845")):
|
||||
with self.subTest(site=site):
|
||||
m = re.search(r"^" + re.escape(site) + r" \{\n(.*?)^\}$", self.code, re.S | re.M)
|
||||
self.assertIsNotNone(m, f"{site} site not found")
|
||||
directives = [l.strip() for l in m.group(1).splitlines() if l.strip()]
|
||||
self.assertEqual(directives, [f"reverse_proxy {upstream}", "encode gzip zstd"])
|
||||
|
||||
def test_the_options_for_a_declared_prefix_are_gone(self):
|
||||
# Never needed once Caddy stops guessing: neither the option nor its
|
||||
# build-time assertion may linger half-wired.
|
||||
roles = _read("modules", "core", "roles.nix")
|
||||
self.assertNotIn("lanIPv6Prefixes", roles)
|
||||
self.assertNotIn("lanIPv6Prefixes", self.caddy)
|
||||
|
||||
|
||||
class CaddyRunsWhereItIsNeeded(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.caddy = _read("modules", "core", "caddy.nix")
|
||||
cls.code = _without_comments(cls.caddy)
|
||||
|
||||
def test_it_runs_for_domains_vhosts_or_the_bitcoin_uis(self):
|
||||
self.assertRegex(
|
||||
self.code,
|
||||
r"caddyEnabled\s*=\s*needsHttpsPorts\s*\|\|\s*extraVhosts\s*!=\s*\"\"\s*\|\|\s*servesRtl\s*;",
|
||||
)
|
||||
self.assertRegex(self.code, r"enable\s*=\s*caddyEnabled\s*;")
|
||||
|
||||
def test_rtl_and_mempool_follow_their_services(self):
|
||||
self.assertRegex(self.code,
|
||||
r"servesRtl\s*=\s*config\.sovran_systemsOS\.services\.bitcoin\s*;")
|
||||
self.assertRegex(
|
||||
self.code,
|
||||
r"servesMempool\s*=\s*servesRtl\s*&&\s*config\.sovran_systemsOS\.features\.mempool\s*;",
|
||||
)
|
||||
|
||||
def test_each_site_exists_only_where_its_service_does(self):
|
||||
self.assertRegex(self.code, r"lib\.optionalString\s+servesRtl\s*''\s*\n+:3051 \{")
|
||||
self.assertRegex(self.code, r"lib\.optionalString\s+servesMempool\s*''\s*\n+:60847 \{")
|
||||
# ... and they are written into the Caddyfile from that one place
|
||||
self.assertIn("${bitcoinUiSites}", self.caddy)
|
||||
|
||||
def test_rtl_and_mempool_still_proxy_to_their_loopback_ports(self):
|
||||
self.assertRegex(self.code, r":3051 \{[^}]*reverse_proxy :3050")
|
||||
self.assertRegex(self.code, r":60847 \{[^}]*reverse_proxy :60845")
|
||||
|
||||
|
||||
class HubPortExposure(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.hub = _read("modules", "core", "sovran-hub.nix")
|
||||
cls.roles = _read("modules", "core", "roles.nix")
|
||||
|
||||
def _firewall_value(self):
|
||||
m = re.search(
|
||||
r"^ networking\.firewall\.allowedTCPPorts =\s*(?P<value>.*?);\s*$",
|
||||
self.hub, re.S | re.M,
|
||||
)
|
||||
self.assertIsNotNone(m, "networking.firewall.allowedTCPPorts not found")
|
||||
return m.group("value")
|
||||
|
||||
def test_the_hub_port_is_never_opened_unconditionally(self):
|
||||
# Regression: this used to be `allowedTCPPorts = [ 8937 60847 ]` with
|
||||
# no mkIf and no option gate, on every role, Desktop Only included.
|
||||
value = self._firewall_value()
|
||||
self.assertNotRegex(value, r"^\s*\[")
|
||||
self.assertRegex(value, r"lib\.optionals\s+cfg\.hub\.directPort\s+\[\s*8937\s*\]")
|
||||
|
||||
def test_the_mempool_port_follows_mempool(self):
|
||||
self.assertRegex(
|
||||
self._firewall_value(),
|
||||
r"lib\.optionals\s+\(cfg\.services\.bitcoin\s*&&\s*cfg\.features\.mempool\)\s+\[\s*60847\s*\]",
|
||||
)
|
||||
|
||||
def test_nothing_else_is_opened_here(self):
|
||||
ports = re.findall(r"\[\s*(\d+)\s*\]", self._firewall_value())
|
||||
self.assertEqual(sorted(ports), ["60847", "8937"])
|
||||
|
||||
def test_direct_port_is_on_for_the_server_roles_and_off_for_desktop_only(self):
|
||||
m = re.search(r"directPort\s*=\s*lib\.mkOption\s*\{(.*?)\n \};", self.roles, re.S)
|
||||
self.assertIsNotNone(m, "hub.directPort option not found")
|
||||
self.assertRegex(m.group(1), r"default\s*=\s*!config\.sovran_systemsOS\.roles\.desktop\s*;")
|
||||
|
||||
def test_the_bind_is_ipv4_only_on_purpose(self):
|
||||
# IPv6 clients cannot reach the Hub, so the question of which IPv6
|
||||
# addresses are "local" never comes up. Widening the bind reopens it.
|
||||
self.assertIn('host="0.0.0.0"', self.hub)
|
||||
self.assertNotRegex(self.hub, r'host="::"')
|
||||
self.assertNotRegex(self.hub, r"both IPv4 and IPv6")
|
||||
|
||||
|
||||
class TheHubIsDocumentedAtItsPort(unittest.TestCase):
|
||||
|
||||
def test_no_document_still_sends_people_to_port_80(self):
|
||||
for name in (("README.md",), ("SECURITY.md",),
|
||||
("app", "sovran_systemsos_web", "templates", "index.html")):
|
||||
with self.subTest(file=name[-1]):
|
||||
text = _read(*name)
|
||||
self.assertNotRegex(text, r"sovransystemsos\.local(?!:8937)(?![a-z])",
|
||||
f"{name[-1]} sends people to sovransystemsos.local without :8937")
|
||||
|
||||
def test_the_documents_name_the_port(self):
|
||||
self.assertIn("http://sovransystemsos.local:8937", _read("README.md"))
|
||||
self.assertIn("http://sovransystemsos.local:8937", _read("SECURITY.md"))
|
||||
self.assertIn("hub.directPort", _read("SECURITY.md"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Guards for the Hub checking its own clients.
|
||||
|
||||
The Hub runs as root. Whether a packet may reach its port is up to the firewall
|
||||
and the router; the application adds a second lock by answering only this
|
||||
computer and the local network. These read the modules as text, like the other
|
||||
nix-file checks: nothing is run and nothing touches the network.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _option(src, name):
|
||||
m = re.search(name + r"\s*=\s*lib\.mkOption\s*\{(.*?)\n \};", src, re.S)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
class HubChecksItsOwnClients(unittest.TestCase):
|
||||
|
||||
def test_lan_only_option_exists_and_defaults_on(self):
|
||||
body = _option(_read("modules", "core", "roles.nix"), "lanOnly")
|
||||
self.assertIsNotNone(body, "hub.lanOnly option not found")
|
||||
self.assertRegex(body, r"default\s*=\s*true")
|
||||
|
||||
def test_extra_networks_option_exists_and_defaults_empty(self):
|
||||
body = _option(_read("modules", "core", "roles.nix"), "extraLanNetworks")
|
||||
self.assertIsNotNone(body, "hub.extraLanNetworks option not found")
|
||||
self.assertRegex(body, r"default\s*=\s*\[\s*\]")
|
||||
|
||||
def test_policy_is_baked_into_the_generated_config(self):
|
||||
hub = _read("modules", "core", "sovran-hub.nix")
|
||||
self.assertRegex(hub, r"lan_only\s*=\s*cfg\.hub\.lanOnly\s*;")
|
||||
self.assertRegex(hub, r"lan_extra_networks\s*=\s*cfg\.hub\.extraLanNetworks\s*;")
|
||||
|
||||
def test_a_typo_is_caught_at_build_time(self):
|
||||
hub = _read("modules", "core", "sovran-hub.nix")
|
||||
self.assertIn("builtins.all lanNetworkOk cfg.hub.extraLanNetworks", hub)
|
||||
|
||||
def test_the_hub_enforces_it_in_its_own_middleware(self):
|
||||
server = _read("app", "sovran_systemsos_web", "server.py")
|
||||
self.assertIn("class LanOnlyMiddleware(BaseHTTPMiddleware)", server)
|
||||
self.assertIn("app.add_middleware(LanOnlyMiddleware", server)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Tests for the Hub's local-network policy and the middleware that enforces it.
|
||||
|
||||
The Hub runs as root, so it answers this computer and the local network and
|
||||
nobody else: LanPolicy in security_helpers decides, LanOnlyMiddleware in
|
||||
server.py enforces it before authentication is considered.
|
||||
|
||||
LanPolicy is exercised directly. The middleware is exercised over real HTTP
|
||||
where the environment allows it; server.py cannot be imported from this repo
|
||||
(it needs sovran_nwc from the Sovran_Bitcoin flake), so those tests skip rather
|
||||
than fail, and the wiring is additionally asserted from source so it is always
|
||||
checked.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
|
||||
if _APP_PARENT not in sys.path:
|
||||
sys.path.insert(0, _APP_PARENT)
|
||||
|
||||
from sovran_systemsos_web.security_helpers import ( # noqa: E402
|
||||
LanPolicy,
|
||||
LAN_ONLY_IPV4,
|
||||
LAN_ONLY_IPV6,
|
||||
)
|
||||
|
||||
_LOCAL = (
|
||||
"127.0.0.1", "10.0.0.1", "172.16.0.1", "172.31.255.254", "192.168.1.10",
|
||||
"100.64.0.1", "169.254.1.1",
|
||||
"::1", "fd12:3456::1", "fc00::1", "fe80::1",
|
||||
)
|
||||
|
||||
_REMOTE = (
|
||||
"8.8.8.8", "1.1.1.1", "203.0.113.9", "9.255.255.255",
|
||||
"172.15.255.255", "172.32.0.1", "192.169.0.1", "100.63.255.255",
|
||||
# public IPv6 — every address in 2000::/3 is on the internet
|
||||
"2001:4860:4860::8888", "2606:4700:4700::1111",
|
||||
"2a00:1450:4001::1", "2400:cb00::1",
|
||||
)
|
||||
|
||||
|
||||
class LanPolicyMatrix(unittest.TestCase):
|
||||
|
||||
def test_local_addresses_are_allowed(self):
|
||||
policy = LanPolicy()
|
||||
for address in _LOCAL:
|
||||
with self.subTest(local=address):
|
||||
self.assertTrue(policy.allows(address))
|
||||
|
||||
def test_remote_addresses_are_refused(self):
|
||||
policy = LanPolicy()
|
||||
for address in _REMOTE:
|
||||
with self.subTest(remote=address):
|
||||
self.assertFalse(policy.allows(address))
|
||||
|
||||
def test_ipv6_global_is_not_whitelisted(self):
|
||||
# 2000::/3 is the whole IPv6 global unicast space: allowing it would
|
||||
# let every public IPv6 address through.
|
||||
joined = " ".join(LAN_ONLY_IPV4 + LAN_ONLY_IPV6)
|
||||
self.assertNotIn("2000::/3", joined)
|
||||
for net in LanPolicy().networks:
|
||||
if net.version == 6:
|
||||
with self.subTest(range=str(net)):
|
||||
self.assertTrue(str(net).startswith(("::1", "fc00", "fe80")),
|
||||
f"{net} is not a local-only IPv6 range")
|
||||
|
||||
|
||||
class LanPolicyConfiguration(unittest.TestCase):
|
||||
|
||||
def test_declared_networks_are_allowed(self):
|
||||
policy = LanPolicy(extra_networks=["203.0.113.0/28", "2001:db8:abcd::/48"])
|
||||
self.assertTrue(policy.allows("203.0.113.9"))
|
||||
self.assertFalse(policy.allows("203.0.113.16"))
|
||||
self.assertTrue(policy.allows("2001:db8:abcd::5"))
|
||||
self.assertFalse(policy.allows("2001:db8:abce::5"))
|
||||
|
||||
def test_a_bare_address_is_a_single_host(self):
|
||||
policy = LanPolicy(extra_networks=["203.0.113.9"])
|
||||
self.assertTrue(policy.allows("203.0.113.9"))
|
||||
self.assertFalse(policy.allows("203.0.113.10"))
|
||||
|
||||
def test_disabled_allows_everything(self):
|
||||
policy = LanPolicy(enabled=False)
|
||||
for address in _REMOTE:
|
||||
with self.subTest(remote=address):
|
||||
self.assertTrue(policy.allows(address))
|
||||
|
||||
def test_malformed_network_does_not_widen_the_policy(self):
|
||||
# A typo must fail closed, not open the Hub to everything.
|
||||
policy = LanPolicy(extra_networks=["not-a-network", "203.0.113.0/28"])
|
||||
self.assertTrue(policy.allows("203.0.113.9"))
|
||||
self.assertFalse(policy.allows("8.8.8.8"))
|
||||
|
||||
def test_a_zero_length_prefix_is_not_a_network(self):
|
||||
# 0.0.0.0/0 and ::/0 mean "everyone". That is lan_only = false and it
|
||||
# has to be asked for by name rather than arrive as a "network".
|
||||
policy = LanPolicy(extra_networks=["0.0.0.0/0", "::/0"])
|
||||
for address in _REMOTE:
|
||||
with self.subTest(remote=address):
|
||||
self.assertFalse(policy.allows(address))
|
||||
|
||||
def test_missing_or_unparseable_client_is_refused(self):
|
||||
policy = LanPolicy()
|
||||
for address in (None, "", "testclient", "not-an-ip"):
|
||||
with self.subTest(client=address):
|
||||
self.assertFalse(policy.allows(address))
|
||||
|
||||
def test_a_dual_stack_socket_does_not_hide_the_ipv4_client(self):
|
||||
# With an IPv6 listener, IPv4 clients arrive as ::ffff:a.b.c.d. The
|
||||
# address that counts is the IPv4 one inside it, both ways round.
|
||||
policy = LanPolicy()
|
||||
for address in ("::ffff:192.168.1.5", "::ffff:127.0.0.1", "::ffff:10.1.2.3"):
|
||||
with self.subTest(local=address):
|
||||
self.assertTrue(policy.allows(address))
|
||||
for address in ("::ffff:8.8.8.8", "::ffff:203.0.113.9"):
|
||||
with self.subTest(remote=address):
|
||||
self.assertFalse(policy.allows(address))
|
||||
|
||||
|
||||
# ── Middleware ───────────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from sovran_systemsos_web.server import LanOnlyMiddleware # noqa: E402
|
||||
HAVE_MIDDLEWARE = True
|
||||
except Exception: # fastapi / sovran_nwc unavailable from this repo
|
||||
HAVE_MIDDLEWARE = False
|
||||
|
||||
|
||||
def _app_with(policy):
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/ping")
|
||||
async def ping():
|
||||
return {"ok": True}
|
||||
|
||||
app.add_middleware(LanOnlyMiddleware, policy=policy)
|
||||
return app
|
||||
|
||||
|
||||
@unittest.skipUnless(HAVE_MIDDLEWARE, "server.py is not importable here")
|
||||
class LanOnlyMiddlewareOverHttp(unittest.TestCase):
|
||||
|
||||
def _status(self, policy, client_ip):
|
||||
client = TestClient(_app_with(policy), client=(client_ip, 51234))
|
||||
return client.get("/ping").status_code
|
||||
|
||||
def test_local_client_is_served(self):
|
||||
for address in ("127.0.0.1", "192.168.1.10", "10.0.0.1"):
|
||||
with self.subTest(local=address):
|
||||
self.assertEqual(self._status(LanPolicy(), address), 200)
|
||||
|
||||
def test_remote_client_is_refused(self):
|
||||
for address in ("203.0.113.9", "8.8.8.8", "2001:4860:4860::8888"):
|
||||
with self.subTest(remote=address):
|
||||
self.assertEqual(self._status(LanPolicy(), address), 403)
|
||||
|
||||
def test_refusal_says_nothing_about_the_configuration(self):
|
||||
# An outsider learns that the answer is no, not why or what to change.
|
||||
client = TestClient(_app_with(LanPolicy()), client=("203.0.113.9", 51234))
|
||||
response = client.get("/ping")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json(), {"detail": "Not available from this network"})
|
||||
|
||||
def test_disabled_policy_admits_remote_clients(self):
|
||||
self.assertEqual(self._status(LanPolicy(enabled=False), "203.0.113.9"), 200)
|
||||
|
||||
def test_a_refused_address_is_logged_once(self):
|
||||
# The operator whose own device is refused needs to find out why; a
|
||||
# scanner must not be able to fill the journal.
|
||||
client = TestClient(_app_with(LanPolicy()), client=("203.0.113.9", 51234))
|
||||
with self.assertLogs("sovran_systemsos_web.server", level="WARNING") as seen:
|
||||
for _ in range(5):
|
||||
client.get("/ping")
|
||||
self.assertEqual(len(seen.records), 1)
|
||||
message = seen.records[0].getMessage()
|
||||
self.assertIn("203.0.113.9", message)
|
||||
self.assertIn("sovran_systemsOS.hub.extraLanNetworks", message)
|
||||
|
||||
def test_a_served_client_is_not_logged(self):
|
||||
records = []
|
||||
|
||||
class _Collect(logging.Handler):
|
||||
def emit(self, record):
|
||||
records.append(record)
|
||||
|
||||
logger = logging.getLogger("sovran_systemsos_web.server")
|
||||
handler = _Collect(level=logging.WARNING)
|
||||
logger.addHandler(handler)
|
||||
try:
|
||||
client = TestClient(_app_with(LanPolicy()), client=("192.168.1.10", 51234))
|
||||
client.get("/ping")
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
self.assertEqual(records, [])
|
||||
|
||||
|
||||
# ── Wiring, checked from source so it always runs ─────────────────────────────
|
||||
|
||||
def _server_source():
|
||||
with open(os.path.join(_APP_PARENT, "sovran_systemsos_web", "server.py"),
|
||||
encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
class LanOnlyWiring(unittest.TestCase):
|
||||
|
||||
def test_middleware_is_registered_outermost(self):
|
||||
# Starlette makes the last-registered middleware the outermost one, so
|
||||
# an off-network client is turned away before auth is considered.
|
||||
src = _server_source()
|
||||
auth = src.index("app.add_middleware(AuthMiddleware)")
|
||||
nocache = src.index("app.add_middleware(NoCacheMiddleware)")
|
||||
lan = src.index("app.add_middleware(LanOnlyMiddleware")
|
||||
self.assertLess(auth, nocache)
|
||||
self.assertLess(nocache, lan)
|
||||
|
||||
def test_policy_comes_from_the_generated_config(self):
|
||||
src = _server_source()
|
||||
self.assertIn("LanPolicy(", src)
|
||||
self.assertIn('_hub_cfg.get("lan_only", True)', src)
|
||||
self.assertIn('_hub_cfg.get("lan_extra_networks")', src)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Tests for the Hub's login throttling.
|
||||
|
||||
These exercise the exact production implementation in
|
||||
sovran_systemsos_web.security_helpers.LoginThrottle. The clock and the sleep are
|
||||
injected, so the tests cover hours of lockout behaviour instantly.
|
||||
|
||||
No network access, no filesystem writes, no real delays.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_APP_PARENT = os.path.join(_REPO_ROOT, "app")
|
||||
if _APP_PARENT not in sys.path:
|
||||
sys.path.insert(0, _APP_PARENT)
|
||||
|
||||
from sovran_systemsos_web.security_helpers import ( # noqa: E402
|
||||
LoginThrottle,
|
||||
LOGIN_FAIL_DELAY,
|
||||
LOGIN_FAIL_MAX_DELAY,
|
||||
LOGIN_FAIL_WINDOW,
|
||||
LOGIN_FAIL_MAX,
|
||||
LOGIN_LOCKOUT_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""A clock that only moves when the test says so."""
|
||||
|
||||
def __init__(self):
|
||||
self.now = 1000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds):
|
||||
self.now += seconds
|
||||
|
||||
|
||||
class FakeSleeper:
|
||||
"""Records the delays it was asked to apply instead of sleeping."""
|
||||
|
||||
def __init__(self, clock):
|
||||
self.clock = clock
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, seconds):
|
||||
self.calls.append(seconds)
|
||||
self.clock.advance(seconds)
|
||||
|
||||
|
||||
def _make(**kwargs):
|
||||
clock = kwargs.pop("clock", None) or FakeClock()
|
||||
sleep = kwargs.pop("sleep", None) or FakeSleeper(clock)
|
||||
return LoginThrottle(clock=clock, sleep=sleep, **kwargs), clock, sleep
|
||||
|
||||
|
||||
def _trip(throttle, ip="203.0.113.9"):
|
||||
"""Fail LOGIN_FAIL_MAX times. The fake sleeper advances the clock for us."""
|
||||
for _ in range(LOGIN_FAIL_MAX):
|
||||
throttle.record_failure(ip)
|
||||
|
||||
|
||||
class DelayRamp(unittest.TestCase):
|
||||
|
||||
def test_delay_ramps_with_the_failure_count(self):
|
||||
throttle, _, _ = _make()
|
||||
self.assertEqual(throttle.delay_for(0), 0.0)
|
||||
self.assertEqual(throttle.delay_for(1), LOGIN_FAIL_DELAY)
|
||||
self.assertEqual(throttle.delay_for(3), LOGIN_FAIL_DELAY * 3)
|
||||
|
||||
def test_delay_is_capped(self):
|
||||
# Unbounded ramping would let a single client park a thread-pool worker
|
||||
# for minutes at a time.
|
||||
throttle, _, _ = _make()
|
||||
self.assertLessEqual(throttle.delay_for(999), LOGIN_FAIL_MAX_DELAY)
|
||||
self.assertEqual(throttle.delay_for(999), LOGIN_FAIL_MAX_DELAY)
|
||||
|
||||
def test_first_failure_is_not_delayed_much(self):
|
||||
throttle, _, sleep = _make()
|
||||
delay = throttle.record_failure("203.0.113.9")
|
||||
self.assertEqual(delay, LOGIN_FAIL_DELAY)
|
||||
self.assertEqual(sleep.calls, [LOGIN_FAIL_DELAY])
|
||||
|
||||
|
||||
class Lockout(unittest.TestCase):
|
||||
|
||||
def test_not_locked_out_initially(self):
|
||||
throttle, _, _ = _make()
|
||||
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
|
||||
self.assertEqual(throttle.remaining_lockout("203.0.113.9"), 0.0)
|
||||
|
||||
def test_reaching_the_limit_locks_the_address_out(self):
|
||||
throttle, _, _ = _make()
|
||||
_trip(throttle)
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_the_limit_is_reachable_inside_the_window(self):
|
||||
# Regression guard for the old 60s window: with a ramping delay it
|
||||
# takes ~80s to reach LOGIN_FAIL_MAX, so a 60s window expired the
|
||||
# earliest failures first and the lockout could never fire.
|
||||
throttle, clock, _ = _make()
|
||||
start = clock.now
|
||||
_trip(throttle)
|
||||
self.assertLess(clock.now - start, LOGIN_FAIL_WINDOW)
|
||||
self.assertEqual(throttle.failure_count("203.0.113.9"), LOGIN_FAIL_MAX)
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_one_failure_short_of_the_limit_is_not_a_lockout(self):
|
||||
throttle, _, _ = _make()
|
||||
for _ in range(LOGIN_FAIL_MAX - 1):
|
||||
throttle.record_failure("203.0.113.9")
|
||||
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_lockout_expires(self):
|
||||
throttle, clock, _ = _make()
|
||||
_trip(throttle)
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
clock.advance(LOGIN_LOCKOUT_SECONDS + 1)
|
||||
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_remaining_lockout_counts_down(self):
|
||||
throttle, clock, _ = _make()
|
||||
_trip(throttle)
|
||||
full = throttle.remaining_lockout("203.0.113.9")
|
||||
# the final record_failure applied a delay, which the fake clock has
|
||||
# already advanced, so what is left is the lockout minus that delay
|
||||
self.assertAlmostEqual(full, LOGIN_LOCKOUT_SECONDS,
|
||||
delta=LOGIN_FAIL_MAX_DELAY + 1.0)
|
||||
clock.advance(full / 2)
|
||||
self.assertLess(throttle.remaining_lockout("203.0.113.9"), full)
|
||||
self.assertGreater(throttle.remaining_lockout("203.0.113.9"), 0.0)
|
||||
|
||||
def test_further_failures_while_locked_out_extend_it(self):
|
||||
throttle, clock, _ = _make()
|
||||
_trip(throttle)
|
||||
clock.advance(LOGIN_LOCKOUT_SECONDS - 1)
|
||||
throttle.record_failure("203.0.113.9")
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
|
||||
class Isolation(unittest.TestCase):
|
||||
|
||||
def test_one_address_does_not_lock_out_another(self):
|
||||
throttle, _, _ = _make()
|
||||
_trip(throttle, "203.0.113.9")
|
||||
self.assertTrue(throttle.is_locked_out("203.0.113.9"))
|
||||
self.assertFalse(throttle.is_locked_out("198.51.100.7"))
|
||||
|
||||
def test_successful_login_clears_the_address(self):
|
||||
throttle, _, _ = _make()
|
||||
for _ in range(LOGIN_FAIL_MAX - 1):
|
||||
throttle.record_failure("203.0.113.9")
|
||||
throttle.clear("203.0.113.9")
|
||||
self.assertEqual(throttle.failure_count("203.0.113.9"), 0)
|
||||
self.assertFalse(throttle.is_locked_out("203.0.113.9"))
|
||||
|
||||
def test_old_failures_age_out_of_the_window(self):
|
||||
throttle, clock, _ = _make()
|
||||
throttle.record_failure("203.0.113.9")
|
||||
clock.advance(LOGIN_FAIL_WINDOW + 1)
|
||||
self.assertEqual(throttle.failure_count("203.0.113.9"), 0)
|
||||
|
||||
|
||||
class BoundedMemory(unittest.TestCase):
|
||||
|
||||
def test_tracked_addresses_are_evicted(self):
|
||||
throttle, clock, _ = _make(max_tracked_ips=8)
|
||||
for i in range(64):
|
||||
throttle.record_failure(f"198.51.100.{i}")
|
||||
clock.advance(LOGIN_FAIL_WINDOW + LOGIN_LOCKOUT_SECONDS + 1)
|
||||
throttle.record_failure("203.0.113.9")
|
||||
self.assertLessEqual(throttle.tracked_addresses(), 8)
|
||||
|
||||
def test_sleep_is_never_called_under_the_lock(self):
|
||||
# If the lock were held across the sleep, one slow client would stall
|
||||
# every other login — a self-inflicted DoS.
|
||||
throttle, clock, _ = _make()
|
||||
order = []
|
||||
|
||||
def spy(seconds):
|
||||
order.append("sleep:start")
|
||||
clock.advance(seconds)
|
||||
order.append("sleep:end")
|
||||
|
||||
throttle._sleep = spy
|
||||
throttle.record_failure("203.0.113.9")
|
||||
self.assertEqual(order, ["sleep:start", "sleep:end"])
|
||||
# A second address can still be recorded while the first is "sleeping".
|
||||
self.assertEqual(throttle.failure_count("198.51.100.7"), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Guards for the Mullvad VPN client being a plain Desktop Role install.
|
||||
|
||||
The desktop app and its daemon ship with Desktop Only and with no other role:
|
||||
Server + Desktop and Bitcoin Node are machines that serve, and a VPN client with
|
||||
its own DNS and routing policy is not something their services asked for.
|
||||
|
||||
Beyond that the install is deliberately boring. It is the NixOS service exactly
|
||||
as NixOS ships it — no local-network boot service, no DNS or firewall edits of
|
||||
our own, no Hub tile, no Feature Manager card, no toggles. The account, the
|
||||
relay and the kill switch belong to the app.
|
||||
|
||||
Like the other nix-file checks these read the shipped sources as text: nothing is
|
||||
run and nothing touches the network.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _without_comments(src):
|
||||
"""The source with whole-line `#` comments removed."""
|
||||
return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
|
||||
|
||||
|
||||
class TheClientIsBuiltOnDesktopAndNowhereElse(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.module = _read("modules", "mullvad.nix")
|
||||
cls.code = _without_comments(cls.module)
|
||||
|
||||
def test_the_module_is_imported(self):
|
||||
self.assertIn("./mullvad.nix", _read("modules", "modules.nix"))
|
||||
|
||||
def test_the_gate_is_the_desktop_role(self):
|
||||
self.assertRegex(
|
||||
self.code,
|
||||
r"lib\.mkIf\s+config\.sovran_systemsOS\.roles\.desktop\s*\{",
|
||||
)
|
||||
|
||||
def test_no_other_role_is_mentioned(self):
|
||||
self.assertNotIn("server_plus_desktop", self.code)
|
||||
self.assertNotIn("roles.node", self.code)
|
||||
|
||||
def test_the_install_is_the_daemon_and_the_app(self):
|
||||
self.assertRegex(self.code, r"services\.mullvad-vpn\s*=\s*\{")
|
||||
self.assertRegex(self.code, r"(?m)^ enable = true;$")
|
||||
self.assertRegex(self.code, r"gui\.enable = true;")
|
||||
|
||||
|
||||
class TheInstallStaysVanilla(unittest.TestCase):
|
||||
"""Nothing is configured for the user, in the module or out of it."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.module = _without_comments(_read("modules", "mullvad.nix"))
|
||||
|
||||
def test_no_extra_services_are_added(self):
|
||||
self.assertNotIn("systemd.services", self.module)
|
||||
|
||||
def test_no_dns_or_network_edits_of_our_own(self):
|
||||
# The NixOS module already turns on systemd-resolved and points
|
||||
# NetworkManager at it; re-declaring either here would be our policy,
|
||||
# not the distribution's.
|
||||
for key in ("services.resolved", "networkmanager", "networking."):
|
||||
self.assertNotIn(key, self.module)
|
||||
|
||||
def test_no_firewall_or_kernel_edits(self):
|
||||
self.assertNotIn("firewall", self.module)
|
||||
self.assertNotIn("boot.", self.module)
|
||||
|
||||
def test_no_packages_are_forced_in(self):
|
||||
self.assertNotIn("environment.systemPackages", self.module)
|
||||
|
||||
def test_the_feature_flag_does_not_come_back(self):
|
||||
# A Hub-managed feature flag on an app that is simply installed would
|
||||
# put a toggle in front of something that has no decision to make.
|
||||
for path in (("modules", "core", "roles.nix"),
|
||||
("modules", "core", "role-logic.nix")):
|
||||
self.assertNotIn("mullvad", _read(*path).lower(), f"{path[-1]} mentions mullvad")
|
||||
|
||||
|
||||
class TheHubDoesNotMentionIt(unittest.TestCase):
|
||||
"""The app is not a service the Hub manages or announces."""
|
||||
|
||||
def test_the_hub_tiles_and_roles_are_untouched(self):
|
||||
self.assertNotIn("mullvad", _read("modules", "core", "sovran-hub.nix").lower())
|
||||
|
||||
def test_the_hub_backend_is_untouched(self):
|
||||
self.assertNotIn("mullvad", _read("app", "sovran_systemsos_web", "server.py").lower())
|
||||
|
||||
def test_the_desktop_shell_is_untouched(self):
|
||||
# The app appears in the GNOME app grid by itself; no folder edit, no
|
||||
# dock entry.
|
||||
self.assertNotIn(
|
||||
"mullvad", _read("modules", "core", "sovran_systemsos-desktop.nix").lower())
|
||||
|
||||
def test_there_is_no_icon_for_it(self):
|
||||
# app/icons holds Hub tile icons only.
|
||||
self.assertFalse(
|
||||
os.path.exists(os.path.join(_ROOT, "app", "icons", "mullvad.svg")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Guards for when port 22 is open in the firewall.
|
||||
|
||||
sshd-localhost.nix gives every role "ssh root@localhost" by listening on
|
||||
127.0.0.1 only. NixOS opens sshd's ports in the firewall by default whether or
|
||||
not sshd listens on them, which left port 22 open on every role, Desktop Only
|
||||
included, with nothing behind it. The roles that really publish SSH open it
|
||||
explicitly, so the default has to stay off.
|
||||
|
||||
Like the other nix-file checks these read the modules as text: nothing is run
|
||||
and nothing touches the network.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _without_comments(src):
|
||||
return "\n".join(l for l in src.splitlines() if not l.lstrip().startswith("#"))
|
||||
|
||||
|
||||
class LocalhostSshdDoesNotOpenThePort(unittest.TestCase):
|
||||
|
||||
def test_the_firewall_is_not_opened_by_default(self):
|
||||
code = _without_comments(_read("modules", "core", "sshd-localhost.nix"))
|
||||
self.assertRegex(code, r"openFirewall\s*=\s*lib\.mkDefault\s+false\s*;")
|
||||
|
||||
def test_it_still_listens_on_loopback_only(self):
|
||||
code = _without_comments(_read("modules", "core", "sshd-localhost.nix"))
|
||||
self.assertRegex(code, r'addr\s*=\s*"127\.0\.0\.1"')
|
||||
self.assertNotIn("0.0.0.0", code)
|
||||
|
||||
|
||||
class PublishedSshOpensItsOwnPort(unittest.TestCase):
|
||||
"""Turning the default off must not close the roles that want SSH open."""
|
||||
|
||||
def test_the_sshd_feature_opens_22_and_only_when_enabled(self):
|
||||
src = _without_comments(_read("modules", "sshd.nix"))
|
||||
self.assertRegex(src, r"lib\.mkIf\s+config\.sovran_systemsOS\.features\.sshd")
|
||||
self.assertRegex(src, r"networking\.firewall\.allowedTCPPorts\s*=\s*\[\s*22\s*\]")
|
||||
|
||||
def test_remote_deploy_opens_22_and_only_when_enabled(self):
|
||||
src = _without_comments(_read("modules", "core", "remote-deploy.nix"))
|
||||
self.assertRegex(src, r"lib\.mkIf\s+cfg\.enable")
|
||||
self.assertRegex(src, r"networking\.firewall\.allowedTCPPorts\s*=\s*\[\s*22\s*\]")
|
||||
|
||||
|
||||
class DesktopOnlyDocumentsWhatItOpens(unittest.TestCase):
|
||||
|
||||
def test_security_policy_says_desktop_opens_no_tcp_port(self):
|
||||
text = " ".join(_read("SECURITY.md").split()) # the file is line-wrapped
|
||||
self.assertIn("opens no TCP port", text)
|
||||
self.assertIn("UDP 5353", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user