Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a87302645 | ||
|
|
b79a6fd7f2 | ||
|
|
3694ea6489 | ||
|
|
6987d9bf2c | ||
|
|
ebcc17ae3c | ||
|
|
78bfc5b408 | ||
|
|
361b25a8bd | ||
|
|
7d784eb653 | ||
|
|
c33457fff2 | ||
|
|
34cfba4282 | ||
|
|
2d777450e1 | ||
|
|
726fec1990 | ||
|
|
70ccf1eba1 | ||
|
|
f6caa2ff32 | ||
|
|
8bc325b148 | ||
|
|
e31094c194 | ||
|
|
09d4cc9b83 | ||
|
|
3341659a0c | ||
|
|
32e1119e33 | ||
|
|
0f7ef8422d | ||
|
|
dd6042928a | ||
|
|
74405b2ffc | ||
|
|
258da6a337 | ||
|
|
73ab3f40c1 | ||
|
|
0768712bf7 | ||
|
|
2ac30dc10a |
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- Postgresql: drop per-database autovacuum ALTERs (rejected by Postgres)
|
||||||
|
- Synapse: performance tuning for 32 GB Server+Desktop hosts
|
||||||
|
- Nextcloud, postgresql: fix Nextcloud 35 DB warnings on 32 GB hosts
|
||||||
|
- Clean up flake.nix by removing comments and LiveKit override
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated nixpkgs
|
||||||
|
- Updated to php85 and fixes
|
||||||
|
- Updated to proper syntax to prevent build errors.
|
||||||
|
- Updated flake lock which contains Bisq 1.10.8 and Bisq2 2.1.13
|
||||||
|
[1.1.7]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.7
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.6] - 2026-09-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Rename 'The Sovran Hub' to 'The Hub' in README
|
||||||
|
- Rename 'Sovran Hub' to 'The Hub' in README
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated nix packages includes the new mempool version
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Update Sovran Hub screenshot to the v1.1.5 redesign
|
||||||
|
[1.1.6]: https://git.sovransystems.com/Sovran_Systems/Sovran_SystemsOS/releases/tag/v1.1.6
|
||||||
|
|
||||||
|
|
||||||
## [1.1.5] - 2026-09-09
|
## [1.1.5] - 2026-09-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
|
|||||||
are ready.
|
are ready.
|
||||||
|
|
||||||
[Visit the Website](https://sovransystems.com) ·
|
[Visit the Website](https://sovransystems.com) ·
|
||||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.5.iso) ·
|
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso) ·
|
||||||
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
[Try it safely in a VM](#try-it-first-in-a-virtual-machine) ·
|
||||||
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.5.iso.sha256) ·
|
[Verify the Download](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso.sha256) ·
|
||||||
[Build from Source](#build-from-source)
|
[Build from Source](#build-from-source)
|
||||||
|
|
||||||
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
<img src="assets/desktop-screenshot.webp" alt="Sovran_SystemsOS private Bitcoin desktop" width="800" />
|
||||||
@@ -45,7 +45,7 @@ are ready.
|
|||||||
- [What is included](#what-is-included)
|
- [What is included](#what-is-included)
|
||||||
- [Three modes](#three-modes)
|
- [Three modes](#three-modes)
|
||||||
- [Use it your way](#use-it-your-way)
|
- [Use it your way](#use-it-your-way)
|
||||||
- [The Sovran Hub](#the-sovran-hub)
|
- [The Hub](#the-hub)
|
||||||
- [Install Sovran_SystemsOS](#install-sovran_systemsos)
|
- [Install Sovran_SystemsOS](#install-sovran_systemsos)
|
||||||
- [For developers](#for-developers)
|
- [For developers](#for-developers)
|
||||||
- [Development workflow](#development-workflow)
|
- [Development workflow](#development-workflow)
|
||||||
@@ -202,7 +202,7 @@ Bitcoin and self-hosting infrastructure runs on the machine.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Desktop** | Everyday users and computers with modest hardware | Sparrow, Bisq, and Bisq 2 for self-custody and peer-to-peer Bitcoin use |
|
| **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 |
|
| **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 |
|
||||||
| **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 |
|
| **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
|
**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
|
edition. It is a complete, private everyday computer with a clean GNOME
|
||||||
@@ -237,6 +237,66 @@ communications, identity, and services.
|
|||||||
> provider allows port forwarding. Most home routers and providers already
|
> provider allows port forwarding. Most home routers and providers already
|
||||||
> support this. If you are unsure, a quick search for your router model and
|
> 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.
|
> "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.
|
||||||
|
|
||||||
|
### Server + Desktop and your home IP address
|
||||||
|
|
||||||
|
> **⚠️ Server + Desktop makes your home IP address public.**
|
||||||
|
> Public services need a domain name that points at your home internet
|
||||||
|
> connection. When you finish the guided domain setup, Sovran_SystemsOS puts
|
||||||
|
> your home's public IP address in a Dynamic DNS record at
|
||||||
|
> [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.
|
||||||
|
> - **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.
|
||||||
|
|
||||||
|
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*.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
<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
|
||||||
|
`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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -256,19 +316,19 @@ the tools of your selected mode already in place.
|
|||||||
Prefer to keep using Windows, macOS, Linux, Android, or iOS? Install
|
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
|
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,
|
network, with or without a monitor. From any other device on the same network,
|
||||||
open a browser, visit `http://sovransystemsos.local`, and manage everything
|
open a browser, visit `http://sovransystemsos.local:8937`, and manage
|
||||||
from [The Sovran Hub](#the-sovran-hub).
|
everything from [The Sovran Hub](#the-sovran-hub).
|
||||||
|
|
||||||
Your existing devices stay familiar. Sovran_SystemsOS provides the independent
|
Your existing devices stay familiar. Sovran_SystemsOS provides the independent
|
||||||
infrastructure behind them.
|
infrastructure behind them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The Sovran Hub
|
## The Hub
|
||||||
|
|
||||||
### Your private infrastructure, controlled from any screen.
|
### Your private infrastructure, controlled from any screen.
|
||||||
|
|
||||||
The Sovran Hub is the command center built into Sovran_SystemsOS. It is both a
|
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
|
local desktop application and a private web interface served directly by your
|
||||||
Sovran_SystemsOS machine. Nothing needs to be installed on the device opening
|
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: you only need a modern browser and access to the same local network.
|
||||||
@@ -281,9 +341,9 @@ From one place, the Hub helps you:
|
|||||||
- Reach your Bitcoin tools, private cloud, and communications
|
- Reach your Bitcoin tools, private cloud, and communications
|
||||||
- Perform supported system operations without everyday terminal commands
|
- Perform supported system operations without everyday terminal commands
|
||||||
|
|
||||||
<img src="assets/sovran-hub-screenshot.webp" alt="The Sovran Hub dashboard" width="800" />
|
<img src="assets/sovran-hub-screenshot.webp" alt="The Sovran Hub welcome dashboard" width="800" />
|
||||||
|
|
||||||
*The Sovran Hub: manage your private infrastructure from one place.*
|
*The Hub: your whole system at a glance — Bitcoin, Lightning, and your private apps.*
|
||||||
|
|
||||||
### Example home setup
|
### Example home setup
|
||||||
|
|
||||||
@@ -294,13 +354,13 @@ From one place, the Hub helps you:
|
|||||||
│ │ │
|
│ │ │
|
||||||
Windows laptop Phone or tablet Mac or Linux
|
Windows laptop Phone or tablet Mac or Linux
|
||||||
│ │ │
|
│ │ │
|
||||||
└──────── Browser: sovransystemsos.local ────┘
|
└─────── Browser: sovransystemsos.local:8937 ─┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────┐
|
┌──────────────────────────┐
|
||||||
│ Sovran_SystemsOS │
|
│ Sovran_SystemsOS │
|
||||||
│ │
|
│ │
|
||||||
│ • Sovran Hub │
|
│ • The Hub │
|
||||||
│ • Bitcoin node │
|
│ • Bitcoin node │
|
||||||
│ • Sparrow Wallet │
|
│ • Sparrow Wallet │
|
||||||
│ • Bisq and Bisq 2 │
|
│ • Bisq and Bisq 2 │
|
||||||
@@ -316,9 +376,10 @@ Keep using the devices you already own. Sovran_SystemsOS becomes the private
|
|||||||
Bitcoin and digital infrastructure behind them.
|
Bitcoin and digital infrastructure behind them.
|
||||||
|
|
||||||
> **Local access:** the Hub is available at
|
> **Local access:** the Hub is available at
|
||||||
> `http://sovransystemsos.local` to devices connected to the same local
|
> `http://sovransystemsos.local:8937` to devices connected to the same local
|
||||||
> network. It is protected by authentication and is not automatically exposed
|
> network (not on Desktop, which publishes nothing). It is protected by
|
||||||
> to the public internet.
|
> authentication, answers only your local network, and is not automatically
|
||||||
|
> exposed to the public internet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -345,8 +406,8 @@ with an imaging application such as [Balena Etcher](https://etcher.balena.io).
|
|||||||
|
|
||||||
### 1. Download the ISO and checksum
|
### 1. Download the ISO and checksum
|
||||||
|
|
||||||
- [Download Sovran_SystemsOS-1.1.5.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.5.iso)
|
- [Download Sovran_SystemsOS-1.2.0.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso)
|
||||||
- [Download Sovran_SystemsOS-1.1.5.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.5.iso.sha256)
|
- [Download Sovran_SystemsOS-1.2.0.iso.sha256](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso.sha256)
|
||||||
|
|
||||||
The download may take some time. Do not rename or modify the ISO before
|
The download may take some time. Do not rename or modify the ISO before
|
||||||
verifying it, and keep both files in the same folder.
|
verifying it, and keep both files in the same folder.
|
||||||
@@ -364,16 +425,16 @@ checksum exactly.
|
|||||||
Open a terminal in the download folder and run:
|
Open a terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sha256sum --check Sovran_SystemsOS-1.1.5.iso.sha256
|
sha256sum --check Sovran_SystemsOS-1.2.0.iso.sha256
|
||||||
```
|
```
|
||||||
|
|
||||||
A successful comparison reports:
|
A successful comparison reports:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Sovran_SystemsOS-1.1.5.iso: OK
|
Sovran_SystemsOS-1.2.0.iso: OK
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also run `sha256sum Sovran_SystemsOS-1.1.5.iso` and compare the output
|
You can also run `sha256sum Sovran_SystemsOS-1.2.0.iso` and compare the output
|
||||||
against the checksum file manually.
|
against the checksum file manually.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -384,11 +445,11 @@ against the checksum file manually.
|
|||||||
Open Terminal in the download folder and run:
|
Open Terminal in the download folder and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
shasum -a 256 Sovran_SystemsOS-1.1.5.iso
|
shasum -a 256 Sovran_SystemsOS-1.2.0.iso
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value shown in Terminal with the value inside
|
Compare the value shown in Terminal with the value inside
|
||||||
`Sovran_SystemsOS-1.1.5.iso.sha256`.
|
`Sovran_SystemsOS-1.2.0.iso.sha256`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -398,7 +459,7 @@ Compare the value shown in Terminal with the value inside
|
|||||||
Open PowerShell in the download folder and run:
|
Open PowerShell in the download folder and run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Get-FileHash .\Sovran_SystemsOS-1.1.5.iso -Algorithm SHA256
|
Get-FileHash .\Sovran_SystemsOS-1.2.0.iso -Algorithm SHA256
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare the value under `Hash` with the published checksum.
|
Compare the value under `Hash` with the published checksum.
|
||||||
@@ -413,7 +474,7 @@ match exactly.
|
|||||||
|
|
||||||
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
1. Download and install [Balena Etcher](https://etcher.balena.io), then
|
||||||
connect the USB drive.
|
connect the USB drive.
|
||||||
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.1.5.iso`.
|
2. Choose **Flash from file** and select `Sovran_SystemsOS-1.2.0.iso`.
|
||||||
3. Choose **Select target**, select the USB drive, and review your selection
|
3. Choose **Select target**, select the USB drive, and review your selection
|
||||||
carefully.
|
carefully.
|
||||||
4. Choose **Flash** and wait for the writing and verification process to
|
4. Choose **Flash** and wait for the writing and verification process to
|
||||||
@@ -476,18 +537,20 @@ Open the Hub directly from the Sovran_SystemsOS desktop, or from any other
|
|||||||
device on the same local network at:
|
device on the same local network at:
|
||||||
|
|
||||||
```text
|
```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>
|
<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
|
1. Make sure the Sovran_SystemsOS machine is powered on, and allow it a few
|
||||||
minutes to finish starting.
|
minutes to finish starting.
|
||||||
2. Make sure both devices are connected to the same local network, and that
|
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
|
3. Avoid guest Wi-Fi networks, which may prevent devices from seeing one
|
||||||
another.
|
another.
|
||||||
4. Temporarily disconnect any VPN that may interfere with local-network
|
4. Temporarily disconnect any VPN that may interfere with local-network
|
||||||
@@ -730,7 +793,9 @@ Sovran_SystemsOS uses layered controls:
|
|||||||
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
||||||
- Tor enforcement for supported Bitcoin services
|
- Tor enforcement for supported Bitcoin services
|
||||||
- Restricted, time-limited support access with scoped `sudo`
|
- Restricted, time-limited support access with scoped `sudo`
|
||||||
- Operator-controlled public service exposure
|
- Operator-controlled public service exposure (Server + Desktop
|
||||||
|
[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,
|
See [`SECURITY.md`](SECURITY.md) for the threat model, limitations, reporting,
|
||||||
and operator guidance. No operating system can protect funds after recovery
|
and operator guidance. No operating system can protect funds after recovery
|
||||||
@@ -856,7 +921,7 @@ primary location for collaboration. Please read our
|
|||||||
## Privacy. Sovereignty. Bitcoin.
|
## Privacy. Sovereignty. Bitcoin.
|
||||||
|
|
||||||
[Visit Sovran Systems](https://sovransystems.com) ·
|
[Visit Sovran Systems](https://sovransystems.com) ·
|
||||||
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.5.iso) ·
|
[Download Sovran_SystemsOS](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso) ·
|
||||||
[View the License](LICENSE)
|
[View the License](LICENSE)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+57
-2
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
| Release | Supported |
|
| Release | Supported |
|
||||||
|---|:---:|
|
|---|:---:|
|
||||||
| Latest `1.0.x` stable release | Yes |
|
| Latest stable release | Yes |
|
||||||
| `main` / `staging-dev` | Development only |
|
| `main` / `staging-dev` | Development only |
|
||||||
| Older than `1.0.0` | No |
|
| Older than `1.0.0` | No |
|
||||||
|
|
||||||
@@ -35,6 +35,60 @@ external networks and are outside a “fully offline” model.
|
|||||||
The local Hub currently uses HTTP. Authentication does not encrypt local network
|
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.
|
traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
|
||||||
|
|
||||||
|
The Hub is served on port 8937, on its own: Caddy does not front it. Server +
|
||||||
|
Desktop and Bitcoin Node Only open that port in the firewall, so other devices
|
||||||
|
on your local network reach the Hub at `http://sovransystemsos.local:8937`.
|
||||||
|
Forwarding ports 80 and 443 for public services does not put the Hub in front of
|
||||||
|
the internet, because the only thing Caddy answers on those ports is the public
|
||||||
|
sites.
|
||||||
|
|
||||||
|
On Desktop Only the Hub is not published at all. It is reachable only from the
|
||||||
|
machine itself, through the desktop application window on localhost. Desktop
|
||||||
|
Only is the role most likely to be used away from home, and a root-capable admin
|
||||||
|
UI has no business listening on a coffee-shop network. The firewall there opens
|
||||||
|
no TCP port at all; the only port open is UDP 5353, for mDNS.
|
||||||
|
`sovran_systemsOS.hub.directPort = true` in `custom.nix` opens port 8937 if you
|
||||||
|
do want to reach a Desktop Only Hub from another device.
|
||||||
|
|
||||||
|
The Hub also checks every client itself, before it shows a login page. It runs
|
||||||
|
as root, so it answers only this computer and the local network (loopback,
|
||||||
|
private, VPN and link-local addresses) and turns everyone else away, however
|
||||||
|
they reached it. The Hub listens on IPv4 only, so IPv6 clients do not reach it
|
||||||
|
at all; if that ever changes, global IPv6 addresses would be turned away,
|
||||||
|
because a laptop on your network and a stranger on the internet look the same
|
||||||
|
by address alone. If your devices use addresses outside the local ranges, list
|
||||||
|
their networks in `sovran_systemsOS.hub.extraLanNetworks` in `custom.nix`;
|
||||||
|
`sovran_systemsOS.hub.lanOnly = false` turns the check off.
|
||||||
|
|
||||||
|
The check goes by the address a connection comes from. A router that rewrites
|
||||||
|
that address when it forwards a port makes an outsider look local, so the check
|
||||||
|
is a second lock and not a reason to forward port 8937: don't.
|
||||||
|
|
||||||
|
Ride The Lightning (port 3051) and Mempool (port 60847) listen on loopback only,
|
||||||
|
and Caddy is how your local network reaches them. Caddy does not filter them by
|
||||||
|
client address: forwarding ports 80 and 443 for public services does not reach
|
||||||
|
them, because they answer on ports of their own, which nothing asks you to
|
||||||
|
forward. Do not forward 3051 or 60847. If you do, Ride The Lightning still asks
|
||||||
|
for its own random password and locks out repeated failures, and Mempool shows
|
||||||
|
public blockchain data, but neither should face the internet.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
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 stack
|
||||||
|
|
||||||
Bitcoin and Lightning modules are maintained in the standalone
|
Bitcoin and Lightning modules are maintained in the standalone
|
||||||
@@ -64,7 +118,8 @@ change both the system and local configuration.
|
|||||||
- Separate service users and systemd sandboxing where supported
|
- Separate service users and systemd sandboxing where supported
|
||||||
- Administrative service ports bound to loopback where practical
|
- Administrative service ports bound to loopback where practical
|
||||||
- Tor enforced for supported Bitcoin traffic and onion services
|
- Tor enforced for supported Bitcoin traffic and onion services
|
||||||
- Public web services exposed only when enabled by the operator
|
- Public web services exposed only when enabled by the operator (this makes
|
||||||
|
your home IP address public)
|
||||||
|
|
||||||
Tor reduces network exposure for configured Bitcoin services. It is not a
|
Tor reduces network exposure for configured Bitcoin services. It is not a
|
||||||
guarantee against every IP leak, application bug, or traffic-analysis attack.
|
guarantee against every IP leak, application bug, or traffic-analysis attack.
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sovran DDNS update runner (sovran-ddns-update.service).
|
||||||
|
|
||||||
|
For every stored Njal.la update URL this asks Njal.la to point the record at
|
||||||
|
the address the request came from ("&auto"), then reads back the address
|
||||||
|
Njal.la says it recorded. That address is saved to /var/lib/secrets/external-ip,
|
||||||
|
where livekit-turn-setup and the Hub read it.
|
||||||
|
|
||||||
|
Njal.la is the only party involved. It has to learn the address to publish
|
||||||
|
it, so nothing else -- no STUN server, no public resolver, no "what is my IP"
|
||||||
|
service -- is ever asked for it.
|
||||||
|
|
||||||
|
Kept from the previous runner:
|
||||||
|
* every URL goes through _validate_ddns_url() (https, njal.la only, /update/)
|
||||||
|
* curl is run directly: no shell, no redirects
|
||||||
|
* the update key is never printed or logged
|
||||||
|
|
||||||
|
The module is installed next to security_helpers.py (/etc/sovran/) and run as a
|
||||||
|
script by the service; it is also importable as
|
||||||
|
sovran_systemsos_web.ddns_update so the tests can exercise it.
|
||||||
|
"""
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .security_helpers import _validate_ddns_url # imported as part of the Hub package
|
||||||
|
except ImportError: # run as a script from /etc/sovran, next to security_helpers.py
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from security_helpers import _validate_ddns_url
|
||||||
|
|
||||||
|
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
||||||
|
IP_FILE = "/var/lib/secrets/external-ip"
|
||||||
|
|
||||||
|
_CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
||||||
|
|
||||||
|
|
||||||
|
def is_public_ipv4(value) -> bool:
|
||||||
|
"""True for a globally routable IPv4 literal (not private, loopback, CGNAT ...)."""
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(str(value).strip())
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if ip.version != 4 or ip in _CGNAT:
|
||||||
|
return False
|
||||||
|
# is_global alone is not enough: CPython reports multicast as global.
|
||||||
|
return ip.is_global and not (
|
||||||
|
ip.is_multicast or ip.is_reserved or ip.is_loopback
|
||||||
|
or ip.is_link_local or ip.is_unspecified or ip.is_private
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_url(raw: str) -> str:
|
||||||
|
"""Return the URL to call for a stored (or freshly pasted) update URL.
|
||||||
|
|
||||||
|
* Older Hubs stored "...&a=${IP}": the address was looked up locally and
|
||||||
|
substituted. Njal.la can use the address the request came from, so that
|
||||||
|
placeholder becomes "&auto".
|
||||||
|
* "&quiet" is dropped: the reply is how we learn the address Njal.la recorded.
|
||||||
|
"""
|
||||||
|
return raw.replace("&a=${IP}", "&auto").replace("&quiet", "")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_reply(body: str):
|
||||||
|
"""Return the public IPv4 address Njal.la says it recorded, or None.
|
||||||
|
|
||||||
|
A successful update replies with JSON of the form
|
||||||
|
{"status": 200, "message": "record updated", "value": {"A": "203.0.113.7", ...}}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict) or str(data.get("status")) != "200":
|
||||||
|
return None
|
||||||
|
value = data.get("value")
|
||||||
|
ip = value.get("A") if isinstance(value, dict) else None
|
||||||
|
return str(ip).strip() if is_public_ipv4(ip) else None
|
||||||
|
|
||||||
|
|
||||||
|
def read_ip_file(path: str = None):
|
||||||
|
"""The address recorded by the last successful update, or None."""
|
||||||
|
try:
|
||||||
|
with open(path or IP_FILE) as f:
|
||||||
|
return f.read().strip() or None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_ip_file(ip: str, path: str = None) -> None:
|
||||||
|
"""Replace the file atomically so a path watcher never sees a partial write."""
|
||||||
|
path = path or IP_FILE
|
||||||
|
directory = os.path.dirname(path)
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=directory, prefix=".external-ip-")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.write(ip)
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def update_all(urls, *, run=None, validate=_validate_ddns_url):
|
||||||
|
"""Call every update URL once; return the address Njal.la reported, or None.
|
||||||
|
|
||||||
|
Nothing secret is printed: URLs are only ever referred to by position.
|
||||||
|
"""
|
||||||
|
run = run or subprocess.run # resolved per call so tests can substitute it
|
||||||
|
reported = None
|
||||||
|
seen = set()
|
||||||
|
todo = []
|
||||||
|
for raw in urls:
|
||||||
|
url = normalise_url(raw)
|
||||||
|
if url not in seen: # an old "&a=${IP}" entry and its "&auto" twin are one record
|
||||||
|
seen.add(url)
|
||||||
|
todo.append(url)
|
||||||
|
for number, url in enumerate(todo, 1):
|
||||||
|
try:
|
||||||
|
validate(url)
|
||||||
|
proc = run(
|
||||||
|
["curl", "--silent", "--ipv4", "--max-time", "15", "--fail", "--no-location", url],
|
||||||
|
capture_output=True, text=True, timeout=20, check=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
print(f"DDNS update {number}/{len(todo)}: skipped (invalid URL or curl unavailable)")
|
||||||
|
continue
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(f"DDNS update {number}/{len(todo)}: failed (curl exit {proc.returncode})")
|
||||||
|
continue
|
||||||
|
ip = parse_reply(proc.stdout)
|
||||||
|
if ip is None:
|
||||||
|
print(f"DDNS update {number}/{len(todo)}: Njal.la did not report a public IPv4 address")
|
||||||
|
continue
|
||||||
|
print(f"DDNS update {number}/{len(todo)}: ok")
|
||||||
|
if reported is None:
|
||||||
|
reported = ip
|
||||||
|
elif ip != reported:
|
||||||
|
print("DDNS: Njal.la reported different addresses for different records; using the first")
|
||||||
|
return reported
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
with open(URLS_FILE) as f:
|
||||||
|
urls = json.load(f)
|
||||||
|
if not isinstance(urls, list):
|
||||||
|
raise ValueError("not a list")
|
||||||
|
except Exception:
|
||||||
|
return 0 # no URLs configured -- nothing to do
|
||||||
|
urls = [u for u in urls if isinstance(u, str)]
|
||||||
|
if not urls:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
ip = update_all(urls)
|
||||||
|
if ip is None:
|
||||||
|
print("DDNS: no address reported by Njal.la; keeping the last known one")
|
||||||
|
return 0
|
||||||
|
previous = read_ip_file()
|
||||||
|
if ip == previous:
|
||||||
|
print(f"DDNS: public IP unchanged ({ip})")
|
||||||
|
return 0
|
||||||
|
write_ip_file(ip)
|
||||||
|
print(f"DDNS: public IP is now {ip} (was {previous or 'unknown'})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -15,6 +15,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
@@ -245,6 +246,218 @@ def _validate_ssh_pubkey(key: str) -> str:
|
|||||||
return key
|
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 ─────────────────────────────────────────────
|
# ── Persistent Hub session store ─────────────────────────────────────────────
|
||||||
|
|
||||||
def load_session_store(path: str) -> dict[str, float]:
|
def load_session_store(path: str) -> dict[str, float]:
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import sys
|
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -41,6 +40,7 @@ from .config import load_config, load_versions
|
|||||||
from . import systemctl as sysctl
|
from . import systemctl as sysctl
|
||||||
from sovran_nwc import nwc_hub_manager as _nwc_mgr
|
from sovran_nwc import nwc_hub_manager as _nwc_mgr
|
||||||
from . import support_ops as _support_ops
|
from . import support_ops as _support_ops
|
||||||
|
from .ddns_update import normalise_url as _normalise_ddns_url
|
||||||
from .security_helpers import (
|
from .security_helpers import (
|
||||||
_nix_escape,
|
_nix_escape,
|
||||||
NPUB_RE,
|
NPUB_RE,
|
||||||
@@ -55,6 +55,13 @@ from .security_helpers import (
|
|||||||
_bech32_convertbits_decode,
|
_bech32_convertbits_decode,
|
||||||
load_session_store,
|
load_session_store,
|
||||||
save_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
|
from .update_state import effective_update_status
|
||||||
|
|
||||||
@@ -175,11 +182,19 @@ _sessions_lock = Lock()
|
|||||||
_SESSION_PERSIST_MIN_INTERVAL = 30.0 # seconds
|
_SESSION_PERSIST_MIN_INTERVAL = 30.0 # seconds
|
||||||
_sessions_last_persist = 0.0
|
_sessions_last_persist = 0.0
|
||||||
|
|
||||||
# Failed login tracking: ip → list of failure timestamps
|
# Failed login tracking.
|
||||||
_login_failures: dict[str, list[float]] = {}
|
#
|
||||||
LOGIN_FAIL_DELAY = 2.0 # seconds to sleep after a failed attempt
|
# LOGIN_FAIL_MAX used to be declared here and never read anywhere: the only
|
||||||
LOGIN_FAIL_WINDOW = 60.0 # rolling window (seconds) for counting failures
|
# thing a failed attempt cost an attacker was a flat 2 second delay, and there
|
||||||
LOGIN_FAIL_MAX = 10 # max failures in window before extra delay
|
# 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
|
# Public paths that are accessible without a valid session
|
||||||
_AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/auto-login", "/api/ping"}
|
_AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/auto-login", "/api/ping"}
|
||||||
@@ -770,19 +785,20 @@ def _ensure_onboarding_reopened_for_migration() -> None:
|
|||||||
logger.warning("Could not clear onboarding flag for migration flow: %s", exc)
|
logger.warning("Could not clear onboarding flag for migration flow: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def _record_failure(client_ip: str) -> None:
|
def _record_failure(client_ip: str) -> float:
|
||||||
"""Record a failed login attempt and apply a rate-limit delay.
|
"""Record a failed login attempt and apply the throttling delay.
|
||||||
|
|
||||||
Must always be called via loop.run_in_executor() so that the blocking
|
Must always be called via loop.run_in_executor() so that the blocking
|
||||||
time.sleep() does not stall the asyncio event loop.
|
time.sleep() does not stall the asyncio event loop.
|
||||||
|
|
||||||
|
Returns the delay that was applied.
|
||||||
"""
|
"""
|
||||||
now = time.time()
|
return _login_throttle.record_failure(client_ip)
|
||||||
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]
|
def _is_locked_out(client_ip: str) -> bool:
|
||||||
_login_failures[client_ip].append(now)
|
"""Return True while *client_ip* is inside a lockout."""
|
||||||
# Sleep in the thread-pool thread to slow brute-force without blocking the loop
|
return _login_throttle.is_locked_out(client_ip)
|
||||||
time.sleep(LOGIN_FAIL_DELAY)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Authentication middleware ─────────────────────────────────────
|
# ── Authentication middleware ─────────────────────────────────────
|
||||||
@@ -806,8 +822,61 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|||||||
return await call_next(request)
|
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(AuthMiddleware)
|
||||||
app.add_middleware(NoCacheMiddleware)
|
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(
|
_ICONS_DIR = os.environ.get(
|
||||||
"SOVRAN_HUB_ICONS",
|
"SOVRAN_HUB_ICONS",
|
||||||
@@ -1008,43 +1077,20 @@ def _save_internal_ip(ip: str):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _save_external_ip(ip: str):
|
|
||||||
"""Write the external IP to a file so other services (e.g. LiveKit) can
|
|
||||||
reference it without running their own detection."""
|
|
||||||
if ip and ip != "unavailable":
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(EXTERNAL_IP_FILE), exist_ok=True)
|
|
||||||
with open(EXTERNAL_IP_FILE, "w") as f:
|
|
||||||
f.write(ip)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _get_external_ip() -> str:
|
def _get_external_ip() -> str:
|
||||||
"""Public IP via the shared detector (/var/lib/sovran/public-ip.py).
|
"""Public IP as recorded by the Njal.la DDNS runner (ddns_update.py).
|
||||||
|
|
||||||
The detector owns discovery (STUN -> DNS -> opt-in HTTPS echo), caches the
|
Nothing here looks the address up or contacts anyone. The DDNS update asks
|
||||||
result in /var/lib/secrets/external-ip, and contacts at most one third
|
Njal.la to use the address the request came from, Njal.la reports it back,
|
||||||
party per refresh interval. This function only reads the cache and asks
|
and the runner saves it to EXTERNAL_IP_FILE. Returns "unavailable" until
|
||||||
the detector to refresh when it is missing or stale — it performs no
|
the first successful update (and on machines with no DDNS URL, e.g. Desktop).
|
||||||
per-call external queries of its own.
|
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
|
|
||||||
capture_output=True, text=True, timeout=20,
|
|
||||||
)
|
|
||||||
if r.returncode == 0 and r.stdout.strip():
|
|
||||||
return r.stdout.strip().splitlines()[0]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
try:
|
||||||
with open(EXTERNAL_IP_FILE) as f:
|
with open(EXTERNAL_IP_FILE) as f:
|
||||||
ip = f.read().strip()
|
ip = f.read().strip()
|
||||||
if ip:
|
ipaddress.ip_address(ip)
|
||||||
return ip
|
return ip
|
||||||
except OSError:
|
except (OSError, ValueError):
|
||||||
pass
|
|
||||||
return "unavailable"
|
return "unavailable"
|
||||||
|
|
||||||
|
|
||||||
@@ -2661,10 +2707,24 @@ async def api_login(req: LoginRequest, request: Request):
|
|||||||
"""Validate the Hub password and issue a session cookie."""
|
"""Validate the Hub password and issue a session cookie."""
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
loop = asyncio.get_event_loop()
|
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)
|
ok = await loop.run_in_executor(None, _check_password, req.password)
|
||||||
if not ok:
|
if not ok:
|
||||||
await loop.run_in_executor(None, _record_failure, client_ip)
|
await loop.run_in_executor(None, _record_failure, client_ip)
|
||||||
raise HTTPException(status_code=401, detail="Incorrect password")
|
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()
|
token = _create_session()
|
||||||
response = JSONResponse({"ok": True})
|
response = JSONResponse({"ok": True})
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
@@ -3796,9 +3856,6 @@ async def api_network():
|
|||||||
# Keep the internal-ip file in sync for credential lookups
|
# Keep the internal-ip file in sync for credential lookups
|
||||||
_save_internal_ip(internal)
|
_save_internal_ip(internal)
|
||||||
_cached_external_ip = external
|
_cached_external_ip = external
|
||||||
# Persist the external IP so other services (e.g. LiveKit) can reuse the
|
|
||||||
# Hub's detection instead of running their own.
|
|
||||||
_save_external_ip(external)
|
|
||||||
return {"internal_ip": internal, "external_ip": external}
|
return {"internal_ip": internal, "external_ip": external}
|
||||||
|
|
||||||
|
|
||||||
@@ -4723,48 +4780,23 @@ def _save_ddns_urls(urls: list[str]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _run_njalla_ddns() -> None:
|
def _run_njalla_ddns() -> None:
|
||||||
"""Update Njal.la DDNS records immediately (best-effort).
|
"""Ask the DDNS runner to update Njal.la right away (best-effort, non-blocking).
|
||||||
|
|
||||||
Resolves the current public IP once, then invokes ``curl`` directly as a
|
The runner (modules/core/njalla.nix -> ddns_update.py) is the only code that
|
||||||
subprocess for each stored DDNS update URL. No shell interpolation is
|
talks to Njal.la. It validates every stored URL, calls it with "&auto" so
|
||||||
performed and no user-controlled value is interpreted as shell syntax.
|
Njal.la uses the address the request came from, and records the address
|
||||||
Each URL is revalidated through ``_validate_ddns_url()`` after ``${IP}``
|
Njal.la reports back for LiveKit and the Hub (EXTERNAL_IP_FILE).
|
||||||
substitution; URLs that fail validation are silently skipped.
|
|
||||||
|
|
||||||
Called when a domain/DDNS entry is saved and when a DDNS-backed feature
|
Called when a domain/DDNS entry is saved and when a DDNS-backed feature is
|
||||||
is enabled, so DNS is refreshed right away instead of waiting for the
|
enabled, so DNS is refreshed right away instead of waiting for the
|
||||||
15-minute timer tick (see modules/core/njalla.nix).
|
15-minute timer tick.
|
||||||
"""
|
"""
|
||||||
urls = _load_ddns_urls()
|
if not _load_ddns_urls():
|
||||||
if not urls:
|
|
||||||
return
|
return
|
||||||
# Resolve current public IP (best-effort; skip if unavailable)
|
|
||||||
public_ip = ""
|
|
||||||
try:
|
try:
|
||||||
ip_result = subprocess.run(
|
|
||||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
|
||||||
capture_output=True, text=True, timeout=10, check=False,
|
|
||||||
)
|
|
||||||
raw_ip = ip_result.stdout.strip().splitlines()[0] if ip_result.stdout.strip() else ""
|
|
||||||
# Validate strictly as a proper IPv4/IPv6 address before substitution
|
|
||||||
ipaddress.ip_address(raw_ip)
|
|
||||||
public_ip = raw_ip
|
|
||||||
except Exception:
|
|
||||||
public_ip = ""
|
|
||||||
|
|
||||||
if not public_ip:
|
|
||||||
return # skip to avoid sending bare ${IP} to curl
|
|
||||||
|
|
||||||
for raw_url in urls:
|
|
||||||
try:
|
|
||||||
# Replace the placeholder with the validated IP (safe string replacement)
|
|
||||||
url = raw_url.replace("${IP}", public_ip)
|
|
||||||
# Revalidate after substitution — enforces /update/ path, no $, etc.
|
|
||||||
_validate_ddns_url(url)
|
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
["systemctl", "start", "--no-block", "sovran-ddns-update.service"],
|
||||||
timeout=20, check=False,
|
capture_output=True, timeout=10, check=False,
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -4906,18 +4938,17 @@ async def api_domains_set(req: DomainSetRequest):
|
|||||||
# Strip surrounding quotes
|
# Strip surrounding quotes
|
||||||
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
|
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
|
||||||
ddns_url = ddns_url[1:-1]
|
ddns_url = ddns_url[1:-1]
|
||||||
# Replace trailing &auto with the IP placeholder used by _run_njalla_ddns
|
# Keep Njal.la's "&auto": Njal.la then uses the address the request comes
|
||||||
if ddns_url.endswith("&auto"):
|
# from, so nothing on this machine has to look the address up. Old
|
||||||
ddns_url = ddns_url[:-5] + "&a=${IP}"
|
# "&a=${IP}" pastes and "&quiet" are normalised exactly as the runner does.
|
||||||
|
ddns_url = _normalise_ddns_url(ddns_url)
|
||||||
# Validate URL strictly — reject injection attempts before persisting.
|
# Validate URL strictly — reject injection attempts before persisting.
|
||||||
# The placeholder ${IP} is replaced temporarily so the validator sees a
|
|
||||||
# real address; the original URL (with the placeholder) is kept for storage.
|
|
||||||
try:
|
try:
|
||||||
_validate_ddns_url(ddns_url.replace("${IP}", "127.0.0.1"))
|
_validate_ddns_url(ddns_url)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}")
|
raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}")
|
||||||
# Persist the URL in the JSON store (never in executable shell source)
|
# Persist the URL in the JSON store (never in executable shell source)
|
||||||
existing_urls = _load_ddns_urls()
|
existing_urls = list(dict.fromkeys(_normalise_ddns_url(u) for u in _load_ddns_urls()))
|
||||||
if ddns_url not in existing_urls:
|
if ddns_url not in existing_urls:
|
||||||
existing_urls.append(ddns_url)
|
existing_urls.append(ddns_url)
|
||||||
try:
|
try:
|
||||||
@@ -6308,13 +6339,12 @@ async def _background_domain_reachability_checker():
|
|||||||
consecutive_failures = 0
|
consecutive_failures = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Keep the persisted external IP fresh (dynamic WAN IPs), so
|
# Pick up the address the Njal.la DDNS runner last recorded (a plain
|
||||||
# services like LiveKit can read /var/lib/secrets/external-ip.
|
# file read; nothing is looked up from here).
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
external = await loop.run_in_executor(None, _get_external_ip)
|
external = await loop.run_in_executor(None, _get_external_ip)
|
||||||
if external != "unavailable":
|
if external != "unavailable":
|
||||||
_cached_external_ip = external
|
_cached_external_ip = external
|
||||||
_save_external_ip(external)
|
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
services = cfg.get("services", [])
|
services = cfg.get("services", [])
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ function renderDomainNeedsHtml(opts) {
|
|||||||
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
+ '<a href="https://njal.la" target="_blank" rel="noopener noreferrer" style="color:var(--accent-color);">Njal.la</a>'
|
||||||
+ " and connecting your services. Just follow the steps below.</p>";
|
+ " and connecting your services. Just follow the steps below.</p>";
|
||||||
}
|
}
|
||||||
|
// Every variant above ends with a domain that points at the user's home
|
||||||
|
// connection. Say what that publishes (README: "Server + Desktop and your
|
||||||
|
// home IP address").
|
||||||
|
html += "<p>⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection, "
|
||||||
|
+ "so anyone can look up your home IP address. Domain privacy does not hide it.</p>";
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -264,7 +264,7 @@
|
|||||||
<h3>Network</h3>
|
<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">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="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>
|
</div>
|
||||||
<div class="welcome-dyn" id="wc-more"></div>
|
<div class="welcome-dyn" id="wc-more"></div>
|
||||||
@@ -476,6 +476,10 @@
|
|||||||
<li>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</li>
|
<li>To make your services available outside your home, complete one router task: forward ports <strong>80 and 443</strong> to this computer</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="support-desc">
|
||||||
|
⚠️ <strong>Heads-up:</strong> your domain points at your home internet connection,
|
||||||
|
so anyone can look up your home IP address. Domain privacy does not hide it.
|
||||||
|
</p>
|
||||||
<p class="support-desc">
|
<p class="support-desc">
|
||||||
The Hub guides you through every step.
|
The Hub guides you through every step.
|
||||||
</p>
|
</p>
|
||||||
@@ -516,7 +520,7 @@
|
|||||||
<div class="security-reset-password-box" id="security-reset-new-password"> </div>
|
<div class="security-reset-password-box" id="security-reset-new-password"> </div>
|
||||||
<p class="security-reset-password-warning">
|
<p class="security-reset-password-warning">
|
||||||
✍️ <strong>Write this down now.</strong><br />
|
✍️ <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>
|
</p>
|
||||||
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
||||||
I have written down my new password — Restart Entire System
|
I have written down my new password — Restart Entire System
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 59 KiB |
@@ -165,6 +165,14 @@
|
|||||||
programs.fish = { enable = true; promptInit = "fastfetch"; };
|
programs.fish = { enable = true; promptInit = "fastfetch"; };
|
||||||
|
|
||||||
# ── PostgreSQL base ────────────────────────────────────────
|
# ── PostgreSQL base ────────────────────────────────────────
|
||||||
|
# Shared cluster for Nextcloud (nextclouddb) + Matrix Synapse.
|
||||||
|
# Sized for the README's Server + Desktop recommendation (32 GB RAM,
|
||||||
|
# 500 GB NVMe OS + 2 TB NVMe timechain). Postgres shares the box with
|
||||||
|
# Bitcoin Core, Electrs, LND, MariaDB, PHP-FPM and GNOME, so
|
||||||
|
# shared_buffers stays below the 25%-of-RAM dedicated-server rule.
|
||||||
|
# Fixes Nextcloud 35 Database checks (pg.cache_hit_ratio,
|
||||||
|
# pg.dead_tuples). Override in custom.nix for other hosts, e.g.:
|
||||||
|
# services.postgresql.settings.shared_buffers = lib.mkForce "512MB";
|
||||||
services.postgresql = {
|
services.postgresql = {
|
||||||
enable = true;
|
enable = true;
|
||||||
authentication = lib.mkForce ''
|
authentication = lib.mkForce ''
|
||||||
@@ -172,6 +180,34 @@
|
|||||||
host all all 127.0.0.1/32 trust
|
host all all 127.0.0.1/32 trust
|
||||||
host all all ::1/128 trust
|
host all all ::1/128 trust
|
||||||
'';
|
'';
|
||||||
|
settings = {
|
||||||
|
# Memory — fixes low buffer cache hit ratio (stock default is
|
||||||
|
# 128MB shared_buffers). effective_cache_size is only a planner
|
||||||
|
# hint, not an allocation, so it can be generous.
|
||||||
|
# NOTE: changing shared_buffers requires a Postgres restart.
|
||||||
|
shared_buffers = "2GB";
|
||||||
|
effective_cache_size = "12GB";
|
||||||
|
maintenance_work_mem = "512MB";
|
||||||
|
work_mem = "32MB";
|
||||||
|
wal_buffers = "64MB";
|
||||||
|
|
||||||
|
# Checkpoints — spread write bursts out on NVMe. Reload-only.
|
||||||
|
min_wal_size = "1GB";
|
||||||
|
max_wal_size = "4GB";
|
||||||
|
checkpoint_completion_target = 0.9;
|
||||||
|
|
||||||
|
# Autovacuum — the stock 60s naptime can't keep up with
|
||||||
|
# Nextcloud's and Synapse's write-heavy tables (filecache,
|
||||||
|
# activity, jobs, state). Reload-only.
|
||||||
|
autovacuum_naptime = "30s";
|
||||||
|
autovacuum_vacuum_scale_factor = 0.05;
|
||||||
|
autovacuum_analyze_scale_factor = 0.025;
|
||||||
|
autovacuum_max_workers = 4;
|
||||||
|
|
||||||
|
# NVMe planner assumptions (README: NVMe OS + data disks).
|
||||||
|
random_page_cost = "1.1";
|
||||||
|
effective_io_concurrency = 200;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── Backups ────────────────────────────────────────────────
|
# ── Backups ────────────────────────────────────────────────
|
||||||
|
|||||||
Generated
+29
-46
@@ -5,11 +5,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788871704,
|
"lastModified": 1790862821,
|
||||||
"narHash": "sha256-Vtc2SqCB7NOO028+K5JHbpWQ3XVpLyoC75MZQ12l13o=",
|
"narHash": "sha256-IcLn2hPlxsbn2PKVlFD6gsDzkVurQ7b0KPtyORUGFcs=",
|
||||||
"owner": "emmanuelrosa",
|
"owner": "emmanuelrosa",
|
||||||
"repo": "btc-clients-nix",
|
"repo": "btc-clients-nix",
|
||||||
"rev": "e14502cba22806341f8c54c6f0c349094830c58b",
|
"rev": "99b0442dcc15198efcb62f3c1e7561a361749a6a",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -26,11 +26,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787559586,
|
"lastModified": 1788450739,
|
||||||
"narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=",
|
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
||||||
"owner": "hercules-ci",
|
"owner": "hercules-ci",
|
||||||
"repo": "flake-parts",
|
"repo": "flake-parts",
|
||||||
"rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1",
|
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -41,11 +41,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788179970,
|
"lastModified": 1790861130,
|
||||||
"narHash": "sha256-r5LmxzIhsu5+oDybatN/HJ8roYOKjb2Apa5xI6v46VU=",
|
"narHash": "sha256-cA8TrQntLbNO14wivJx7Gi2pZBhhBcMplgzIA3sk3TA=",
|
||||||
"owner": "NixOS",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "1db62ab7d2ccf1916bbf7deb61fc9d16f1c4ab49",
|
"rev": "3d23ea05a8a3be3f078845c2753af10cef80e1a5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -56,27 +56,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788921488,
|
"lastModified": 1790750587,
|
||||||
"narHash": "sha256-8+xWRxEkD6l217cIUdRxfeUGS9lQX0hVtUuNVsBaDzk=",
|
"narHash": "sha256-VfjaoJ1Uyb7JZTrBgE5Jf2nhjtQPmD9KOd19HJwmZwM=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "6aefcda9401be8acc2b74244fb3b37520ea1f0a8",
|
"rev": "78e9c786dc08cd4f3420c2395cd977206a9b1da2",
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nixos",
|
|
||||||
"ref": "nixos-26.05",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs-stable_2": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1788807765,
|
|
||||||
"narHash": "sha256-J9oC0bKnkXUrMegqRTXVkyDFJ0gn2U/Qpoo9HgGMQmA=",
|
|
||||||
"owner": "nixos",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "93108a538f079596c9a16c72cf03e9322782b6dd",
|
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -88,11 +72,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788881743,
|
"lastModified": 1790822859,
|
||||||
"narHash": "sha256-2V9GZGvPfrNzxFozhI9dcqV+c3QdA8YZrvAAzqEB+dI=",
|
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "d6524aaca2ff07876657ae2b323f24be4874944b",
|
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -104,11 +88,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_3": {
|
"nixpkgs_3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787631388,
|
"lastModified": 1789724158,
|
||||||
"narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=",
|
"narHash": "sha256-nlKgrm0dsVhOSopKheVBCcOpiIticufPPVLdVOI0euA=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "ac6b2166e7a9375683b8e98f860f273222337b16",
|
"rev": "0a3468a402c449992505b6a9fc5b06580141b750",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -120,11 +104,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_4": {
|
"nixpkgs_4": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788881743,
|
"lastModified": 1790822859,
|
||||||
"narHash": "sha256-2V9GZGvPfrNzxFozhI9dcqV+c3QdA8YZrvAAzqEB+dI=",
|
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "d6524aaca2ff07876657ae2b323f24be4874944b",
|
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -141,11 +125,11 @@
|
|||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788190018,
|
"lastModified": 1790541651,
|
||||||
"narHash": "sha256-59BAfH0txPAZrPBF4QJqwvUWppD+ICrcjA1LZAmPnrQ=",
|
"narHash": "sha256-/496IQz8qNWHHLnc3Zvr0l4uxJ34igNhJhn7ZKAefFk=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "41844750e55f17b1385d5b09ca7ade5f11f49506",
|
"rev": "5980a626794486abad69fca7667f9c11dfbc3bd7",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -165,15 +149,14 @@
|
|||||||
},
|
},
|
||||||
"sovran-bitcoin": {
|
"sovran-bitcoin": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs_4",
|
"nixpkgs": "nixpkgs_4"
|
||||||
"nixpkgs-stable": "nixpkgs-stable_2"
|
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788921077,
|
"lastModified": 1790877969,
|
||||||
"narHash": "sha256-k2i55M0lK3xzjsJKS/kOhQHSf8479r6RYEllnICxggg=",
|
"narHash": "sha256-Ia88keP9t3B6ECVw+LP4xasPdvzw1TIWya1K+3dySu0=",
|
||||||
"owner": "naturallaw777",
|
"owner": "naturallaw777",
|
||||||
"repo": "Sovran_Bitcoin",
|
"repo": "Sovran_Bitcoin",
|
||||||
"rev": "7c4d5b509c0533ea48192146f987182668bf42dc",
|
"rev": "b0da63bd81f1a1b1a970068b3061c6c8389db9aa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
nixvim.url = "github:nix-community/nixvim";
|
nixvim.url = "github:nix-community/nixvim";
|
||||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
|
|
||||||
# Bitcoin / Lightning stack — standalone flake, consumed as a module.
|
|
||||||
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
|
sovran-bitcoin.url = "github:naturallaw777/Sovran_Bitcoin";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,24 +17,6 @@
|
|||||||
system = prev.stdenv.hostPlatform.system;
|
system = prev.stdenv.hostPlatform.system;
|
||||||
config.allowUnfree = true;
|
config.allowUnfree = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
# Pin LiveKit to 1.13.6: element-calling.nix sets
|
|
||||||
# rtc.advertise_internal_ip, which gives LAN callers a host candidate
|
|
||||||
# so calls work on Wi-Fi without the router needing NAT-hairpin. That
|
|
||||||
# flag is only honoured when node_ip is set manually from LiveKit
|
|
||||||
# v1.13.6 (mediatransportutil f234b53); nixpkgs-unstable currently
|
|
||||||
# ships 1.13.5. Remove this override once nixpkgs-unstable reaches
|
|
||||||
# >= 1.13.6.
|
|
||||||
livekit = prev.livekit.overrideAttrs (old: {
|
|
||||||
version = "1.13.6";
|
|
||||||
src = prev.fetchFromGitHub {
|
|
||||||
owner = "livekit";
|
|
||||||
repo = "livekit";
|
|
||||||
rev = "v1.13.6";
|
|
||||||
hash = "sha256-sUAx6ooeEUUqot5xuZv7xiQa3DdRFVULteTwYgFUzCI=";
|
|
||||||
};
|
|
||||||
vendorHash = "sha256-nOGSmoNuQQm/sIVI1HojsiS4GkbhA68uYMQ6X7d4a5Q=";
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
@@ -77,14 +57,5 @@
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
checks.x86_64-linux = let
|
|
||||||
pkgs = import nixpkgs {
|
|
||||||
system = "x86_64-linux";
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
# Bitcoin hardening and package checks now live in the Sovran_Bitcoin flake.
|
|
||||||
# Run them with: nix build github:naturallaw777/Sovran_Bitcoin#checks.x86_64-linux
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-4
@@ -54,9 +54,14 @@ DICEWARE_WORDS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
def generate_diceware_password():
|
def generate_diceware_password():
|
||||||
words = [secrets.choice(DICEWARE_WORDS) for _ in range(3)]
|
# 4 words from a 96 word list plus 2 digits: 96^4 x 100 = ~8.5e9, about
|
||||||
digit = secrets.randbelow(10)
|
# 33 bits. The old 3 words plus 1 digit was 96^3 x 10 = ~8.8e6, about 23
|
||||||
return "-".join(words) + f"-{digit}"
|
# 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:
|
try:
|
||||||
logfile = open(LOG, "a")
|
logfile = open(LOG, "a")
|
||||||
@@ -471,7 +476,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
|||||||
# Role cards
|
# Role cards
|
||||||
roles = [
|
roles = [
|
||||||
("Server + Desktop",
|
("Server + Desktop",
|
||||||
"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.",
|
"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"),
|
"Server+Desktop"),
|
||||||
("Desktop Only",
|
("Desktop Only",
|
||||||
"A beautiful, easy-to-use desktop without the background server applications.",
|
"A beautiful, easy-to-use desktop without the background server applications.",
|
||||||
|
|||||||
+50
-35
@@ -14,13 +14,54 @@ let
|
|||||||
|| config.sovran_systemsOS.features.haven
|
|| config.sovran_systemsOS.features.haven
|
||||||
|| config.sovran_systemsOS.features."nwc-wallets"
|
|| config.sovran_systemsOS.features."nwc-wallets"
|
||||||
|| config.sovran_systemsOS.features.element-calling;
|
|| 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
|
in
|
||||||
{
|
{
|
||||||
services.caddy = {
|
services.caddy = {
|
||||||
# Only enable Caddy when at least one domain-based service needs it or
|
# Caddy runs when a domain-based service needs it, when the operator has
|
||||||
# the operator has defined custom vhosts. This prevents Caddy from
|
# defined custom vhosts, or when it is the way to reach RTL and Mempool.
|
||||||
# running on Desktop Only installs that have no web services configured.
|
# Desktop Only has none of those, so Caddy stays off there.
|
||||||
enable = needsHttpsPorts || extraVhosts != "";
|
enable = caddyEnabled;
|
||||||
user = "caddy";
|
user = "caddy";
|
||||||
group = "root";
|
group = "root";
|
||||||
};
|
};
|
||||||
@@ -202,37 +243,11 @@ $LIGHTNING {
|
|||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Sovran Hub (LAN access via mDNS) ────────────
|
# ── RTL and Mempool (local network) ─────────────
|
||||||
cat >> /run/caddy/Caddyfile <<EOF
|
# Only where those services run; see bitcoinUiSites above.
|
||||||
|
cat >> /run/caddy/Caddyfile <<'LAN_SITES_EOF'
|
||||||
http://sovransystemsos.local {
|
${bitcoinUiSites}
|
||||||
reverse_proxy localhost:8937
|
LAN_SITES_EOF
|
||||||
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 {
|
|
||||||
reverse_proxy :3050
|
|
||||||
encode gzip zstd
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# ── Mempool (LAN access) ────────────────────────
|
|
||||||
cat >> /run/caddy/Caddyfile <<EOF
|
|
||||||
|
|
||||||
:60847 {
|
|
||||||
reverse_proxy :60845
|
|
||||||
encode gzip zstd
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# ── Custom vhosts from custom.nix ──────────────
|
# ── Custom vhosts from custom.nix ──────────────
|
||||||
cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF'
|
cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF'
|
||||||
|
|||||||
+33
-89
@@ -1,43 +1,64 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
{
|
{
|
||||||
|
# The public-IP detector (STUN / OpenDNS / HTTPS echo) is gone: the public
|
||||||
|
# address is whatever Njal.la reports back for the DDNS update below, and
|
||||||
|
# nothing else on the system looks it up. Fail with a pointer, instead of
|
||||||
|
# silently ignoring them, if a custom.nix still sets one of its old options.
|
||||||
|
imports = map (opt:
|
||||||
|
lib.mkRemovedOptionModule [ "sovran_systemsOS" "publicIP" opt ]
|
||||||
|
"Sovran no longer looks up the public IP: the Njal.la DDNS update reports it (modules/core/njalla.nix). To force an address for Element Calling, set sovran_systemsOS.elementCalling.externalIP."
|
||||||
|
) [ "stunServer" "stunPort" "dnsResolver" "httpsEcho" "cacheTTL" ];
|
||||||
|
|
||||||
# ── Ensure njalla directory exists on every build ────────────────────────
|
# ── Ensure njalla directory exists on every build ────────────────────────
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /var/lib/njalla 0750 root root -"
|
"d /var/lib/njalla 0750 root root -"
|
||||||
];
|
];
|
||||||
|
|
||||||
# ── Install the shared validation helper so the DDNS runner can import it ─
|
# ── Install the DDNS runner and the validator it shares with the Hub ─────
|
||||||
# The exact same _validate_ddns_url() function used by the Hub web application
|
# Both files come straight from the Hub's source tree and are installed side
|
||||||
# is installed here as a read-only system file. The DDNS runner imports it
|
# by side as read-only system files. The runner imports the exact same
|
||||||
# directly so the two code paths share one validator — no weaker inline copy.
|
# _validate_ddns_url() the Hub API uses — no weaker inline copy.
|
||||||
environment.etc."sovran/security_helpers.py" = {
|
environment.etc."sovran/security_helpers.py" = {
|
||||||
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
||||||
mode = "0444";
|
mode = "0444";
|
||||||
user = "root";
|
user = "root";
|
||||||
group = "root";
|
group = "root";
|
||||||
};
|
};
|
||||||
|
environment.etc."sovran/ddns-update.py" = {
|
||||||
|
source = ../../app/sovran_systemsos_web/ddns_update.py;
|
||||||
|
mode = "0444";
|
||||||
|
user = "root";
|
||||||
|
group = "root";
|
||||||
|
};
|
||||||
|
|
||||||
# ── Safe DDNS update service ─────────────────────────────────────────────
|
# ── Safe DDNS update service ─────────────────────────────────────────────
|
||||||
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||||
# invokes curl directly — no shell interpolation, no script execution.
|
# invokes curl directly — no shell interpolation, no script execution.
|
||||||
# Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh.
|
# Njal.la is asked to use the address the request came from ("&auto") and
|
||||||
|
# reports it back; the runner saves it to /var/lib/secrets/external-ip,
|
||||||
|
# where LiveKit and the Hub read it. See app/sovran_systemsos_web/ddns_update.py.
|
||||||
systemd.services.sovran-ddns-update = {
|
systemd.services.sovran-ddns-update = {
|
||||||
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" ];
|
||||||
after = [ "network-online.target" ];
|
after = [ "network-online.target" ];
|
||||||
|
# curl is not in a NixOS unit's default PATH (coreutils, findutils, grep,
|
||||||
|
# sed, systemd): without this the runner cannot start it.
|
||||||
|
path = [ pkgs.curl ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
User = "root";
|
User = "root";
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
|
ExecStart = "${pkgs.python3}/bin/python3 /etc/sovran/ddns-update.py";
|
||||||
# Harden the service — it only needs network access and read access to
|
# Harden the service — it needs network access, the URL store, and the
|
||||||
# /var/lib/njalla/ddns_urls.json.
|
# file that receives the reported address.
|
||||||
NoNewPrivileges = true;
|
NoNewPrivileges = true;
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
||||||
ReadOnlyPaths = [ "/etc/sovran" ];
|
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||||
ProtectHome = true;
|
ProtectHome = true;
|
||||||
PrivateTmp = true;
|
PrivateTmp = true;
|
||||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
# AF_UNIX: name lookups can go through nscd / systemd-resolved sockets.
|
||||||
|
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,86 +73,9 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Install the Python runner script at build time so the service can find it.
|
# The runner used to be written to /var/lib/sovran by this activation script,
|
||||||
# The script is owned by root and not world-writable.
|
# next to the old public-ip.py detector. Remove those stale copies.
|
||||||
# Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same
|
|
||||||
# production validator used by the Hub API — before executing any curl call.
|
|
||||||
# No shell is used; no redirects; no script execution.
|
|
||||||
# ${IP} placeholder is preserved in stored URLs and substituted at runtime;
|
|
||||||
# the URL is validated after substitution so any remaining $ is rejected.
|
|
||||||
system.activationScripts.sovran-ddns-update-script = ''
|
system.activationScripts.sovran-ddns-update-script = ''
|
||||||
install -d -m 0755 /var/lib/sovran
|
rm -f /var/lib/sovran/ddns-update.py /var/lib/sovran/public-ip.py
|
||||||
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Sovran safe DDNS update runner.
|
|
||||||
|
|
||||||
Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder,
|
|
||||||
validates each URL using the production _validate_ddns_url() from
|
|
||||||
/etc/sovran/security_helpers.py, then calls curl per URL.
|
|
||||||
No shell interpolation. No redirects. No script execution.
|
|
||||||
"""
|
|
||||||
import ipaddress, json, os, subprocess, sys
|
|
||||||
|
|
||||||
sys.path.insert(0, '/etc/sovran')
|
|
||||||
try:
|
|
||||||
from security_helpers import _validate_ddns_url
|
|
||||||
except ImportError:
|
|
||||||
sys.exit(1) # validator missing — fail so systemd logs the misconfiguration
|
|
||||||
|
|
||||||
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(URLS_FILE) as f:
|
|
||||||
urls = json.load(f)
|
|
||||||
if not isinstance(urls, list):
|
|
||||||
raise ValueError("not a list")
|
|
||||||
except Exception:
|
|
||||||
sys.exit(0) # no URLs configured — nothing to do
|
|
||||||
|
|
||||||
# Resolve current public IP via the shared detector — one script, one cache
|
|
||||||
# (STUN -> DNS -> opt-in HTTPS echo; see /var/lib/sovran/public-ip.py).
|
|
||||||
# The detector refreshes /var/lib/secrets/external-ip, which the Hub and
|
|
||||||
# LiveKit read as well, so the whole system shares a single detected value.
|
|
||||||
public_ip = ""
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
[sys.executable, "/var/lib/sovran/public-ip.py", "check"],
|
|
||||||
capture_output=True, text=True, timeout=20,
|
|
||||||
)
|
|
||||||
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
|
|
||||||
ipaddress.ip_address(raw) # validates — raises if not a real IP
|
|
||||||
public_ip = raw
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not public_ip:
|
|
||||||
# Last resort: the shared cache file, if the detector is unavailable.
|
|
||||||
try:
|
|
||||||
with open("/var/lib/secrets/external-ip") as f:
|
|
||||||
raw = f.read().strip()
|
|
||||||
ipaddress.ip_address(raw)
|
|
||||||
public_ip = raw
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not public_ip:
|
|
||||||
sys.exit(0) # no IP resolved — skip to avoid sending bare ''${IP}
|
|
||||||
|
|
||||||
for raw_url in urls:
|
|
||||||
try:
|
|
||||||
# Substitute ''${IP} placeholder then validate through production validator.
|
|
||||||
# After substitution there must be no $ left; _validate_ddns_url rejects
|
|
||||||
# any remaining $ expression.
|
|
||||||
url = raw_url.replace("''${IP}", public_ip)
|
|
||||||
_validate_ddns_url(url)
|
|
||||||
subprocess.run(
|
|
||||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
|
||||||
timeout=20, check=False,
|
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
PYEOF
|
|
||||||
chmod 0500 /var/lib/sovran/ddns-update.py
|
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
# ── Unified public-IP detection (privacy-first) ─────────────────────────────
|
|
||||||
#
|
|
||||||
# One script, one cache file, every consumer on the system reads the same
|
|
||||||
# value. Previously the public IP was detected independently in three places,
|
|
||||||
# each phoning home to a different third party:
|
|
||||||
# * the Hub (server.py _get_external_ip) → api.ipify.org / ifconfig.me /
|
|
||||||
# icanhazip.com over HTTPS on every /api/network call and every
|
|
||||||
# background-loop tick
|
|
||||||
# * DDNS (ddns-update.py) → myip.opendns.com via OpenDNS
|
|
||||||
# * LiveKit → STUN (its own embedded detection)
|
|
||||||
#
|
|
||||||
# This module replaces all of that with a single script
|
|
||||||
# (/var/lib/sovran/public-ip.py) that detects the IP once per TTL using the
|
|
||||||
# least-exposing mechanism available, and caches it in
|
|
||||||
# /var/lib/secrets/external-ip. Consumers (Hub, DDNS, LiveKit) read the cache
|
|
||||||
# and only invoke the script when it is missing or stale.
|
|
||||||
#
|
|
||||||
# Detection chain (first success wins, stops immediately):
|
|
||||||
# 1. pin — sovran_systemsOS.elementCalling.externalIP (baked in)
|
|
||||||
# 2. cache — /var/lib/secrets/external-ip if newer than cacheTTL
|
|
||||||
# 3. STUN — UDP binding request (one packet, no application data,
|
|
||||||
# no HTTP metadata; the same protocol every WebRTC client
|
|
||||||
# uses). Server configurable via publicIP.stunServer.
|
|
||||||
# 4. DNS — "myip.opendns.com" A query via publicIP.dnsResolver
|
|
||||||
# (single DNS query, no HTTP headers)
|
|
||||||
# 5. HTTPS echo — ONLY endpoints listed in publicIP.httpsEcho (empty by
|
|
||||||
# default → never contacted)
|
|
||||||
#
|
|
||||||
# Privacy property: while the cache is fresh, zero third parties are
|
|
||||||
# contacted. When detection runs, at most ONE party learns the IP per
|
|
||||||
# refresh interval (default 5 minutes), and the STUN/DNS mechanisms expose
|
|
||||||
# nothing beyond the bare address.
|
|
||||||
{
|
|
||||||
config,
|
|
||||||
pkgs,
|
|
||||||
lib,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
|
|
||||||
let
|
|
||||||
stunServer = config.sovran_systemsOS.publicIP.stunServer;
|
|
||||||
stunPort = config.sovran_systemsOS.publicIP.stunPort;
|
|
||||||
dnsResolver = config.sovran_systemsOS.publicIP.dnsResolver;
|
|
||||||
httpsEcho = config.sovran_systemsOS.publicIP.httpsEcho;
|
|
||||||
cacheTTL = config.sovran_systemsOS.publicIP.cacheTTL;
|
|
||||||
|
|
||||||
# Optional pin shared with element-calling (baked in at build time).
|
|
||||||
pin = if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else "";
|
|
||||||
|
|
||||||
echoList = lib.concatStringsSep "," (map (u: "'${u}'") httpsEcho);
|
|
||||||
in
|
|
||||||
{
|
|
||||||
options.sovran_systemsOS.publicIP = {
|
|
||||||
stunServer = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "stun.l.google.com";
|
|
||||||
description = ''
|
|
||||||
STUN server used to discover the public IP over UDP. STUN is the most
|
|
||||||
privacy-preserving detection mechanism: a single stateless packet,
|
|
||||||
no HTTP metadata. Only used when the cache is stale.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
stunPort = lib.mkOption {
|
|
||||||
type = lib.types.port;
|
|
||||||
default = 19302;
|
|
||||||
};
|
|
||||||
dnsResolver = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "resolver4.opendns.com";
|
|
||||||
description = ''
|
|
||||||
DNS resolver used as fallback (myip.opendns.com trick) when STUN is
|
|
||||||
unavailable (e.g. ISP blocks UDP egress). A single DNS query, no
|
|
||||||
HTTP headers.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
httpsEcho = lib.mkOption {
|
|
||||||
type = lib.types.listOf lib.types.str;
|
|
||||||
default = [ ];
|
|
||||||
example = [ "https://api.ipify.org" ];
|
|
||||||
description = ''
|
|
||||||
OPT-IN HTTPS endpoints that return the caller's public IP as a bare
|
|
||||||
IPv4 literal. Each listed endpoint observes this server's public IP
|
|
||||||
and HTTP metadata every time detection runs. Empty by default — no
|
|
||||||
HTTPS echo service is ever contacted unless you add one here. This is
|
|
||||||
the last-resort fallback after STUN and DNS.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
cacheTTL = lib.mkOption {
|
|
||||||
type = lib.types.int;
|
|
||||||
default = 300;
|
|
||||||
description = "Seconds the detected public IP is cached before re-detection.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# ── Install the unified detector ──────────────────────────────────────────
|
|
||||||
# This module declares `options` above, so ALL configuration must go under
|
|
||||||
# the `config` attribute: NixOS forbids mixing bare top-level settings
|
|
||||||
# (like `system.*`) with the `options`/`config` keyword attributes in the
|
|
||||||
# same module. (Fixes: "Module ... has an unsupported attribute `system'".)
|
|
||||||
config.system.activationScripts.sovranPublicIpInstall = lib.stringAfter [ "users" ] ''
|
|
||||||
install -d -m 0755 /var/lib/sovran
|
|
||||||
cat > /var/lib/sovran/public-ip.py <<'PYEOF'
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""sovran-public-ip — one detector, one cache, every consumer reads the same IP.
|
|
||||||
|
|
||||||
Privacy-first detection chain (first success wins):
|
|
||||||
1. pin — baked in from sovran_systemsOS.elementCalling.externalIP
|
|
||||||
2. cache — /var/lib/secrets/external-ip if newer than CACHE_TTL seconds
|
|
||||||
3. STUN — UDP binding request (one packet, no application data)
|
|
||||||
4. DNS — myip.opendns.com A query via the configured resolver
|
|
||||||
5. HTTPS — ONLY endpoints baked in from publicIP.httpsEcho (opt-in)
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
public-ip.py check print current public IP (cache first; refresh if stale)
|
|
||||||
public-ip.py refresh force re-detection, update the cache file, print IP
|
|
||||||
|
|
||||||
Exit status: 0 with the IP on stdout on success; 1 if no IP is available
|
|
||||||
(cached value, if any, is still printed to stdout with a warning on stderr).
|
|
||||||
"""
|
|
||||||
import ipaddress
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
CACHE_FILE = "/var/lib/secrets/external-ip"
|
|
||||||
PIN = "${pin}"
|
|
||||||
STUN_SERVER = "${stunServer}"
|
|
||||||
STUN_PORT = ${toString stunPort}
|
|
||||||
DNS_RESOLVER = "${dnsResolver}"
|
|
||||||
DNS_HOST = "myip.opendns.com"
|
|
||||||
ECHO_URLS = [ ${echoList} ]
|
|
||||||
CACHE_TTL = ${toString cacheTTL}
|
|
||||||
TIMEOUT = 3.0
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Detection primitives
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def is_usable_ip(text: str) -> bool:
|
|
||||||
"""True if text is a globally routable IPv4 that LiveKit may advertise."""
|
|
||||||
try:
|
|
||||||
ip = ipaddress.ip_address(text)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
if ip.version != 4:
|
|
||||||
return False
|
|
||||||
if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast
|
|
||||||
or ip.is_reserved or ip.is_unspecified or not ip.is_global):
|
|
||||||
return False
|
|
||||||
# RFC 6598 shared (CGNAT) space — not reachable from the internet.
|
|
||||||
if ip in ipaddress.ip_network("100.64.0.0/10"):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def stun_public_ip() -> str | None:
|
|
||||||
"""RFC 5389 Binding request over UDP; returns the mapped (public) IPv4."""
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
sock.settimeout(TIMEOUT)
|
|
||||||
try:
|
|
||||||
txid = random.randbytes(12)
|
|
||||||
req = struct.pack("!HHI", 0x0001, 0, 0) + txid # Binding request
|
|
||||||
sock.sendto(req, (STUN_SERVER, STUN_PORT))
|
|
||||||
data, _ = sock.recvfrom(2048)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
if len(data) < 20:
|
|
||||||
return None
|
|
||||||
mtype, _mlen = struct.unpack("!HH", data[:4])
|
|
||||||
if mtype != 0x0101: # Binding success response
|
|
||||||
return None
|
|
||||||
|
|
||||||
cookie = data[4:8]
|
|
||||||
i = 20
|
|
||||||
while i + 4 <= len(data):
|
|
||||||
atype, alen = struct.unpack("!HH", data[i : i + 4])
|
|
||||||
aval = data[i + 4 : i + 4 + alen]
|
|
||||||
if atype in (0x0001, 0x0020) and len(aval) >= 8: # MAPPED / XOR-MAPPED
|
|
||||||
family = aval[1]
|
|
||||||
if family == 0x01: # IPv4
|
|
||||||
raw = aval[4:8]
|
|
||||||
if atype == 0x0020: # XOR with magic cookie + txid prefix
|
|
||||||
raw = bytes(b ^ c for b, c in zip(raw, cookie + txid[:4]))
|
|
||||||
return socket.inet_ntop(socket.AF_INET, raw)
|
|
||||||
i += 4 + ((alen + 3) // 4) * 4
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def dns_public_ip() -> str | None:
|
|
||||||
"""Minimal DNS A query for myip.opendns.com against the given resolver."""
|
|
||||||
qid = random.randint(0, 0xFFFF)
|
|
||||||
qname = b"".join(bytes([len(p)]) + p.encode() for p in DNS_HOST.split(".")) + b"\x00"
|
|
||||||
query = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0) + qname + struct.pack("!HH", 1, 1)
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
sock.settimeout(TIMEOUT)
|
|
||||||
try:
|
|
||||||
sock.sendto(query, (DNS_RESOLVER, 53))
|
|
||||||
data, _ = sock.recvfrom(4096)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if len(data) < 12:
|
|
||||||
return None
|
|
||||||
rid, _flags, _qd, an, _ns, _ar = struct.unpack("!HHHHHH", data[:12])
|
|
||||||
if rid != qid or an == 0:
|
|
||||||
return None
|
|
||||||
i = 12
|
|
||||||
for _ in range(_qd): # skip question
|
|
||||||
while data[i] != 0:
|
|
||||||
i += 1 + data[i]
|
|
||||||
i += 5
|
|
||||||
for _ in range(an):
|
|
||||||
if data[i] & 0xC0 == 0xC0:
|
|
||||||
i += 2
|
|
||||||
else:
|
|
||||||
while data[i] != 0:
|
|
||||||
i += 1 + data[i]
|
|
||||||
i += 1
|
|
||||||
rtype, _rclass, _ttl, rdlen = struct.unpack("!HHIH", data[i : i + 10])
|
|
||||||
i += 10
|
|
||||||
if rtype == 1 and rdlen == 4:
|
|
||||||
return socket.inet_ntop(socket.AF_INET, data[i : i + 4])
|
|
||||||
i += rdlen
|
|
||||||
except (IndexError, struct.error):
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def echo_public_ip() -> str | None:
|
|
||||||
"""Opt-in HTTPS echo endpoints (baked in at build time; empty by default)."""
|
|
||||||
for url in ECHO_URLS:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "sovran-public-ip"})
|
|
||||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
|
|
||||||
text = resp.read().decode().strip()
|
|
||||||
if is_usable_ip(text):
|
|
||||||
return text
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cache handling
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def read_cache() -> str:
|
|
||||||
try:
|
|
||||||
with open(CACHE_FILE) as f:
|
|
||||||
return f.read().strip()
|
|
||||||
except OSError:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def write_cache(ip: str) -> None:
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True)
|
|
||||||
tmp = f"{CACHE_FILE}.tmp"
|
|
||||||
with open(tmp, "w") as f:
|
|
||||||
f.write(ip + "\n")
|
|
||||||
os.replace(tmp, CACHE_FILE)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def cache_fresh() -> bool:
|
|
||||||
try:
|
|
||||||
return time.time() - os.path.getmtime(CACHE_FILE) < CACHE_TTL
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def detect() -> str:
|
|
||||||
"""Run the chain; returns usable IP or an empty string."""
|
|
||||||
if PIN and is_usable_ip(PIN):
|
|
||||||
return PIN
|
|
||||||
for fn in (stun_public_ip, dns_public_ip, echo_public_ip):
|
|
||||||
try:
|
|
||||||
cand = fn()
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if cand and is_usable_ip(cand):
|
|
||||||
return cand
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
force = len(sys.argv) > 1 and sys.argv[1] == "refresh"
|
|
||||||
ip = ""
|
|
||||||
if not force and cache_fresh():
|
|
||||||
ip = read_cache()
|
|
||||||
if not ip:
|
|
||||||
ip = detect()
|
|
||||||
if ip:
|
|
||||||
write_cache(ip)
|
|
||||||
else:
|
|
||||||
stale = read_cache()
|
|
||||||
if stale:
|
|
||||||
print(stale)
|
|
||||||
print("WARNING: detection failed; using last known public IP", file=sys.stderr)
|
|
||||||
return 0
|
|
||||||
print("ERROR: could not determine a public IP (STUN/DNS unreachable)", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
print(ip)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
PYEOF
|
|
||||||
chmod 0555 /var/lib/sovran/public-ip.py
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
+66
-3
@@ -61,6 +61,67 @@
|
|||||||
sshd = lib.mkEnableOption "SSH remote access";
|
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 exposure (controls Caddy vhosts) ──────────────────
|
||||||
web = {
|
web = {
|
||||||
btcpayserver = lib.mkOption {
|
btcpayserver = lib.mkOption {
|
||||||
@@ -107,9 +168,11 @@
|
|||||||
description = ''
|
description = ''
|
||||||
Optional pin: force LiveKit to advertise this public IPv4 in its
|
Optional pin: force LiveKit to advertise this public IPv4 in its
|
||||||
host/TURN ICE candidates. Not required in normal operation — the
|
host/TURN ICE candidates. Not required in normal operation — the
|
||||||
module auto-detects the public IP at runtime (HTTPS egress
|
address is the one Njal.la reports for the DDNS update (set up in
|
||||||
detection, falling back to STUN). Set it only to override a
|
the Hub's Domains page), and nothing on this system looks it up
|
||||||
mis-detected address (e.g. multi-WAN/VPN setups).
|
anywhere else. Set it for a fixed public address with no Njal.la
|
||||||
|
DDNS entry, or to override the reported one (e.g. multi-WAN/VPN
|
||||||
|
setups).
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -115,6 +115,15 @@ let
|
|||||||
else if cfg.roles.node then "node"
|
else if cfg.roles.node then "node"
|
||||||
else "server_plus_desktop";
|
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"
|
generatedConfig = pkgs.writeText "sovran-hub-config.json"
|
||||||
(builtins.toJSON {
|
(builtins.toJSON {
|
||||||
refresh_interval = 5;
|
refresh_interval = 5;
|
||||||
@@ -122,6 +131,9 @@ let
|
|||||||
role = activeRole;
|
role = activeRole;
|
||||||
services = monitoredServices;
|
services = monitoredServices;
|
||||||
feature_manager = true;
|
feature_manager = true;
|
||||||
|
# Read by LanOnlyMiddleware in server.py.
|
||||||
|
lan_only = cfg.hub.lanOnly;
|
||||||
|
lan_extra_networks = cfg.hub.extraLanNetworks;
|
||||||
feature_states = {
|
feature_states = {
|
||||||
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
|
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
|
import uvicorn
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"sovran_systemsos_web.server:app",
|
"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",
|
host="0.0.0.0",
|
||||||
port=8937,
|
port=8937,
|
||||||
log_level="info",
|
log_level="info",
|
||||||
@@ -503,6 +521,22 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
config = {
|
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 = {
|
systemd.services.sovran-hub-web = {
|
||||||
description = "Sovran_SystemsOS Hub Web Interface";
|
description = "Sovran_SystemsOS Hub Web Interface";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
@@ -571,7 +605,14 @@ in
|
|||||||
|
|
||||||
environment.systemPackages = [ sovran-hub-web ];
|
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 ───────────────────────
|
# ── Auto-launch Hub in browser on login ───────────────────────
|
||||||
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
|
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
|
|
||||||
services.openssh = {
|
services.openssh = {
|
||||||
enable = true;
|
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 [
|
listenAddresses = lib.mkDefault [
|
||||||
{ addr = "127.0.0.1"; port = 22; }
|
{ addr = "127.0.0.1"; port = 22; }
|
||||||
];
|
];
|
||||||
|
|||||||
+11
-8
@@ -91,7 +91,7 @@ in
|
|||||||
SECRET_FILE="/var/lib/secrets/root-password"
|
SECRET_FILE="/var/lib/secrets/root-password"
|
||||||
if [ ! -f "$SECRET_FILE" ]; then
|
if [ ! -f "$SECRET_FILE" ]; then
|
||||||
mkdir -p /var/lib/secrets
|
mkdir -p /var/lib/secrets
|
||||||
# Generate a diceware-style passphrase: word-word-word-N
|
# Generate a diceware-style passphrase: word-word-word-word-NN
|
||||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||||
fern field flame flora flint frost grove haven hedge holly heron \
|
fern field flame flora flint frost grove haven hedge holly heron \
|
||||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||||
@@ -106,8 +106,9 @@ in
|
|||||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
DIGIT=$((RANDOM % 10))
|
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
ROOT_PASS="$W1-$W2-$W3-$DIGIT"
|
DIGIT=$(printf '%02d' $((RANDOM % 100)))
|
||||||
|
ROOT_PASS="$W1-$W2-$W3-$W4-$DIGIT"
|
||||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||||
chmod 600 "$SECRET_FILE"
|
chmod 600 "$SECRET_FILE"
|
||||||
fi
|
fi
|
||||||
@@ -170,7 +171,7 @@ in
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p /var/lib/secrets
|
mkdir -p /var/lib/secrets
|
||||||
# Generate a diceware-style passphrase: word-word-word-N
|
# Generate a diceware-style passphrase: word-word-word-word-NN
|
||||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||||
fern field flame flora flint frost grove haven hedge holly heron \
|
fern field flame flora flint frost grove haven hedge holly heron \
|
||||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||||
@@ -185,8 +186,9 @@ in
|
|||||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
DIGIT=$((RANDOM % 10))
|
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
DIGIT=$(printf '%02d' $((RANDOM % 100)))
|
||||||
|
FREE_PASS="$W1-$W2-$W3-$W4-$DIGIT"
|
||||||
echo "$FREE_PASS" > "$SECRET_FILE"
|
echo "$FREE_PASS" > "$SECRET_FILE"
|
||||||
chmod 600 "$SECRET_FILE"
|
chmod 600 "$SECRET_FILE"
|
||||||
echo "free:$FREE_PASS" | chpasswd
|
echo "free:$FREE_PASS" | chpasswd
|
||||||
@@ -229,8 +231,9 @@ in
|
|||||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
DIGIT=$((RANDOM % 10))
|
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
DIGIT=$(printf '%02d' $((RANDOM % 100)))
|
||||||
|
FREE_PASS="$W1-$W2-$W3-$W4-$DIGIT"
|
||||||
|
|
||||||
printf '%s\n' "$FREE_PASS" > "$SECRET_FILE"
|
printf '%s\n' "$FREE_PASS" > "$SECRET_FILE"
|
||||||
chmod 600 "$SECRET_FILE"
|
chmod 600 "$SECRET_FILE"
|
||||||
|
|||||||
+41
-37
@@ -204,34 +204,36 @@ EOF
|
|||||||
# NAT with port-forwarding. It does not need to be assigned to this box,
|
# NAT with port-forwarding. It does not need to be assigned to this box,
|
||||||
# and it may be dynamic.
|
# and it may be dynamic.
|
||||||
#
|
#
|
||||||
# Reuse the shared detector (/var/lib/sovran/public-ip.py — see
|
# Nothing here looks the address up. Priority:
|
||||||
# modules/core/public-ip.nix) instead of running our own: one script,
|
|
||||||
# one cache, privacy-first (STUN -> DNS -> opt-in HTTPS echo). Priority:
|
|
||||||
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
||||||
# 2. /var/lib/secrets/external-ip (the shared cache)
|
# 2. /var/lib/secrets/external-ip — the address Njal.la reported for the
|
||||||
# 3. run the detector now (it refreshes the cache)
|
# last DDNS update (modules/core/njalla.nix). The runner rewrites that
|
||||||
# 4. STUN auto-detection (use_external_ip) as the fallback, with a
|
# file only when the address changes, and livekit-external-ip.path
|
||||||
# warning — this is where broken installs used to silently end up
|
# then re-runs this script.
|
||||||
# advertising a private IP, causing "call connects but no video".
|
# With neither, or with an address that is not public, this unit fails with
|
||||||
|
# a clear message instead of guessing: advertising a wrong or private
|
||||||
|
# address is what produces "call connects but no video".
|
||||||
EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}'
|
EXTERNAL_IP='${if config.sovran_systemsOS.elementCalling.externalIP != null then config.sovran_systemsOS.elementCalling.externalIP else ""}'
|
||||||
|
|
||||||
PUBLIC_IP="$EXTERNAL_IP"
|
PUBLIC_IP="$EXTERNAL_IP"
|
||||||
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
||||||
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
||||||
fi
|
fi
|
||||||
if [ -z "$PUBLIC_IP" ] && [ -x /var/lib/sovran/public-ip.py ]; then
|
|
||||||
PUBLIC_IP=$(python3 /var/lib/sovran/public-ip.py check 2>/dev/null | head -n1)
|
if [ -z "$PUBLIC_IP" ]; then
|
||||||
|
echo "ERROR: no public IP is known for LiveKit yet." >&2
|
||||||
|
echo "ERROR: It is recorded after the first successful Njal.la DDNS update (Hub, Domains)." >&2
|
||||||
|
echo "ERROR: To use a fixed address instead, set sovran_systemsOS.elementCalling.externalIP." >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
||||||
# A detected/pinned address like this must never be advertised.
|
if printf '%s' "$PUBLIC_IP" | grep -qE \
|
||||||
if [ -n "$PUBLIC_IP" ] && printf '%s' "$PUBLIC_IP" | grep -qE \
|
'^(0\.|127\.|10\.|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
|
||||||
'^(0\.|127\.|10\.|100\.64\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
|
echo "ERROR: $PUBLIC_IP is not a public address, so remote peers cannot reach LiveKit there." >&2
|
||||||
echo "WARNING: external IP '$PUBLIC_IP' is not routable; falling back to STUN auto-detection." >&2
|
exit 1
|
||||||
PUBLIC_IP=""
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$PUBLIC_IP" ]; then
|
|
||||||
cat > /run/livekit/livekit.yaml <<EOF
|
cat > /run/livekit/livekit.yaml <<EOF
|
||||||
port: 7880
|
port: 7880
|
||||||
rtc:
|
rtc:
|
||||||
@@ -245,21 +247,6 @@ rtc:
|
|||||||
- $IFACE
|
- $IFACE
|
||||||
EOF
|
EOF
|
||||||
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
||||||
else
|
|
||||||
cat > /run/livekit/livekit.yaml <<EOF
|
|
||||||
port: 7880
|
|
||||||
rtc:
|
|
||||||
use_external_ip: true
|
|
||||||
skip_external_ip_validation: true
|
|
||||||
advertise_internal_ip: true
|
|
||||||
tcp_port: 7881
|
|
||||||
udp_port: 7882
|
|
||||||
interfaces:
|
|
||||||
includes:
|
|
||||||
- $IFACE
|
|
||||||
EOF
|
|
||||||
echo "WARNING: could not determine a public IP for LiveKit; using STUN auto-detection. If calls connect without media, check STUN egress or set sovran_systemsOS.elementCalling.externalIP." >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
||||||
# signature against the same key file it issues tokens with, and uses
|
# signature against the same key file it issues tokens with, and uses
|
||||||
@@ -414,15 +401,32 @@ EOF
|
|||||||
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
|
# Restart LiveKit / lk-jwt-service when a rebuild regenerates their runtime
|
||||||
# configs (new domains, externalIP, full-access list), mirroring the domain
|
# configs (new domains, externalIP, full-access list), mirroring the domain
|
||||||
# change flow.
|
# change flow.
|
||||||
# Re-run the config generator and restart LiveKit when a rebuild regenerates
|
|
||||||
# the runtime config, or when the Hub persists a new external IP (dynamic
|
|
||||||
# WAN IPs), so the advertised ICE candidate stays current without a manual
|
|
||||||
# restart. The trigger chain: external-ip change → livekit-turn-setup
|
|
||||||
# re-runs → rewrites livekit.yaml → livekit restarts with the new config.
|
|
||||||
systemd.services.livekit-turn-setup.restartTriggers = [ "/var/lib/secrets/external-ip" ];
|
|
||||||
systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
|
systemd.services.livekit.restartTriggers = [ "/run/livekit/livekit.yaml" ];
|
||||||
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
|
systemd.services.lk-jwt-service.restartTriggers = [ "/run/lk-jwt-service/env" ];
|
||||||
|
|
||||||
|
# Follow a changing public IP. ddns-update.py rewrites
|
||||||
|
# /var/lib/secrets/external-ip only when Njal.la reports a different address;
|
||||||
|
# this path unit then re-runs livekit-turn-setup (new node_ip and TURN
|
||||||
|
# address) and starts LiveKit if it is not running, e.g. because no address
|
||||||
|
# was known yet at boot. restartTriggers cannot do this: it is evaluated when
|
||||||
|
# the system is built, so it cannot watch a file that changes at runtime.
|
||||||
|
systemd.paths.livekit-external-ip = {
|
||||||
|
description = "Watch the public IP recorded by the Njal.la DDNS runner";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
pathConfig.PathChanged = "/var/lib/secrets/external-ip";
|
||||||
|
};
|
||||||
|
systemd.services.livekit-external-ip = {
|
||||||
|
description = "Re-run LiveKit setup for a changed public IP";
|
||||||
|
serviceConfig.Type = "oneshot";
|
||||||
|
unitConfig.ConditionPathExists = "/var/lib/domains/element-calling";
|
||||||
|
script = ''
|
||||||
|
# livekit.service requires livekit-turn-setup, so it restarts with it.
|
||||||
|
systemctl restart livekit-turn-setup.service
|
||||||
|
# No-op if LiveKit is already running; starts it after an earlier failure.
|
||||||
|
systemctl start livekit.service
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
####### PUBLIC REACHABILITY SELF-CHECK #######
|
####### PUBLIC REACHABILITY SELF-CHECK #######
|
||||||
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
||||||
# classic "call connects but no media" setup errors at boot instead of at
|
# classic "call connects but no media" setup errors at boot instead of at
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
./core/no-sleep.nix
|
./core/no-sleep.nix
|
||||||
./core/cpu-performance.nix
|
./core/cpu-performance.nix
|
||||||
./core/local-domain-loopback.nix
|
./core/local-domain-loopback.nix
|
||||||
./core/public-ip.nix
|
|
||||||
|
|
||||||
# ── Always on (no flag) ───────────────────────────────────
|
# ── Always on (no flag) ───────────────────────────────────
|
||||||
./php.nix
|
./php.nix
|
||||||
|
|||||||
+112
-3
@@ -3,10 +3,22 @@
|
|||||||
lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||||
|
|
||||||
# ── PostgreSQL database ───────────────────────────────────
|
# ── PostgreSQL database ───────────────────────────────────
|
||||||
|
# Cluster-wide tuning (shared_buffers, autovacuum) lives in
|
||||||
|
# configuration.nix so it is shared with Matrix Synapse.
|
||||||
services.postgresql = {
|
services.postgresql = {
|
||||||
enable = true;
|
enable = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# ── Redis for Nextcloud distributed cache + file locking ───
|
||||||
|
# Nextcloud does not recommend APCu for memcache.locking in production.
|
||||||
|
# TCP on localhost avoids unix-socket permission juggling with the caddy user.
|
||||||
|
# Scoped to Nextcloud only — Synapse / MariaDB / Bitcoin are unaffected.
|
||||||
|
services.redis.servers.nextcloud = {
|
||||||
|
enable = true;
|
||||||
|
bind = "127.0.0.1";
|
||||||
|
port = 6379;
|
||||||
|
};
|
||||||
|
|
||||||
# ── Auto-generate DB password and initialize ──────────────
|
# ── Auto-generate DB password and initialize ──────────────
|
||||||
systemd.services.nextcloud-db-init = {
|
systemd.services.nextcloud-db-init = {
|
||||||
description = "Initialize Nextcloud PostgreSQL database with auto-generated password";
|
description = "Initialize Nextcloud PostgreSQL database with auto-generated password";
|
||||||
@@ -47,14 +59,20 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
|||||||
if ! psql -U postgres -lqt | cut -d \| -f 1 | grep -qw "nextclouddb"; then
|
if ! psql -U postgres -lqt | cut -d \| -f 1 | grep -qw "nextclouddb"; then
|
||||||
psql -U postgres -c "CREATE DATABASE nextclouddb WITH OWNER ncusr TEMPLATE template0 LC_COLLATE = 'C' LC_CTYPE = 'C';"
|
psql -U postgres -c "CREATE DATABASE nextclouddb WITH OWNER ncusr TEMPLATE template0 LC_COLLATE = 'C' LC_CTYPE = 'C';"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# NOTE: autovacuum GUCs are SIGHUP-context, so they cannot be set
|
||||||
|
# per-database — ALTER DATABASE ... SET rejects them with
|
||||||
|
# 'parameter "..." cannot be changed now'. They are set
|
||||||
|
# cluster-wide in configuration.nix instead, which already covers
|
||||||
|
# both nextclouddb and matrix-synapse.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── Fully automated Nextcloud setup ───────────────────────
|
# ── Fully automated Nextcloud setup ───────────────────────
|
||||||
systemd.services.nextcloud-init = {
|
systemd.services.nextcloud-init = {
|
||||||
description = "Download, extract, and fully configure Nextcloud";
|
description = "Download, extract, and fully configure Nextcloud";
|
||||||
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" ];
|
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" "redis-nextcloud.service" ];
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" "redis-nextcloud.service" ];
|
||||||
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
|
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
@@ -150,7 +168,11 @@ lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
|||||||
php $INSTALL_DIR/occ config:system:set default_phone_region --value='US'
|
php $INSTALL_DIR/occ config:system:set default_phone_region --value='US'
|
||||||
php $INSTALL_DIR/occ config:system:set maintenance_window_start --type=integer --value=1
|
php $INSTALL_DIR/occ config:system:set maintenance_window_start --type=integer --value=1
|
||||||
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
|
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
|
||||||
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\APCu'
|
php $INSTALL_DIR/occ config:system:set memcache.distributed --value='\OC\Memcache\Redis'
|
||||||
|
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
|
||||||
|
php $INSTALL_DIR/occ config:system:set redis host --value='127.0.0.1'
|
||||||
|
php $INSTALL_DIR/occ config:system:set redis port --type=integer --value=6379
|
||||||
|
php $INSTALL_DIR/occ config:system:set redis timeout --value='1.5'
|
||||||
php $INSTALL_DIR/occ config:system:set server_id --value='$SERVER_ID'
|
php $INSTALL_DIR/occ config:system:set server_id --value='$SERVER_ID'
|
||||||
php $INSTALL_DIR/occ background:cron
|
php $INSTALL_DIR/occ background:cron
|
||||||
"
|
"
|
||||||
@@ -247,6 +269,93 @@ CREDS
|
|||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# ── Migrate existing installs to Redis locking ────────────
|
||||||
|
# nextcloud-init only runs on fresh installs (ConditionPathExists
|
||||||
|
# !config.php), so pre-existing / pre-Sovran installs would keep
|
||||||
|
# APCu locking forever. This one-shot is idempotent and safe to
|
||||||
|
# re-run on every boot — occ just overwrites the same values.
|
||||||
|
systemd.services.nextcloud-redis-migrate = {
|
||||||
|
description = "Point existing Nextcloud installs at Redis locking";
|
||||||
|
after = [ "postgresql.service" "redis-nextcloud.service" "phpfpm-nextcloud.service" ];
|
||||||
|
wants = [ "redis-nextcloud.service" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
unitConfig = {
|
||||||
|
ConditionPathExists = [
|
||||||
|
"/var/lib/www/nextcloud/occ"
|
||||||
|
"/var/lib/www/nextcloud/config/config.php"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
};
|
||||||
|
path = with pkgs; [ coreutils shadow ];
|
||||||
|
script = ''
|
||||||
|
set -euo pipefail
|
||||||
|
INSTALL_DIR="/var/lib/www/nextcloud"
|
||||||
|
# Wait briefly for Redis (TCP localhost:6379).
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if (echo > /dev/tcp/127.0.0.1/6379) >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||||
|
php $INSTALL_DIR/occ config:system:set memcache.local --value='\OC\Memcache\APCu'
|
||||||
|
php $INSTALL_DIR/occ config:system:set memcache.distributed --value='\OC\Memcache\Redis'
|
||||||
|
php $INSTALL_DIR/occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
|
||||||
|
php $INSTALL_DIR/occ config:system:set redis host --value='127.0.0.1'
|
||||||
|
php $INSTALL_DIR/occ config:system:set redis port --type=integer --value=6379
|
||||||
|
php $INSTALL_DIR/occ config:system:set redis timeout --value='1.5'
|
||||||
|
"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── Recurring DB maintenance (Nextcloud 35 checks) ───────────
|
||||||
|
# nextcloud-init runs db:add-missing-indices exactly once. Upgrades
|
||||||
|
# (e.g. to NC35) and later app installs (Mail, Guests) add tables
|
||||||
|
# like oc_mail_tags / oc_guests_users that then seq-scan forever.
|
||||||
|
# Weekly: VACUUM ANALYZE (dead tuples) + backfill missing indices.
|
||||||
|
# Scoped to nextclouddb only — matrix-synapse is untouched.
|
||||||
|
systemd.services.nextcloud-db-maintenance = {
|
||||||
|
description = "Nextcloud DB maintenance: VACUUM + missing indices";
|
||||||
|
after = [ "postgresql.service" "redis-nextcloud.service" "phpfpm-nextcloud.service" ];
|
||||||
|
wants = [ "postgresql.service" ];
|
||||||
|
unitConfig = {
|
||||||
|
ConditionPathExists = [
|
||||||
|
"/var/lib/www/nextcloud/occ"
|
||||||
|
"/var/lib/www/nextcloud/config/config.php"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
};
|
||||||
|
path = [ config.services.postgresql.package pkgs.coreutils pkgs.shadow ];
|
||||||
|
script = ''
|
||||||
|
set -euo pipefail
|
||||||
|
INSTALL_DIR="/var/lib/www/nextcloud"
|
||||||
|
echo "Vacuuming nextclouddb..."
|
||||||
|
psql -U postgres -d nextclouddb -c "VACUUM (ANALYZE);"
|
||||||
|
echo "Backfilling Nextcloud indices..."
|
||||||
|
/run/wrappers/bin/su -s /bin/sh caddy -c "
|
||||||
|
php $INSTALL_DIR/occ db:add-missing-indices
|
||||||
|
php $INSTALL_DIR/occ db:add-missing-columns
|
||||||
|
php $INSTALL_DIR/occ db:add-missing-primary-keys
|
||||||
|
"
|
||||||
|
echo "Nextcloud DB maintenance complete."
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.timers.nextcloud-db-maintenance = {
|
||||||
|
description = "Weekly Nextcloud DB maintenance";
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
timerConfig = {
|
||||||
|
OnCalendar = "Sun 03:30";
|
||||||
|
Persistent = true;
|
||||||
|
RandomizedDelaySec = "30m";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
services.cron.systemCronJobs = [
|
services.cron.systemCronJobs = [
|
||||||
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
|
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
|
||||||
];
|
];
|
||||||
|
|||||||
+56
-14
@@ -1,29 +1,70 @@
|
|||||||
{ config, pkgs, lib, ... }:
|
{ config, pkgs, lib, ... }:
|
||||||
|
|
||||||
|
# ── Shared PHP for Nextcloud + WordPress ──────────────────────────────────────
|
||||||
|
#
|
||||||
|
# One interpreter (with one extension set and one php.ini) is shared by the
|
||||||
|
# phpfpm-nextcloud and phpfpm-wordpress pools, the Nextcloud cron job and the
|
||||||
|
# occ / wp-cli helper scripts. Every consumer must reference
|
||||||
|
# config.sovran_systemsOS.phpPackage (or /run/current-system/sw/bin/php) so
|
||||||
|
# that the CLI and the FPM pools always run the *same* PHP.
|
||||||
|
#
|
||||||
|
# Version policy (September 2026):
|
||||||
|
# • Nextcloud 35 supports PHP 8.3 / 8.4 / 8.5 and recommends 8.5. Its setup
|
||||||
|
# check flags 8.3 as "deprecated since Nextcloud 35" and warns that
|
||||||
|
# Nextcloud 36 may require at least 8.4.
|
||||||
|
# • WordPress 6.9 / 7.0 fully support PHP 8.4 and 8.5.
|
||||||
|
# • PHP 8.3 has been security-only since 2025-12-31; PHP 8.4 leaves active
|
||||||
|
# support on 2026-12-31; PHP 8.5 is actively supported until 2027-12-31.
|
||||||
|
#
|
||||||
|
# To fall back to PHP 8.4 (nixpkgs' current default `pkgs.php`) change only
|
||||||
|
# the `phpBase` line below.
|
||||||
|
|
||||||
let
|
let
|
||||||
|
phpBase = pkgs.php85;
|
||||||
|
|
||||||
|
custom-php = phpBase.buildEnv {
|
||||||
|
# `enabled` is nixpkgs' default extension set. It already contains every
|
||||||
|
# module Nextcloud lists as required or recommended (ctype, curl, dom,
|
||||||
|
# fileinfo, gd, intl, mbstring, openssl, posix, session, simplexml,
|
||||||
|
# xmlreader, xmlwriter, zip, zlib, pdo_pgsql, pdo_mysql, bcmath, gmp,
|
||||||
|
# exif, sodium, sysvsem, pcntl, ...). OPcache is compiled into PHP >= 8.5
|
||||||
|
# and no longer appears as a separate extension.
|
||||||
|
extensions = { enabled, all }: enabled ++ (with all; [
|
||||||
|
bz2 # Nextcloud: bz2 archive support
|
||||||
|
apcu # Nextcloud: memcache.local (apc.enable_cli=1 below is mandatory for occ + cron)
|
||||||
|
redis # Nextcloud: memcache.distributed / file locking once a Redis server is configured
|
||||||
|
imagick # Nextcloud: previews + theming (nixpkgs ImageMagick is built with SVG support)
|
||||||
|
memcached # WordPress object-cache plugins (legacy option for Nextcloud)
|
||||||
|
]);
|
||||||
|
|
||||||
custom-php = pkgs.php83.buildEnv {
|
|
||||||
extensions = { enabled, all }: enabled ++ (with all; [ bz2 apcu redis imagick memcached ]);
|
|
||||||
extraConfig = ''
|
extraConfig = ''
|
||||||
|
; ── Error handling (production) ─────────────────────────────────
|
||||||
|
display_errors = Off
|
||||||
|
display_startup_errors = Off
|
||||||
|
log_errors = On
|
||||||
|
|
||||||
display_errors = On
|
; ── Limits ──────────────────────────────────────────────────────
|
||||||
display_startup_errors = On
|
|
||||||
max_execution_time = 10000
|
max_execution_time = 10000
|
||||||
max_input_time = 3000
|
max_input_time = 3000
|
||||||
memory_limit = 1G;
|
memory_limit = 1G
|
||||||
opcache.enable=1;
|
|
||||||
opcache.memory_consumption=512;
|
|
||||||
opcache_revalidate_freq = 240;
|
|
||||||
opcache.max_accelerated_files=20000;
|
|
||||||
post_max_size = 3G
|
post_max_size = 3G
|
||||||
upload_max_filesize = 3G
|
upload_max_filesize = 3G
|
||||||
apc.enable_cli=1
|
|
||||||
opcache.interned_strings_buffer = 192
|
|
||||||
redis.session.locking_enabled=1
|
|
||||||
redis.session.lock_retries=-1
|
|
||||||
redis.session.lock_wait_time=10000
|
|
||||||
|
|
||||||
|
; ── OPcache (Nextcloud "Server tuning" recommendations) ─────────
|
||||||
|
opcache.enable = 1
|
||||||
|
opcache.memory_consumption = 512
|
||||||
|
opcache.interned_strings_buffer = 192
|
||||||
|
opcache.max_accelerated_files = 20000
|
||||||
|
opcache.revalidate_freq = 240
|
||||||
|
opcache.save_comments = 1
|
||||||
|
|
||||||
|
; ── APCu ────────────────────────────────────────────────────────
|
||||||
|
apc.enable_cli = 1
|
||||||
|
|
||||||
|
; ── phpredis session locking (only used with session.save_handler = redis)
|
||||||
|
redis.session.locking_enabled = 1
|
||||||
|
redis.session.lock_retries = -1
|
||||||
|
redis.session.lock_wait_time = 10000
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
@@ -55,3 +96,4 @@ in
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,9 +105,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.allowedTCPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
||||||
networking.firewall.allowedUDPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
|
||||||
|
|
||||||
# ── 6. NWC / LNURL — Sovran Hub integration ───────────────────
|
# ── 6. NWC / LNURL — Sovran Hub integration ───────────────────
|
||||||
# Sovran_Bitcoin's albyhub.nix and lnurl.nix handle the base services.
|
# Sovran_Bitcoin's albyhub.nix and lnurl.nix handle the base services.
|
||||||
|
|||||||
@@ -94,16 +94,23 @@ EOF
|
|||||||
# ── Synapse service ─────────────────────────────────────────
|
# ── Synapse service ─────────────────────────────────────────
|
||||||
services.matrix-synapse = {
|
services.matrix-synapse = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
# cache-memory provides cache-size statistics for the autotuning below
|
||||||
|
# (in addition to the NixOS defaults).
|
||||||
|
extras = [ "systemd" "postgres" "url-preview" "cache-memory" ];
|
||||||
extraConfigFiles = [
|
extraConfigFiles = [
|
||||||
"/run/matrix-synapse/runtime-config.yaml"
|
"/run/matrix-synapse/runtime-config.yaml"
|
||||||
];
|
];
|
||||||
settings = {
|
settings = {
|
||||||
database = {
|
database = {
|
||||||
name = "psycopg2";
|
name = "psycopg2";
|
||||||
|
# Recycle pooled connections less often (fewer reconnects).
|
||||||
|
txn_limit = 10000;
|
||||||
args = {
|
args = {
|
||||||
host = "localhost";
|
host = "localhost";
|
||||||
database = "matrix-synapse";
|
database = "matrix-synapse";
|
||||||
user = "matrix-synapse";
|
user = "matrix-synapse";
|
||||||
|
cp_min = 5;
|
||||||
|
cp_max = 15;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
push.include_content = false;
|
push.include_content = false;
|
||||||
@@ -120,6 +127,32 @@ EOF
|
|||||||
];
|
];
|
||||||
presence.enabled = true;
|
presence.enabled = true;
|
||||||
enable_registration = false;
|
enable_registration = false;
|
||||||
|
# ── Performance (32 GB Server + Desktop) ─────────────────
|
||||||
|
# Synapse trades RAM for fewer Postgres round-trips; most RAM goes
|
||||||
|
# to caches. Stock is global_factor 0.5 + 10K event cache, which
|
||||||
|
# leaves syncs hitting the database on every request.
|
||||||
|
# Deliberately unchanged: presence and URL previews stay enabled —
|
||||||
|
# disabling them is faster but changes user-visible behavior.
|
||||||
|
event_cache_size = "100K";
|
||||||
|
caches = {
|
||||||
|
global_factor = 4.0;
|
||||||
|
expire_caches = true;
|
||||||
|
cache_entry_ttl = "30m";
|
||||||
|
sync_response_cache_duration = "2m";
|
||||||
|
cache_autotuning = {
|
||||||
|
max_cache_memory_usage = "2G";
|
||||||
|
target_cache_memory_usage = "1G";
|
||||||
|
min_cache_ttl = "30s";
|
||||||
|
};
|
||||||
|
per_cache_factors = {
|
||||||
|
# Hot paths for /sync and room joins.
|
||||||
|
get_users_in_room = 3.0;
|
||||||
|
get_current_state_ids = 3.0;
|
||||||
|
get_unread_event_push_actions_by_room_for_user = 5.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
# Fewer GC pauses at the cost of a little more memory.
|
||||||
|
gc_thresholds = [ 1500 20 10 ];
|
||||||
listeners = [
|
listeners = [
|
||||||
{
|
{
|
||||||
port = 8008;
|
port = 8008;
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""Tests for the DDNS runner (sovran_systemsos_web.ddns_update).
|
||||||
|
|
||||||
|
The runner asks Njal.la to use the address the request came from ("&auto"),
|
||||||
|
reads back the address Njal.la recorded and saves it for LiveKit and the Hub.
|
||||||
|
|
||||||
|
Tests must never:
|
||||||
|
- access the network (curl is replaced by a fake ``run``)
|
||||||
|
- write to system paths (the URL and IP files live in a temp dir)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
_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 import ddns_update as d # noqa: E402
|
||||||
|
|
||||||
|
KEY = "SECRETKEY123"
|
||||||
|
AUTO_URL = f"https://njal.la/update/?h=sub.example.com&k={KEY}&auto"
|
||||||
|
LEGACY_URL = f"https://njal.la/update/?h=sub.example.com&k={KEY}&a=${{IP}}"
|
||||||
|
PUBLIC_IP = "93.184.216.34"
|
||||||
|
OTHER_IP = "8.8.4.4"
|
||||||
|
|
||||||
|
|
||||||
|
def reply(ip=PUBLIC_IP, status=200):
|
||||||
|
return json.dumps({"status": status, "message": "record updated", "value": {"A": ip}})
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRun:
|
||||||
|
"""Stands in for subprocess.run; records every command it is given."""
|
||||||
|
|
||||||
|
def __init__(self, stdout=None, returncode=0, raises=None):
|
||||||
|
self.stdout = reply() if stdout is None else stdout
|
||||||
|
self.returncode = returncode
|
||||||
|
self.raises = raises
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def __call__(self, cmd, **kwargs):
|
||||||
|
self.calls.append(cmd)
|
||||||
|
if self.raises:
|
||||||
|
raise self.raises
|
||||||
|
return subprocess.CompletedProcess(cmd, self.returncode, stdout=self.stdout, stderr="")
|
||||||
|
|
||||||
|
|
||||||
|
class NormaliseUrlTests(unittest.TestCase):
|
||||||
|
def test_legacy_placeholder_becomes_auto(self):
|
||||||
|
self.assertEqual(d.normalise_url(LEGACY_URL), AUTO_URL)
|
||||||
|
|
||||||
|
def test_quiet_is_dropped_so_the_reply_can_be_read(self):
|
||||||
|
self.assertEqual(d.normalise_url(AUTO_URL + "&quiet"), AUTO_URL)
|
||||||
|
|
||||||
|
def test_plain_auto_is_unchanged(self):
|
||||||
|
self.assertEqual(d.normalise_url(AUTO_URL), AUTO_URL)
|
||||||
|
|
||||||
|
def test_explicit_address_is_unchanged(self):
|
||||||
|
url = "https://njal.la/update/?h=a.example.com&k=K&a=93.184.216.34"
|
||||||
|
self.assertEqual(d.normalise_url(url), url)
|
||||||
|
|
||||||
|
|
||||||
|
class IsPublicIpv4Tests(unittest.TestCase):
|
||||||
|
def test_public_addresses(self):
|
||||||
|
for ip in ("93.184.216.34", "8.8.8.8", " 1.1.1.1\n"):
|
||||||
|
self.assertTrue(d.is_public_ipv4(ip), ip)
|
||||||
|
|
||||||
|
def test_everything_else_is_rejected(self):
|
||||||
|
for ip in ("10.0.0.1", "192.168.1.5", "172.16.0.9", "127.0.0.1", "169.254.1.1",
|
||||||
|
"100.64.0.1", "0.0.0.0", "224.0.0.1", "::1", "2001:4860:4860::8888",
|
||||||
|
"not-an-ip", "", None):
|
||||||
|
self.assertFalse(d.is_public_ipv4(ip), ip)
|
||||||
|
|
||||||
|
|
||||||
|
class ParseReplyTests(unittest.TestCase):
|
||||||
|
def test_success_returns_the_recorded_address(self):
|
||||||
|
self.assertEqual(d.parse_reply(reply()), PUBLIC_IP)
|
||||||
|
|
||||||
|
def test_status_may_be_a_string(self):
|
||||||
|
self.assertEqual(d.parse_reply(reply(status="200")), PUBLIC_IP)
|
||||||
|
|
||||||
|
def test_error_status_is_rejected(self):
|
||||||
|
body = json.dumps({"status": 401, "message": "invalid host or key"})
|
||||||
|
self.assertIsNone(d.parse_reply(body))
|
||||||
|
|
||||||
|
def test_garbage_is_rejected(self):
|
||||||
|
for body in ("", "not json", "[]", "null", "{}", json.dumps({"status": 200})):
|
||||||
|
self.assertIsNone(d.parse_reply(body), body)
|
||||||
|
|
||||||
|
def test_non_public_or_non_ipv4_address_is_rejected(self):
|
||||||
|
for ip in ("10.1.2.3", "100.64.9.9", "127.0.0.1", "::1", "2001:4860:4860::8888", "x"):
|
||||||
|
self.assertIsNone(d.parse_reply(reply(ip)), ip)
|
||||||
|
|
||||||
|
|
||||||
|
class IpFileTests(unittest.TestCase):
|
||||||
|
def test_write_then_read(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "secrets", "external-ip")
|
||||||
|
d.write_ip_file(PUBLIC_IP, path)
|
||||||
|
self.assertEqual(d.read_ip_file(path), PUBLIC_IP)
|
||||||
|
self.assertEqual(open(path).read(), PUBLIC_IP) # no trailing newline
|
||||||
|
self.assertEqual(os.stat(path).st_mode & 0o777, 0o644)
|
||||||
|
|
||||||
|
def test_replace_is_atomic_and_leaves_no_temp_files(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "external-ip")
|
||||||
|
d.write_ip_file(PUBLIC_IP, path)
|
||||||
|
d.write_ip_file(OTHER_IP, path)
|
||||||
|
self.assertEqual(d.read_ip_file(path), OTHER_IP)
|
||||||
|
self.assertEqual(os.listdir(tmp), ["external-ip"])
|
||||||
|
|
||||||
|
def test_missing_file_reads_as_none(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
self.assertIsNone(d.read_ip_file(os.path.join(tmp, "nope")))
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateAllTests(unittest.TestCase):
|
||||||
|
def run_update(self, urls, fake):
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
result = d.update_all(urls, run=fake)
|
||||||
|
return result, out.getvalue()
|
||||||
|
|
||||||
|
def test_curl_is_called_directly_with_ipv4_and_no_redirects(self):
|
||||||
|
fake = FakeRun()
|
||||||
|
result, _ = self.run_update([AUTO_URL], fake)
|
||||||
|
self.assertEqual(result, PUBLIC_IP)
|
||||||
|
self.assertEqual(len(fake.calls), 1)
|
||||||
|
cmd = fake.calls[0]
|
||||||
|
self.assertEqual(cmd[0], "curl")
|
||||||
|
for flag in ("--ipv4", "--no-location", "--fail", "--silent"):
|
||||||
|
self.assertIn(flag, cmd)
|
||||||
|
self.assertEqual(cmd[-1], AUTO_URL)
|
||||||
|
|
||||||
|
def test_legacy_entry_and_its_auto_twin_are_one_call(self):
|
||||||
|
fake = FakeRun()
|
||||||
|
self.run_update([LEGACY_URL, AUTO_URL], fake)
|
||||||
|
self.assertEqual(fake.calls[0][-1], AUTO_URL)
|
||||||
|
self.assertEqual(len(fake.calls), 1)
|
||||||
|
|
||||||
|
def test_url_for_another_host_is_never_called(self):
|
||||||
|
fake = FakeRun()
|
||||||
|
result, _ = self.run_update([f"https://evil.example/update/?h=x&k={KEY}&auto"], fake)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
self.assertEqual(fake.calls, [])
|
||||||
|
|
||||||
|
def test_failed_curl_yields_nothing(self):
|
||||||
|
result, _ = self.run_update([AUTO_URL], FakeRun(returncode=22))
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_reply_without_an_address_yields_nothing(self):
|
||||||
|
result, _ = self.run_update([AUTO_URL], FakeRun(stdout=json.dumps({"status": 200})))
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_missing_curl_is_survived(self):
|
||||||
|
result, out = self.run_update([AUTO_URL], FakeRun(raises=FileNotFoundError("curl")))
|
||||||
|
self.assertIsNone(result)
|
||||||
|
self.assertIn("skipped", out)
|
||||||
|
|
||||||
|
def test_first_reported_address_wins(self):
|
||||||
|
calls = iter([reply(PUBLIC_IP), reply(OTHER_IP)])
|
||||||
|
|
||||||
|
def fake(cmd, **kwargs):
|
||||||
|
return subprocess.CompletedProcess(cmd, 0, stdout=next(calls), stderr="")
|
||||||
|
|
||||||
|
other = f"https://njal.la/update/?h=other.example.com&k={KEY}&auto"
|
||||||
|
out = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(out):
|
||||||
|
result = d.update_all([AUTO_URL, other], run=fake)
|
||||||
|
self.assertEqual(result, PUBLIC_IP)
|
||||||
|
|
||||||
|
def test_the_key_is_never_printed(self):
|
||||||
|
for fake in (FakeRun(), FakeRun(returncode=22), FakeRun(stdout="junk"),
|
||||||
|
FakeRun(raises=FileNotFoundError("curl"))):
|
||||||
|
_, out = self.run_update([AUTO_URL, LEGACY_URL], fake)
|
||||||
|
self.assertNotIn(KEY, out)
|
||||||
|
|
||||||
|
|
||||||
|
class MainTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.urls_file = os.path.join(self.tmp.name, "ddns_urls.json")
|
||||||
|
self.ip_file = os.path.join(self.tmp.name, "secrets", "external-ip")
|
||||||
|
for patch in (mock.patch.object(d, "URLS_FILE", self.urls_file),
|
||||||
|
mock.patch.object(d, "IP_FILE", self.ip_file)):
|
||||||
|
patch.start()
|
||||||
|
self.addCleanup(patch.stop)
|
||||||
|
|
||||||
|
def store(self, urls):
|
||||||
|
with open(self.urls_file, "w") as f:
|
||||||
|
json.dump(urls, f)
|
||||||
|
|
||||||
|
def main(self, fake):
|
||||||
|
out = io.StringIO()
|
||||||
|
with mock.patch.object(d.subprocess, "run", fake), contextlib.redirect_stdout(out):
|
||||||
|
code = d.main()
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
def test_first_update_records_the_address(self):
|
||||||
|
self.store([LEGACY_URL])
|
||||||
|
out = self.main(FakeRun())
|
||||||
|
self.assertEqual(d.read_ip_file(self.ip_file), PUBLIC_IP)
|
||||||
|
self.assertIn("now " + PUBLIC_IP, out)
|
||||||
|
self.assertNotIn(KEY, out)
|
||||||
|
|
||||||
|
def test_unchanged_address_does_not_touch_the_file(self):
|
||||||
|
# A path unit restarts LiveKit whenever the file is written, so an
|
||||||
|
# unchanged address must not rewrite it.
|
||||||
|
self.store([AUTO_URL])
|
||||||
|
self.main(FakeRun())
|
||||||
|
before = os.stat(self.ip_file)
|
||||||
|
out = self.main(FakeRun())
|
||||||
|
after = os.stat(self.ip_file)
|
||||||
|
self.assertEqual((before.st_ino, before.st_mtime_ns), (after.st_ino, after.st_mtime_ns))
|
||||||
|
self.assertIn("unchanged", out)
|
||||||
|
|
||||||
|
def test_changed_address_is_recorded(self):
|
||||||
|
self.store([AUTO_URL])
|
||||||
|
self.main(FakeRun(stdout=reply(PUBLIC_IP)))
|
||||||
|
out = self.main(FakeRun(stdout=reply(OTHER_IP)))
|
||||||
|
self.assertEqual(d.read_ip_file(self.ip_file), OTHER_IP)
|
||||||
|
self.assertIn(f"now {OTHER_IP} (was {PUBLIC_IP})", out)
|
||||||
|
|
||||||
|
def test_failed_update_keeps_the_last_known_address(self):
|
||||||
|
self.store([AUTO_URL])
|
||||||
|
self.main(FakeRun(stdout=reply(PUBLIC_IP)))
|
||||||
|
self.main(FakeRun(returncode=7))
|
||||||
|
self.assertEqual(d.read_ip_file(self.ip_file), PUBLIC_IP)
|
||||||
|
|
||||||
|
def test_nothing_configured_does_nothing(self):
|
||||||
|
fake = FakeRun()
|
||||||
|
self.main(fake) # no URL file at all
|
||||||
|
self.store([])
|
||||||
|
self.main(fake) # empty list
|
||||||
|
self.assertEqual(fake.calls, [])
|
||||||
|
self.assertFalse(os.path.exists(self.ip_file))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Guards for the home-IP warnings.
|
||||||
|
|
||||||
|
Server + Desktop publishes the home IP address (the domain points at it), so every
|
||||||
|
place that offers Server + Desktop must say so, and the README section they point
|
||||||
|
at must exist.
|
||||||
|
|
||||||
|
These read the shipped source files like the nix-file checks in test_security.py:
|
||||||
|
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__), ".."))
|
||||||
|
_PHRASE = "home ip address"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(*parts):
|
||||||
|
with open(os.path.join(_ROOT, *parts), encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _github_slug(heading):
|
||||||
|
slug = re.sub(r"[^\w\- ]", "", heading.strip().lower())
|
||||||
|
return slug.replace(" ", "-")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeIpWarnings(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_installer_role_card(self):
|
||||||
|
src = _read("iso", "installer.py")
|
||||||
|
card = re.search(r'\("Server \+ Desktop",\s*"((?:[^"\\]|\\.)*)"', src, re.S)
|
||||||
|
self.assertIsNotNone(card, "Server + Desktop role card not found")
|
||||||
|
self.assertIn(_PHRASE, card.group(1).lower())
|
||||||
|
|
||||||
|
def test_hub_domain_setup_text(self):
|
||||||
|
# domain-prereqs.js is the single source for onboarding, feature setup
|
||||||
|
# and domain reconfiguration; the notice must follow every variant.
|
||||||
|
js = _read("app", "sovran_systemsos_web", "static", "js", "domain-prereqs.js")
|
||||||
|
body = js[js.index("function renderDomainNeedsHtml"):]
|
||||||
|
body = body[:body.index("\n}\n")]
|
||||||
|
self.assertIn(_PHRASE, body.lower())
|
||||||
|
self.assertGreater(body.lower().index(_PHRASE), body.rindex("} else {"),
|
||||||
|
"the notice must come after the last variant, not inside one")
|
||||||
|
|
||||||
|
def test_hub_upgrade_dialog(self):
|
||||||
|
html = _read("app", "sovran_systemsos_web", "templates", "index.html")
|
||||||
|
dialog = html[html.index('id="upgrade-modal"'):html.index("Security Reset overlay")]
|
||||||
|
self.assertIn(_PHRASE, " ".join(dialog.lower().split()))
|
||||||
|
|
||||||
|
def test_readme_and_security_policy(self):
|
||||||
|
self.assertIn(_PHRASE, _read("README.md").lower())
|
||||||
|
self.assertIn(_PHRASE, " ".join(_read("SECURITY.md").lower().split()))
|
||||||
|
|
||||||
|
def test_links_to_the_readme_section_resolve(self):
|
||||||
|
readme = _read("README.md")
|
||||||
|
slugs = {_github_slug(m.group(2))
|
||||||
|
for m in re.finditer(r"^(#{1,6})\s+(.+?)\s*$", readme, re.M)}
|
||||||
|
links = re.findall(r"\]\(#(server--desktop[^)]*)\)", readme)
|
||||||
|
links += re.findall(r"README\.md#(server--desktop[^)\s]*)", _read("SECURITY.md"))
|
||||||
|
self.assertTrue(links, "expected links to the home-IP section")
|
||||||
|
for anchor in links:
|
||||||
|
self.assertIn(anchor, slugs)
|
||||||
|
|
||||||
|
|
||||||
|
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,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