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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -21,9 +21,9 @@ Lightning infrastructure, private cloud, and communications platform when you
|
||||
are ready.
|
||||
|
||||
[Visit the Website](https://sovransystems.com) ·
|
||||
[Download the ISO](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.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) ·
|
||||
[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)
|
||||
|
||||
<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)
|
||||
- [Three modes](#three-modes)
|
||||
- [Use it your way](#use-it-your-way)
|
||||
- [The Sovran Hub](#the-sovran-hub)
|
||||
- [The Hub](#the-hub)
|
||||
- [Install Sovran_SystemsOS](#install-sovran_systemsos)
|
||||
- [For developers](#for-developers)
|
||||
- [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 |
|
||||
| **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
|
||||
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
|
||||
> support this. If you are unsure, a quick search for your router model and
|
||||
> "port forwarding" will usually turn up a step-by-step guide.
|
||||
>
|
||||
> **This mode also makes your home IP address public.** Read
|
||||
> [what that means](#server--desktop-and-your-home-ip-address) before you
|
||||
> choose it.
|
||||
|
||||
### 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
|
||||
Sovran_SystemsOS on a separate computer and let it run quietly on your local
|
||||
network, with or without a monitor. From any other device on the same network,
|
||||
open a browser, visit `http://sovransystemsos.local`, and manage everything
|
||||
from [The Sovran Hub](#the-sovran-hub).
|
||||
open a browser, visit `http://sovransystemsos.local:8937`, and manage
|
||||
everything from [The Sovran Hub](#the-sovran-hub).
|
||||
|
||||
Your existing devices stay familiar. Sovran_SystemsOS provides the independent
|
||||
infrastructure behind them.
|
||||
|
||||
---
|
||||
|
||||
## The Sovran Hub
|
||||
## The Hub
|
||||
|
||||
### 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
|
||||
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.
|
||||
@@ -281,9 +341,9 @@ From one place, the Hub helps you:
|
||||
- Reach your Bitcoin tools, private cloud, and communications
|
||||
- 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
|
||||
|
||||
@@ -294,13 +354,13 @@ From one place, the Hub helps you:
|
||||
│ │ │
|
||||
Windows laptop Phone or tablet Mac or Linux
|
||||
│ │ │
|
||||
└──────── Browser: sovransystemsos.local ────┘
|
||||
└─────── Browser: sovransystemsos.local:8937 ─┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ Sovran_SystemsOS │
|
||||
│ │
|
||||
│ • Sovran Hub │
|
||||
│ • The Hub │
|
||||
│ • Bitcoin node │
|
||||
│ • Sparrow Wallet │
|
||||
│ • 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.
|
||||
|
||||
> **Local access:** the Hub is available at
|
||||
> `http://sovransystemsos.local` to devices connected to the same local
|
||||
> network. It is protected by authentication and is not automatically exposed
|
||||
> to the public internet.
|
||||
> `http://sovransystemsos.local:8937` to devices connected to the same local
|
||||
> network (not on Desktop, which publishes nothing). It is protected by
|
||||
> authentication, 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
|
||||
|
||||
- [Download Sovran_SystemsOS-1.1.5.iso](https://downloads.sovransystems.com/Sovran_SystemsOS-1.1.5.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](https://downloads.sovransystems.com/Sovran_SystemsOS-1.2.0.iso)
|
||||
- [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
|
||||
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:
|
||||
|
||||
```bash
|
||||
sha256sum --check Sovran_SystemsOS-1.1.5.iso.sha256
|
||||
sha256sum --check Sovran_SystemsOS-1.2.0.iso.sha256
|
||||
```
|
||||
|
||||
A successful comparison reports:
|
||||
|
||||
```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.
|
||||
|
||||
</details>
|
||||
@@ -384,11 +445,11 @@ against the checksum file manually.
|
||||
Open Terminal in the download folder and run:
|
||||
|
||||
```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
|
||||
`Sovran_SystemsOS-1.1.5.iso.sha256`.
|
||||
`Sovran_SystemsOS-1.2.0.iso.sha256`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -398,7 +459,7 @@ Compare the value shown in Terminal with the value inside
|
||||
Open PowerShell in the download folder and run:
|
||||
|
||||
```powershell
|
||||
Get-FileHash .\Sovran_SystemsOS-1.1.5.iso -Algorithm SHA256
|
||||
Get-FileHash .\Sovran_SystemsOS-1.2.0.iso -Algorithm SHA256
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
carefully.
|
||||
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:
|
||||
|
||||
```text
|
||||
http://sovransystemsos.local
|
||||
http://sovransystemsos.local:8937
|
||||
```
|
||||
|
||||
Sign in with your Sovran_SystemsOS credentials.
|
||||
Sign in with your Sovran_SystemsOS credentials. Desktop does not publish the Hub
|
||||
on the network, so in that mode open it from the desktop.
|
||||
|
||||
<details>
|
||||
<summary><strong>If sovransystemsos.local does not open</strong></summary>
|
||||
<summary><strong>If sovransystemsos.local:8937 does not open</strong></summary>
|
||||
|
||||
1. Make sure the Sovran_SystemsOS machine is powered on, and allow it a few
|
||||
minutes to finish starting.
|
||||
2. Make sure both devices are connected to the same local network, and that
|
||||
you entered the full address `http://sovransystemsos.local`.
|
||||
you entered the full address `http://sovransystemsos.local:8937`,
|
||||
including the `:8937`.
|
||||
3. Avoid guest Wi-Fi networks, which may prevent devices from seeing one
|
||||
another.
|
||||
4. Temporarily disconnect any VPN that may interfere with local-network
|
||||
@@ -730,7 +793,9 @@ Sovran_SystemsOS uses layered controls:
|
||||
- Separate service users, systemd sandboxing, and loopback bindings where practical
|
||||
- Tor enforcement for supported Bitcoin services
|
||||
- Restricted, time-limited support access with scoped `sudo`
|
||||
- Operator-controlled public service exposure
|
||||
- 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,
|
||||
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.
|
||||
|
||||
[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)
|
||||
|
||||
</div>
|
||||
|
||||
+57
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
| Release | Supported |
|
||||
|---|:---:|
|
||||
| Latest `1.0.x` stable release | Yes |
|
||||
| Latest stable release | Yes |
|
||||
| `main` / `staging-dev` | Development only |
|
||||
| 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
|
||||
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 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
|
||||
- Administrative service ports bound to loopback where practical
|
||||
- Tor enforced for supported Bitcoin traffic and onion services
|
||||
- Public web services exposed only when enabled by the operator
|
||||
- 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
|
||||
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 re
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
@@ -245,6 +246,218 @@ def _validate_ssh_pubkey(key: str) -> str:
|
||||
return key
|
||||
|
||||
|
||||
# ── Login throttling ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# Delays applied after each failed login, and the lockout that follows once an
|
||||
# address has tripped LOGIN_FAIL_MAX inside the window.
|
||||
#
|
||||
# LOGIN_FAIL_WINDOW has to be longer than the time it takes to reach
|
||||
# LOGIN_FAIL_MAX failures under the ramping delay: with a 2s ramp capped at
|
||||
# LOGIN_FAIL_MAX_DELAY, 10 attempts take about 80 seconds, so a 60 second
|
||||
# window would silently expire the earliest failures and the counter could
|
||||
# never reach the limit. 900s (15 minutes) keeps the whole ramp inside it.
|
||||
LOGIN_FAIL_DELAY = 2.0 # base delay; the nth failure waits n x this
|
||||
LOGIN_FAIL_MAX_DELAY = 10.0 # ceiling for a single delay
|
||||
LOGIN_FAIL_WINDOW = 900.0 # rolling window failures are counted in
|
||||
LOGIN_FAIL_MAX = 10 # failures in the window that trigger a lockout
|
||||
LOGIN_LOCKOUT_SECONDS = 300.0 # how long the lockout lasts
|
||||
|
||||
# Cap on how many addresses are tracked, so a distributed sweep cannot grow
|
||||
# the table without bound.
|
||||
_LOGIN_THROTTLE_MAX_IPS = 4096
|
||||
|
||||
|
||||
class LoginThrottle:
|
||||
"""Per-address failed-login tracking with a ramping delay and a lockout.
|
||||
|
||||
The delay ramps so a script hammering the login form slows down as it goes,
|
||||
and once LOGIN_FAIL_MAX failures land inside the window the address is
|
||||
refused outright for LOGIN_LOCKOUT_SECONDS. A successful login clears the
|
||||
address so a legitimate user who fumbles a password is not penalised later.
|
||||
|
||||
``sleep`` and ``clock`` are injectable so tests run without waiting.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fail_delay=LOGIN_FAIL_DELAY,
|
||||
max_delay=LOGIN_FAIL_MAX_DELAY,
|
||||
window=LOGIN_FAIL_WINDOW,
|
||||
max_failures=LOGIN_FAIL_MAX,
|
||||
lockout=LOGIN_LOCKOUT_SECONDS,
|
||||
max_tracked_ips=_LOGIN_THROTTLE_MAX_IPS,
|
||||
sleep=None,
|
||||
clock=None,
|
||||
):
|
||||
self._fail_delay = float(fail_delay)
|
||||
self._max_delay = float(max_delay)
|
||||
self._window = float(window)
|
||||
self._max_failures = int(max_failures)
|
||||
self._lockout = float(lockout)
|
||||
self._max_tracked_ips = int(max_tracked_ips)
|
||||
self._sleep = sleep if sleep is not None else time.sleep
|
||||
self._clock = clock if clock is not None else time.monotonic
|
||||
self._lock = threading.Lock()
|
||||
self._failures: dict[str, list[float]] = {}
|
||||
|
||||
# ── internals ────────────────────────────────────────────────────────────
|
||||
|
||||
def _prune(self, ip, now):
|
||||
"""Drop timestamps outside the window; return what is left."""
|
||||
keep = [t for t in self._failures.get(ip, ()) if now - t < self._window]
|
||||
if keep:
|
||||
self._failures[ip] = keep
|
||||
else:
|
||||
self._failures.pop(ip, None)
|
||||
return keep
|
||||
|
||||
def _evict(self, now):
|
||||
"""Forget addresses that can no longer affect anything."""
|
||||
horizon = max(self._window, self._lockout)
|
||||
for ip in [i for i, ts in self._failures.items()
|
||||
if ts and now - max(ts) > horizon]:
|
||||
self._failures.pop(ip, None)
|
||||
while len(self._failures) > self._max_tracked_ips:
|
||||
oldest = min(self._failures, key=lambda i: max(self._failures[i]))
|
||||
self._failures.pop(oldest, None)
|
||||
|
||||
# ── public API ───────────────────────────────────────────────────────────
|
||||
|
||||
def delay_for(self, count):
|
||||
"""Return the delay owed after *count* failures in the current window."""
|
||||
if count <= 0:
|
||||
return 0.0
|
||||
return min(self._fail_delay * count, self._max_delay)
|
||||
|
||||
def failure_count(self, ip):
|
||||
"""Return the failures currently counted against *ip*."""
|
||||
with self._lock:
|
||||
return len(self._prune(ip, self._clock()))
|
||||
|
||||
def is_locked_out(self, ip):
|
||||
"""Return True while *ip* is inside a lockout."""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
failures = self._prune(ip, now)
|
||||
if len(failures) < self._max_failures:
|
||||
return False
|
||||
return (now - failures[-1]) < self._lockout
|
||||
|
||||
def remaining_lockout(self, ip):
|
||||
"""Return the seconds left in *ip*'s lockout, or 0.0 if not locked out."""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
failures = self._prune(ip, now)
|
||||
if len(failures) < self._max_failures:
|
||||
return 0.0
|
||||
return max(0.0, self._lockout - (now - failures[-1]))
|
||||
|
||||
def record_failure(self, ip):
|
||||
"""Record a failure for *ip* and serve out the delay it has earned.
|
||||
|
||||
Returns the delay that was applied. The lock is never held across the
|
||||
sleep, so one slow client cannot stall every other login.
|
||||
"""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
failures = list(self._prune(ip, now))
|
||||
failures.append(now)
|
||||
self._failures[ip] = failures
|
||||
count = len(failures)
|
||||
self._evict(now)
|
||||
delay = self.delay_for(count)
|
||||
if delay > 0:
|
||||
self._sleep(delay)
|
||||
return delay
|
||||
|
||||
def clear(self, ip):
|
||||
"""Forget *ip*, e.g. after a successful login."""
|
||||
with self._lock:
|
||||
self._failures.pop(ip, None)
|
||||
|
||||
def tracked_addresses(self):
|
||||
"""Return how many addresses are currently being tracked."""
|
||||
with self._lock:
|
||||
return len(self._failures)
|
||||
|
||||
|
||||
# ── Local-network client policy ───────────────────────────────────────────────
|
||||
#
|
||||
# The Hub runs as root: it can display stored credentials, reboot the machine
|
||||
# and rebuild the system. It answers this computer and the local network and
|
||||
# nobody else. Whether a packet may reach its port is the firewall's and the
|
||||
# router's business; this is the second lock, applied by the application itself
|
||||
# so that a port forward, a firewall mistake, or a machine that has a public
|
||||
# address does not put the login page in front of the internet.
|
||||
#
|
||||
# The Hub listens on IPv4 only (see sovran-hub.nix), so IPv6 clients never
|
||||
# reach it directly and the IPv6 ranges below only matter if that bind is ever
|
||||
# widened. Global IPv6 addresses (2000::/3) are deliberately not listed: a
|
||||
# global address belonging to a laptop on the LAN cannot be told apart from a
|
||||
# stranger's by the address alone, and allowing the range would let the whole
|
||||
# IPv6 internet through.
|
||||
LAN_ONLY_IPV4 = (
|
||||
"127.0.0.0/8", # this computer
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"100.64.0.0/10", # Tailscale and other VPN/CGNAT ranges
|
||||
"169.254.0.0/16", # link-local
|
||||
)
|
||||
LAN_ONLY_IPV6 = (
|
||||
"::1/128",
|
||||
"fc00::/7", # unique-local (covers fd00::/8)
|
||||
"fe80::/10", # link-local
|
||||
)
|
||||
|
||||
|
||||
class LanPolicy:
|
||||
"""Decides whether a client address counts as local.
|
||||
|
||||
``extra_networks`` are CIDR blocks an operator has declared local in
|
||||
addition to the built-in ranges, of either address family. ``enabled=False``
|
||||
turns the check off entirely; it is the one explicit way to do that.
|
||||
"""
|
||||
|
||||
def __init__(self, extra_networks=(), enabled=True):
|
||||
self.enabled = bool(enabled)
|
||||
nets = [ipaddress.ip_network(c, strict=False)
|
||||
for c in LAN_ONLY_IPV4 + LAN_ONLY_IPV6]
|
||||
for cidr in (extra_networks or ()):
|
||||
try:
|
||||
net = ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError:
|
||||
# A malformed entry must never widen the policy. Ignore it and
|
||||
# stay at the strictest interpretation.
|
||||
continue
|
||||
if net.prefixlen == 0:
|
||||
# 0.0.0.0/0 and ::/0 are "everyone". That is lan_only = false,
|
||||
# and it should be asked for by name, not arrive as a "network".
|
||||
continue
|
||||
nets.append(net)
|
||||
self._nets = tuple(nets)
|
||||
|
||||
def allows(self, ip):
|
||||
"""Return True if *ip* may reach the service."""
|
||||
if not self.enabled:
|
||||
return True
|
||||
if not ip:
|
||||
return False
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
# A dual-stack socket reports IPv4 clients as ::ffff:a.b.c.d. The
|
||||
# address that matters is the IPv4 one inside it.
|
||||
if addr.version == 6 and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped
|
||||
return any(addr.version == n.version and addr in n for n in self._nets)
|
||||
|
||||
@property
|
||||
def networks(self):
|
||||
return self._nets
|
||||
|
||||
|
||||
# ── Persistent Hub session store ─────────────────────────────────────────────
|
||||
|
||||
def load_session_store(path: str) -> dict[str, float]:
|
||||
|
||||
@@ -21,7 +21,6 @@ import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -41,6 +40,7 @@ from .config import load_config, load_versions
|
||||
from . import systemctl as sysctl
|
||||
from sovran_nwc import nwc_hub_manager as _nwc_mgr
|
||||
from . import support_ops as _support_ops
|
||||
from .ddns_update import normalise_url as _normalise_ddns_url
|
||||
from .security_helpers import (
|
||||
_nix_escape,
|
||||
NPUB_RE,
|
||||
@@ -55,6 +55,13 @@ from .security_helpers import (
|
||||
_bech32_convertbits_decode,
|
||||
load_session_store,
|
||||
save_session_store,
|
||||
LoginThrottle,
|
||||
LanPolicy,
|
||||
LOGIN_FAIL_DELAY,
|
||||
LOGIN_FAIL_MAX_DELAY,
|
||||
LOGIN_FAIL_WINDOW,
|
||||
LOGIN_FAIL_MAX,
|
||||
LOGIN_LOCKOUT_SECONDS,
|
||||
)
|
||||
from .update_state import effective_update_status
|
||||
|
||||
@@ -175,11 +182,19 @@ _sessions_lock = Lock()
|
||||
_SESSION_PERSIST_MIN_INTERVAL = 30.0 # seconds
|
||||
_sessions_last_persist = 0.0
|
||||
|
||||
# Failed login tracking: ip → list of failure timestamps
|
||||
_login_failures: dict[str, list[float]] = {}
|
||||
LOGIN_FAIL_DELAY = 2.0 # seconds to sleep after a failed attempt
|
||||
LOGIN_FAIL_WINDOW = 60.0 # rolling window (seconds) for counting failures
|
||||
LOGIN_FAIL_MAX = 10 # max failures in window before extra delay
|
||||
# Failed login tracking.
|
||||
#
|
||||
# LOGIN_FAIL_MAX used to be declared here and never read anywhere: the only
|
||||
# thing a failed attempt cost an attacker was a flat 2 second delay, and there
|
||||
# was no lockout, no escalation and no ban. The throttling now lives in
|
||||
# security_helpers.LoginThrottle, which ramps the delay and refuses an address
|
||||
# outright once it has tripped LOGIN_FAIL_MAX inside the window.
|
||||
#
|
||||
# The window moved from 60s to 900s. With the ramping delay, reaching
|
||||
# LOGIN_FAIL_MAX takes about 80 seconds, so a 60 second window expired the
|
||||
# earliest failures before the limit could ever be reached — the old constant
|
||||
# could not have worked even if it had been wired up.
|
||||
_login_throttle = LoginThrottle()
|
||||
|
||||
# Public paths that are accessible without a valid session
|
||||
_AUTH_EXEMPT_PATHS = {"/login", "/api/login", "/auto-login", "/api/ping"}
|
||||
@@ -770,19 +785,20 @@ def _ensure_onboarding_reopened_for_migration() -> None:
|
||||
logger.warning("Could not clear onboarding flag for migration flow: %s", exc)
|
||||
|
||||
|
||||
def _record_failure(client_ip: str) -> None:
|
||||
"""Record a failed login attempt and apply a rate-limit delay.
|
||||
def _record_failure(client_ip: str) -> float:
|
||||
"""Record a failed login attempt and apply the throttling delay.
|
||||
|
||||
Must always be called via loop.run_in_executor() so that the blocking
|
||||
time.sleep() does not stall the asyncio event loop.
|
||||
|
||||
Returns the delay that was applied.
|
||||
"""
|
||||
now = time.time()
|
||||
failures = _login_failures.setdefault(client_ip, [])
|
||||
# Prune old entries outside the window
|
||||
_login_failures[client_ip] = [t for t in failures if now - t < LOGIN_FAIL_WINDOW]
|
||||
_login_failures[client_ip].append(now)
|
||||
# Sleep in the thread-pool thread to slow brute-force without blocking the loop
|
||||
time.sleep(LOGIN_FAIL_DELAY)
|
||||
return _login_throttle.record_failure(client_ip)
|
||||
|
||||
|
||||
def _is_locked_out(client_ip: str) -> bool:
|
||||
"""Return True while *client_ip* is inside a lockout."""
|
||||
return _login_throttle.is_locked_out(client_ip)
|
||||
|
||||
|
||||
# ── Authentication middleware ─────────────────────────────────────
|
||||
@@ -806,8 +822,61 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ── Local-network middleware ───────────────────────────────────
|
||||
#
|
||||
# The Hub runs as root. Whether a packet may reach its port is up to the
|
||||
# firewall and the router; this is the second lock, so a port forward or a
|
||||
# firewall mistake does not put the login page in front of the internet. It
|
||||
# runs before authentication: a client that is not on the local network never
|
||||
# sees the login page at all.
|
||||
#
|
||||
# Built from the Nix-generated config. lan_only defaults to True, so a Hub built
|
||||
# without the key still turns off-network clients away rather than failing open.
|
||||
_hub_cfg = load_config()
|
||||
_lan_policy = LanPolicy(
|
||||
enabled=bool(_hub_cfg.get("lan_only", True)),
|
||||
extra_networks=tuple(_hub_cfg.get("lan_extra_networks") or ()),
|
||||
)
|
||||
|
||||
|
||||
class LanOnlyMiddleware(BaseHTTPMiddleware):
|
||||
"""Refuse clients that are not on this computer or the local network."""
|
||||
|
||||
# Each refused address is logged once, and the list is capped: a scanner
|
||||
# must not be able to fill the journal or the process's memory.
|
||||
_MAX_LOGGED = 256
|
||||
|
||||
def __init__(self, app, policy):
|
||||
super().__init__(app)
|
||||
self._policy = policy
|
||||
self._logged: set = set()
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
client_ip = request.client.host if request.client else None
|
||||
if not self._policy.allows(client_ip):
|
||||
self._note_refusal(client_ip)
|
||||
return JSONResponse(
|
||||
{"detail": "Not available from this network"}, status_code=403,
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
def _note_refusal(self, client_ip):
|
||||
if client_ip in self._logged or len(self._logged) >= self._MAX_LOGGED:
|
||||
return
|
||||
self._logged.add(client_ip)
|
||||
logger.warning(
|
||||
"Refused a Hub request from %r: not this computer or a local "
|
||||
"network. If that address is yours, add its network to "
|
||||
"sovran_systemsOS.hub.extraLanNetworks.",
|
||||
client_ip,
|
||||
)
|
||||
|
||||
|
||||
app.add_middleware(AuthMiddleware)
|
||||
app.add_middleware(NoCacheMiddleware)
|
||||
# Registered last so it runs outermost: a client that is not on the local
|
||||
# network is turned away before authentication is considered at all.
|
||||
app.add_middleware(LanOnlyMiddleware, policy=_lan_policy)
|
||||
|
||||
_ICONS_DIR = os.environ.get(
|
||||
"SOVRAN_HUB_ICONS",
|
||||
@@ -1008,44 +1077,21 @@ def _save_internal_ip(ip: str):
|
||||
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:
|
||||
"""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
|
||||
result in /var/lib/secrets/external-ip, and contacts at most one third
|
||||
party per refresh interval. This function only reads the cache and asks
|
||||
the detector to refresh when it is missing or stale — it performs no
|
||||
per-call external queries of its own.
|
||||
Nothing here looks the address up or contacts anyone. The DDNS update asks
|
||||
Njal.la to use the address the request came from, Njal.la reports it back,
|
||||
and the runner saves it to EXTERNAL_IP_FILE. Returns "unavailable" until
|
||||
the first successful update (and on machines with no DDNS URL, e.g. Desktop).
|
||||
"""
|
||||
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:
|
||||
with open(EXTERNAL_IP_FILE) as f:
|
||||
ip = f.read().strip()
|
||||
if ip:
|
||||
return ip
|
||||
except OSError:
|
||||
pass
|
||||
return "unavailable"
|
||||
ipaddress.ip_address(ip)
|
||||
return ip
|
||||
except (OSError, ValueError):
|
||||
return "unavailable"
|
||||
|
||||
|
||||
# ── Port status helpers (local-only, no external calls) ──────────
|
||||
@@ -2661,10 +2707,24 @@ async def api_login(req: LoginRequest, request: Request):
|
||||
"""Validate the Hub password and issue a session cookie."""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Refuse outright while the address is locked out. This runs before the
|
||||
# scrypt hash, so a locked-out client costs almost nothing to reject.
|
||||
if _is_locked_out(client_ip):
|
||||
remaining = int(_login_throttle.remaining_lockout(client_ip) // 60) + 1
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Too many failed attempts. Try again in about {remaining} minute(s).",
|
||||
)
|
||||
|
||||
ok = await loop.run_in_executor(None, _check_password, req.password)
|
||||
if not ok:
|
||||
await loop.run_in_executor(None, _record_failure, client_ip)
|
||||
raise HTTPException(status_code=401, detail="Incorrect password")
|
||||
|
||||
# A real login clears the address, so fumbling a password once in a while
|
||||
# does not accumulate towards a lockout.
|
||||
_login_throttle.clear(client_ip)
|
||||
token = _create_session()
|
||||
response = JSONResponse({"ok": True})
|
||||
response.set_cookie(
|
||||
@@ -3796,9 +3856,6 @@ async def api_network():
|
||||
# Keep the internal-ip file in sync for credential lookups
|
||||
_save_internal_ip(internal)
|
||||
_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}
|
||||
|
||||
|
||||
@@ -4723,51 +4780,26 @@ def _save_ddns_urls(urls: list[str]) -> 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
|
||||
subprocess for each stored DDNS update URL. No shell interpolation is
|
||||
performed and no user-controlled value is interpreted as shell syntax.
|
||||
Each URL is revalidated through ``_validate_ddns_url()`` after ``${IP}``
|
||||
substitution; URLs that fail validation are silently skipped.
|
||||
The runner (modules/core/njalla.nix -> ddns_update.py) is the only code that
|
||||
talks to Njal.la. It validates every stored URL, calls it with "&auto" so
|
||||
Njal.la uses the address the request came from, and records the address
|
||||
Njal.la reports back for LiveKit and the Hub (EXTERNAL_IP_FILE).
|
||||
|
||||
Called when a domain/DDNS entry is saved and when a DDNS-backed feature
|
||||
is enabled, so DNS is refreshed right away instead of waiting for the
|
||||
15-minute timer tick (see modules/core/njalla.nix).
|
||||
Called when a domain/DDNS entry is saved and when a DDNS-backed feature is
|
||||
enabled, so DNS is refreshed right away instead of waiting for the
|
||||
15-minute timer tick.
|
||||
"""
|
||||
urls = _load_ddns_urls()
|
||||
if not urls:
|
||||
if not _load_ddns_urls():
|
||||
return
|
||||
# Resolve current public IP (best-effort; skip if unavailable)
|
||||
public_ip = ""
|
||||
try:
|
||||
ip_result = subprocess.run(
|
||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
||||
capture_output=True, text=True, timeout=10, check=False,
|
||||
subprocess.run(
|
||||
["systemctl", "start", "--no-block", "sovran-ddns-update.service"],
|
||||
capture_output=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(
|
||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
||||
timeout=20, check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
pass
|
||||
|
||||
|
||||
def _reload_caddy_for_domain_change() -> None:
|
||||
@@ -4906,18 +4938,17 @@ async def api_domains_set(req: DomainSetRequest):
|
||||
# Strip surrounding quotes
|
||||
if len(ddns_url) >= 2 and ddns_url[0] in ('"', "'") and ddns_url[-1] == ddns_url[0]:
|
||||
ddns_url = ddns_url[1:-1]
|
||||
# Replace trailing &auto with the IP placeholder used by _run_njalla_ddns
|
||||
if ddns_url.endswith("&auto"):
|
||||
ddns_url = ddns_url[:-5] + "&a=${IP}"
|
||||
# Keep Njal.la's "&auto": Njal.la then uses the address the request comes
|
||||
# from, so nothing on this machine has to look the address up. Old
|
||||
# "&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.
|
||||
# The placeholder ${IP} is replaced temporarily so the validator sees a
|
||||
# real address; the original URL (with the placeholder) is kept for storage.
|
||||
try:
|
||||
_validate_ddns_url(ddns_url.replace("${IP}", "127.0.0.1"))
|
||||
_validate_ddns_url(ddns_url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid DDNS URL: {exc}")
|
||||
# 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:
|
||||
existing_urls.append(ddns_url)
|
||||
try:
|
||||
@@ -6308,13 +6339,12 @@ async def _background_domain_reachability_checker():
|
||||
consecutive_failures = 0
|
||||
while True:
|
||||
try:
|
||||
# Keep the persisted external IP fresh (dynamic WAN IPs), so
|
||||
# services like LiveKit can read /var/lib/secrets/external-ip.
|
||||
# Pick up the address the Njal.la DDNS runner last recorded (a plain
|
||||
# file read; nothing is looked up from here).
|
||||
loop = asyncio.get_event_loop()
|
||||
external = await loop.run_in_executor(None, _get_external_ip)
|
||||
if external != "unavailable":
|
||||
_cached_external_ip = external
|
||||
_save_external_ip(external)
|
||||
|
||||
cfg = load_config()
|
||||
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>'
|
||||
+ " 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
<h3>Network</h3>
|
||||
<div class="net-row"><span class="net-k">LAN</span><span class="ip-value" id="ip-internal">…</span></div>
|
||||
<div class="net-row"><span class="net-k">WAN</span><span class="ip-value" id="ip-external">…</span></div>
|
||||
<div class="sub">sovransystemsos.local</div>
|
||||
<div class="sub">sovransystemsos.local:8937</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcome-dyn" id="wc-more"></div>
|
||||
@@ -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>
|
||||
</ul>
|
||||
</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">
|
||||
The Hub guides you through every step.
|
||||
</p>
|
||||
@@ -516,7 +520,7 @@
|
||||
<div class="security-reset-password-box" id="security-reset-new-password"> </div>
|
||||
<p class="security-reset-password-warning">
|
||||
✍️ <strong>Write this down now.</strong><br />
|
||||
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>.
|
||||
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local:8937</em>.
|
||||
</p>
|
||||
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
|
||||
I have written down my new password — Restart Entire System
|
||||
|
||||
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"; };
|
||||
|
||||
# ── 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 = {
|
||||
enable = true;
|
||||
authentication = lib.mkForce ''
|
||||
@@ -172,6 +180,34 @@
|
||||
host all all 127.0.0.1/32 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 ────────────────────────────────────────────────
|
||||
|
||||
Generated
+29
-46
@@ -5,11 +5,11 @@
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1788871704,
|
||||
"narHash": "sha256-Vtc2SqCB7NOO028+K5JHbpWQ3XVpLyoC75MZQ12l13o=",
|
||||
"lastModified": 1790862821,
|
||||
"narHash": "sha256-IcLn2hPlxsbn2PKVlFD6gsDzkVurQ7b0KPtyORUGFcs=",
|
||||
"owner": "emmanuelrosa",
|
||||
"repo": "btc-clients-nix",
|
||||
"rev": "e14502cba22806341f8c54c6f0c349094830c58b",
|
||||
"rev": "99b0442dcc15198efcb62f3c1e7561a361749a6a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -26,11 +26,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1787559586,
|
||||
"narHash": "sha256-onL0VLf9vPllmT0H/OlURIU5r5t5WIEl7t4tVNKT0Nw=",
|
||||
"lastModified": 1788450739,
|
||||
"narHash": "sha256-glZLQlzIn1fXH6PazR2iUmTo7kzzyYSshrWhLS9TqCU=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "9d0d87172c374f89da73c1cfe6d81ae62feac1f1",
|
||||
"rev": "31729ca8cbdb4fa927b34e5f4353e6a83f39e993",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -41,11 +41,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1788179970,
|
||||
"narHash": "sha256-r5LmxzIhsu5+oDybatN/HJ8roYOKjb2Apa5xI6v46VU=",
|
||||
"owner": "NixOS",
|
||||
"lastModified": 1790861130,
|
||||
"narHash": "sha256-cA8TrQntLbNO14wivJx7Gi2pZBhhBcMplgzIA3sk3TA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1db62ab7d2ccf1916bbf7deb61fc9d16f1c4ab49",
|
||||
"rev": "3d23ea05a8a3be3f078845c2753af10cef80e1a5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -56,27 +56,11 @@
|
||||
},
|
||||
"nixpkgs-stable": {
|
||||
"locked": {
|
||||
"lastModified": 1788921488,
|
||||
"narHash": "sha256-8+xWRxEkD6l217cIUdRxfeUGS9lQX0hVtUuNVsBaDzk=",
|
||||
"lastModified": 1790750587,
|
||||
"narHash": "sha256-VfjaoJ1Uyb7JZTrBgE5Jf2nhjtQPmD9KOd19HJwmZwM=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6aefcda9401be8acc2b74244fb3b37520ea1f0a8",
|
||||
"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",
|
||||
"rev": "78e9c786dc08cd4f3420c2395cd977206a9b1da2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -88,11 +72,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1788881743,
|
||||
"narHash": "sha256-2V9GZGvPfrNzxFozhI9dcqV+c3QdA8YZrvAAzqEB+dI=",
|
||||
"lastModified": 1790822859,
|
||||
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d6524aaca2ff07876657ae2b323f24be4874944b",
|
||||
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -104,11 +88,11 @@
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1787631388,
|
||||
"narHash": "sha256-vMiXptXarfSdJb1Gkc+FYVOAibuBRj7qxGa8z68q1Uw=",
|
||||
"lastModified": 1789724158,
|
||||
"narHash": "sha256-nlKgrm0dsVhOSopKheVBCcOpiIticufPPVLdVOI0euA=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ac6b2166e7a9375683b8e98f860f273222337b16",
|
||||
"rev": "0a3468a402c449992505b6a9fc5b06580141b750",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -120,11 +104,11 @@
|
||||
},
|
||||
"nixpkgs_4": {
|
||||
"locked": {
|
||||
"lastModified": 1788881743,
|
||||
"narHash": "sha256-2V9GZGvPfrNzxFozhI9dcqV+c3QdA8YZrvAAzqEB+dI=",
|
||||
"lastModified": 1790822859,
|
||||
"narHash": "sha256-69xHQhAeMAD2wDXO7T2pcOZIF9Sga2W+JkmY2a11Ops=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d6524aaca2ff07876657ae2b323f24be4874944b",
|
||||
"rev": "c59305bab2065cfecc4944690d9eedbb56f3a9fa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -141,11 +125,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1788190018,
|
||||
"narHash": "sha256-59BAfH0txPAZrPBF4QJqwvUWppD+ICrcjA1LZAmPnrQ=",
|
||||
"lastModified": 1790541651,
|
||||
"narHash": "sha256-/496IQz8qNWHHLnc3Zvr0l4uxJ34igNhJhn7ZKAefFk=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixvim",
|
||||
"rev": "41844750e55f17b1385d5b09ca7ade5f11f49506",
|
||||
"rev": "5980a626794486abad69fca7667f9c11dfbc3bd7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -165,15 +149,14 @@
|
||||
},
|
||||
"sovran-bitcoin": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs_4",
|
||||
"nixpkgs-stable": "nixpkgs-stable_2"
|
||||
"nixpkgs": "nixpkgs_4"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1788921077,
|
||||
"narHash": "sha256-k2i55M0lK3xzjsJKS/kOhQHSf8479r6RYEllnICxggg=",
|
||||
"lastModified": 1790877969,
|
||||
"narHash": "sha256-Ia88keP9t3B6ECVw+LP4xasPdvzw1TIWya1K+3dySu0=",
|
||||
"owner": "naturallaw777",
|
||||
"repo": "Sovran_Bitcoin",
|
||||
"rev": "7c4d5b509c0533ea48192146f987182668bf42dc",
|
||||
"rev": "b0da63bd81f1a1b1a970068b3061c6c8389db9aa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
nixvim.url = "github:nix-community/nixvim";
|
||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||
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";
|
||||
};
|
||||
|
||||
@@ -19,24 +17,6 @@
|
||||
system = prev.stdenv.hostPlatform.system;
|
||||
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
|
||||
{
|
||||
@@ -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():
|
||||
words = [secrets.choice(DICEWARE_WORDS) for _ in range(3)]
|
||||
digit = secrets.randbelow(10)
|
||||
return "-".join(words) + f"-{digit}"
|
||||
# 4 words from a 96 word list plus 2 digits: 96^4 x 100 = ~8.5e9, about
|
||||
# 33 bits. The old 3 words plus 1 digit was 96^3 x 10 = ~8.8e6, about 23
|
||||
# bits, for a password that is simultaneously the desktop login, the
|
||||
# 'free' account password and the only thing in front of a Hub that runs
|
||||
# as root and hands out every stored credential.
|
||||
words = [secrets.choice(DICEWARE_WORDS) for _ in range(4)]
|
||||
digits = f"{secrets.randbelow(100):02d}"
|
||||
return "-".join(words) + f"-{digits}"
|
||||
|
||||
try:
|
||||
logfile = open(LOG, "a")
|
||||
@@ -471,7 +476,7 @@ class InstallerWindow(Adw.ApplicationWindow):
|
||||
# Role cards
|
||||
roles = [
|
||||
("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"),
|
||||
("Desktop Only",
|
||||
"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."nwc-wallets"
|
||||
|| config.sovran_systemsOS.features.element-calling;
|
||||
|
||||
# RTL and Mempool listen on loopback only: Sovran_Bitcoin binds them to
|
||||
# 127.0.0.1, and RTL's unit is sandboxed to loopback besides. Caddy is how
|
||||
# the local network reaches them (:3051 and :60847), so it has to run
|
||||
# wherever they do. That includes Bitcoin Node Only, which has no
|
||||
# domain-based service and so no other reason to run Caddy.
|
||||
#
|
||||
# The Hub is not one of these. It listens on 0.0.0.0:8937 itself, so it is
|
||||
# served on its own port rather than through Caddy: the one service that
|
||||
# runs as root has nothing in front of it that it does not need, and the
|
||||
# public sites on ports 80/443 cannot be asked for it by Host header.
|
||||
servesRtl = config.sovran_systemsOS.services.bitcoin;
|
||||
servesMempool = servesRtl && config.sovran_systemsOS.features.mempool;
|
||||
|
||||
caddyEnabled = needsHttpsPorts || extraVhosts != "" || servesRtl;
|
||||
|
||||
# Sites for the local network, one per loopback-only service. Written after
|
||||
# the public domain sites; each exists only where its service does.
|
||||
#
|
||||
# They do not filter by client address. A request can only reach them on
|
||||
# their own ports, which no setup step asks you to forward, so forwarding
|
||||
# 80/443 for public services does not expose them (a Host header on those
|
||||
# ports cannot select a site that listens elsewhere). RTL has its own
|
||||
# password and lockout, and Mempool shows public chain data. An address
|
||||
# check here could not be made right for IPv6 anyway: a laptop's global
|
||||
# address on the LAN looks exactly like a stranger's.
|
||||
bitcoinUiSites =
|
||||
lib.optionalString servesRtl ''
|
||||
|
||||
:3051 {
|
||||
reverse_proxy :3050
|
||||
encode gzip zstd
|
||||
}
|
||||
''
|
||||
+ lib.optionalString servesMempool ''
|
||||
|
||||
:60847 {
|
||||
reverse_proxy :60845
|
||||
encode gzip zstd
|
||||
}
|
||||
'';
|
||||
in
|
||||
{
|
||||
services.caddy = {
|
||||
# Only enable Caddy when at least one domain-based service needs it or
|
||||
# the operator has defined custom vhosts. This prevents Caddy from
|
||||
# running on Desktop Only installs that have no web services configured.
|
||||
enable = needsHttpsPorts || extraVhosts != "";
|
||||
# Caddy runs when a domain-based service needs it, when the operator has
|
||||
# defined custom vhosts, or when it is the way to reach RTL and Mempool.
|
||||
# Desktop Only has none of those, so Caddy stays off there.
|
||||
enable = caddyEnabled;
|
||||
user = "caddy";
|
||||
group = "root";
|
||||
};
|
||||
@@ -202,37 +243,11 @@ $LIGHTNING {
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── Sovran Hub (LAN access via mDNS) ────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
http://sovransystemsos.local {
|
||||
reverse_proxy localhost:8937
|
||||
header {
|
||||
Clear-Site-Data "\"cache\""
|
||||
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
|
||||
Pragma "no-cache"
|
||||
Expires "0"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── RTL (LAN access) ────────────────────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
:3051 {
|
||||
reverse_proxy :3050
|
||||
encode gzip zstd
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── Mempool (LAN access) ────────────────────────
|
||||
cat >> /run/caddy/Caddyfile <<EOF
|
||||
|
||||
:60847 {
|
||||
reverse_proxy :60845
|
||||
encode gzip zstd
|
||||
}
|
||||
EOF
|
||||
# ── RTL and Mempool (local network) ─────────────
|
||||
# Only where those services run; see bitcoinUiSites above.
|
||||
cat >> /run/caddy/Caddyfile <<'LAN_SITES_EOF'
|
||||
${bitcoinUiSites}
|
||||
LAN_SITES_EOF
|
||||
|
||||
# ── Custom vhosts from custom.nix ──────────────
|
||||
cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF'
|
||||
|
||||
+33
-89
@@ -1,43 +1,64 @@
|
||||
{ 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 ────────────────────────
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/njalla 0750 root root -"
|
||||
];
|
||||
|
||||
# ── Install the shared validation helper so the DDNS runner can import it ─
|
||||
# The exact same _validate_ddns_url() function used by the Hub web application
|
||||
# is installed here as a read-only system file. The DDNS runner imports it
|
||||
# directly so the two code paths share one validator — no weaker inline copy.
|
||||
# ── Install the DDNS runner and the validator it shares with the Hub ─────
|
||||
# Both files come straight from the Hub's source tree and are installed side
|
||||
# by side as read-only system files. The runner imports the exact same
|
||||
# _validate_ddns_url() the Hub API uses — no weaker inline copy.
|
||||
environment.etc."sovran/security_helpers.py" = {
|
||||
source = ../../app/sovran_systemsos_web/security_helpers.py;
|
||||
mode = "0444";
|
||||
user = "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 ─────────────────────────────────────────────
|
||||
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||
# invokes curl directly — no shell interpolation, no script execution.
|
||||
# Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh.
|
||||
# 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 = {
|
||||
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
||||
wants = [ "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 = {
|
||||
Type = "oneshot";
|
||||
User = "root";
|
||||
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
|
||||
# Harden the service — it only needs network access and read access to
|
||||
# /var/lib/njalla/ddns_urls.json.
|
||||
ExecStart = "${pkgs.python3}/bin/python3 /etc/sovran/ddns-update.py";
|
||||
# Harden the service — it needs network access, the URL store, and the
|
||||
# file that receives the reported address.
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ReadWritePaths = [ "/var/lib/njalla" "/var/lib/secrets" ];
|
||||
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||
ProtectHome = 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 script is owned by root and not world-writable.
|
||||
# Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same
|
||||
# production validator used by the Hub API — before executing any curl call.
|
||||
# No shell is used; no redirects; no script execution.
|
||||
# ${IP} placeholder is preserved in stored URLs and substituted at runtime;
|
||||
# the URL is validated after substitution so any remaining $ is rejected.
|
||||
# The runner used to be written to /var/lib/sovran by this activation script,
|
||||
# next to the old public-ip.py detector. Remove those stale copies.
|
||||
system.activationScripts.sovran-ddns-update-script = ''
|
||||
install -d -m 0755 /var/lib/sovran
|
||||
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
|
||||
#!/usr/bin/env python3
|
||||
"""Sovran safe DDNS update runner.
|
||||
|
||||
Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder,
|
||||
validates each URL using the production _validate_ddns_url() from
|
||||
/etc/sovran/security_helpers.py, then calls curl per URL.
|
||||
No shell interpolation. No redirects. No script execution.
|
||||
"""
|
||||
import ipaddress, json, os, subprocess, sys
|
||||
|
||||
sys.path.insert(0, '/etc/sovran')
|
||||
try:
|
||||
from security_helpers import _validate_ddns_url
|
||||
except ImportError:
|
||||
sys.exit(1) # validator missing — fail so systemd logs the misconfiguration
|
||||
|
||||
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
||||
|
||||
try:
|
||||
with open(URLS_FILE) as f:
|
||||
urls = json.load(f)
|
||||
if not isinstance(urls, list):
|
||||
raise ValueError("not a list")
|
||||
except Exception:
|
||||
sys.exit(0) # no URLs configured — nothing to do
|
||||
|
||||
# Resolve current public IP 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
|
||||
rm -f /var/lib/sovran/ddns-update.py /var/lib/sovran/public-ip.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";
|
||||
};
|
||||
|
||||
# ── Hub ───────────────────────────────────────────────────
|
||||
hub = {
|
||||
lanOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Refuse Hub requests from clients that are not on this computer or on
|
||||
the local network: loopback, private (10.0.0.0/8, 172.16.0.0/12,
|
||||
192.168.0.0/16), VPN/CGNAT (100.64.0.0/10) and link-local addresses,
|
||||
plus anything listed in sovran_systemsOS.hub.extraLanNetworks.
|
||||
|
||||
The Hub runs as root and can display stored credentials and reboot
|
||||
the machine. Whether a packet may reach its port is up to the
|
||||
firewall and your router; this check is the second lock, so that a
|
||||
port forward or a firewall mistake does not put the Hub's login page
|
||||
in front of the internet.
|
||||
|
||||
Set it to false only if this computer sits on a network that hands
|
||||
out public addresses to your own devices and you would rather not
|
||||
list them.
|
||||
'';
|
||||
};
|
||||
|
||||
directPort = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = !config.sovran_systemsOS.roles.desktop;
|
||||
defaultText = lib.literalExpression "!config.sovran_systemsOS.roles.desktop";
|
||||
description = ''
|
||||
Open port 8937 on the firewall, so that other devices on the local
|
||||
network can reach the Hub at http://sovransystemsos.local:8937.
|
||||
|
||||
On by default for Server + Desktop and Bitcoin Node Only. Off on
|
||||
Desktop Only, the role most likely to be used away from home: there
|
||||
nothing is published, and the Hub is reachable only from this
|
||||
computer, through the desktop application window on localhost. Set it
|
||||
to true in custom.nix if you do want to reach a Desktop Only Hub from
|
||||
another device.
|
||||
|
||||
The Hub runs as root, so it checks every client itself (see
|
||||
sovran_systemsOS.hub.lanOnly); the firewall opening only decides
|
||||
whether a packet may reach it at all.
|
||||
'';
|
||||
};
|
||||
|
||||
extraLanNetworks = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "203.0.113.0/28" ];
|
||||
description = ''
|
||||
Extra networks, in CIDR notation, that the Hub should treat as local
|
||||
in addition to the built-in ranges. Needed only if devices on your
|
||||
local network use addresses outside the private ranges, for example a
|
||||
public IPv4 block your provider routes onto your LAN.
|
||||
|
||||
Keep each entry as narrow as you can: every address inside it is let
|
||||
through. To let everything through, set sovran_systemsOS.hub.lanOnly
|
||||
to false instead; 0.0.0.0/0 and ::/0 are not accepted here.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
# ── Web exposure (controls Caddy vhosts) ──────────────────
|
||||
web = {
|
||||
btcpayserver = lib.mkOption {
|
||||
@@ -107,9 +168,11 @@
|
||||
description = ''
|
||||
Optional pin: force LiveKit to advertise this public IPv4 in its
|
||||
host/TURN ICE candidates. Not required in normal operation — the
|
||||
module auto-detects the public IP at runtime (HTTPS egress
|
||||
detection, falling back to STUN). Set it only to override a
|
||||
mis-detected address (e.g. multi-WAN/VPN setups).
|
||||
address is the one Njal.la reports for the DDNS update (set up in
|
||||
the Hub's Domains page), and nothing on this system looks it up
|
||||
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 "server_plus_desktop";
|
||||
|
||||
# IPv4 a.b.c.d[/0-32] or IPv6 [/0-128], and never a /0 (that is "everyone").
|
||||
octet = "(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])";
|
||||
lanNetworkOk = p:
|
||||
(
|
||||
builtins.match "${octet}(\\.${octet}){3}(/(3[0-2]|[12]?[0-9]))?" p != null
|
||||
|| builtins.match "[0-9a-fA-F:]*:[0-9a-fA-F:]*(/(12[0-8]|1[01][0-9]|[1-9]?[0-9]))?" p != null
|
||||
)
|
||||
&& builtins.match ".*/0" p == null;
|
||||
|
||||
generatedConfig = pkgs.writeText "sovran-hub-config.json"
|
||||
(builtins.toJSON {
|
||||
refresh_interval = 5;
|
||||
@@ -122,6 +131,9 @@ let
|
||||
role = activeRole;
|
||||
services = monitoredServices;
|
||||
feature_manager = true;
|
||||
# Read by LanOnlyMiddleware in server.py.
|
||||
lan_only = cfg.hub.lanOnly;
|
||||
lan_extra_networks = cfg.hub.extraLanNetworks;
|
||||
feature_states = {
|
||||
bitcoin-tor-gossip = cfg.features.bitcoin-tor-gossip;
|
||||
};
|
||||
@@ -475,6 +487,12 @@ os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "i
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"sovran_systemsos_web.server:app",
|
||||
# IPv4 only, on purpose. The desktop launcher uses "localhost", which
|
||||
# falls back to 127.0.0.1, and other devices reach the Hub over IPv4 too.
|
||||
# An IPv6 listener would admit clients whose global addresses the Hub's own
|
||||
# check cannot tell from a stranger's (see LanPolicy). Which devices may
|
||||
# connect is up to the firewall (hub.directPort) and that check
|
||||
# (hub.lanOnly), not this bind.
|
||||
host="0.0.0.0",
|
||||
port=8937,
|
||||
log_level="info",
|
||||
@@ -503,6 +521,22 @@ in
|
||||
};
|
||||
|
||||
config = {
|
||||
# Catch a typo'd network at build time. The Hub ignores an entry it cannot
|
||||
# parse (it must never widen its policy by guessing), so without this the
|
||||
# only symptom would be a client that is refused for no visible reason.
|
||||
assertions = [
|
||||
{
|
||||
assertion = builtins.all lanNetworkOk cfg.hub.extraLanNetworks;
|
||||
message = ''
|
||||
sovran_systemsOS.hub.extraLanNetworks must be a list of IPv4 or IPv6
|
||||
networks in CIDR notation, for example [ "203.0.113.0/28" ]. A /0
|
||||
prefix is not accepted; set sovran_systemsOS.hub.lanOnly = false to
|
||||
let every client through. Got:
|
||||
${builtins.toJSON cfg.hub.extraLanNetworks}
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
systemd.services.sovran-hub-web = {
|
||||
description = "Sovran_SystemsOS Hub Web Interface";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
@@ -571,7 +605,14 @@ in
|
||||
|
||||
environment.systemPackages = [ sovran-hub-web ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 8937 60847 ];
|
||||
# The Hub is served on its own port, not through Caddy (see caddy.nix).
|
||||
# Nothing here filters by client address: that is the Hub's own check
|
||||
# (sovran_systemsOS.hub.lanOnly), and which networks can route to this
|
||||
# computer at all is the router's call.
|
||||
# 60847 is where Caddy serves Mempool, so it is open only when Mempool is.
|
||||
networking.firewall.allowedTCPPorts =
|
||||
lib.optionals cfg.hub.directPort [ 8937 ]
|
||||
++ lib.optionals (cfg.services.bitcoin && cfg.features.mempool) [ 60847 ];
|
||||
|
||||
# ── Auto-launch Hub in browser on login ───────────────────────
|
||||
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
# sshd listens on 127.0.0.1 only here, so there is nothing for the firewall
|
||||
# to let in. NixOS opens sshd's ports by default (openFirewall = true)
|
||||
# whether or not sshd listens on them, which left port 22 open on every
|
||||
# role, Desktop Only included. The roles that do publish SSH open it
|
||||
# themselves: the sshd feature (sshd.nix) and remote deploy
|
||||
# (remote-deploy.nix) both add 22 explicitly.
|
||||
openFirewall = lib.mkDefault false;
|
||||
listenAddresses = lib.mkDefault [
|
||||
{ addr = "127.0.0.1"; port = 22; }
|
||||
];
|
||||
|
||||
+11
-8
@@ -91,7 +91,7 @@ in
|
||||
SECRET_FILE="/var/lib/secrets/root-password"
|
||||
if [ ! -f "$SECRET_FILE" ]; then
|
||||
mkdir -p /var/lib/secrets
|
||||
# Generate a diceware-style passphrase: word-word-word-N
|
||||
# Generate a diceware-style passphrase: word-word-word-word-NN
|
||||
WORDS="apple barn brook cabin cedar cloud coral crane delta eagle ember \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
@@ -106,8 +106,9 @@ in
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
ROOT_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$(printf '%02d' $((RANDOM % 100)))
|
||||
ROOT_PASS="$W1-$W2-$W3-$W4-$DIGIT"
|
||||
echo "$ROOT_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
fi
|
||||
@@ -170,7 +171,7 @@ in
|
||||
fi
|
||||
|
||||
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 \
|
||||
fern field flame flora flint frost grove haven hedge holly heron \
|
||||
jade juniper kelp larch lemon lilac linden loch lotus maple marsh \
|
||||
@@ -185,8 +186,9 @@ in
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$(printf '%02d' $((RANDOM % 100)))
|
||||
FREE_PASS="$W1-$W2-$W3-$W4-$DIGIT"
|
||||
echo "$FREE_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
echo "free:$FREE_PASS" | chpasswd
|
||||
@@ -229,8 +231,9 @@ in
|
||||
W1=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W2=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
W3=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$((RANDOM % 10))
|
||||
FREE_PASS="$W1-$W2-$W3-$DIGIT"
|
||||
W4=''${WORD_ARRAY[$((RANDOM % COUNT))]}
|
||||
DIGIT=$(printf '%02d' $((RANDOM % 100)))
|
||||
FREE_PASS="$W1-$W2-$W3-$W4-$DIGIT"
|
||||
|
||||
printf '%s\n' "$FREE_PASS" > "$SECRET_FILE"
|
||||
chmod 600 "$SECRET_FILE"
|
||||
|
||||
+43
-39
@@ -204,35 +204,37 @@ EOF
|
||||
# NAT with port-forwarding. It does not need to be assigned to this box,
|
||||
# and it may be dynamic.
|
||||
#
|
||||
# Reuse the shared detector (/var/lib/sovran/public-ip.py — see
|
||||
# modules/core/public-ip.nix) instead of running our own: one script,
|
||||
# one cache, privacy-first (STUN -> DNS -> opt-in HTTPS echo). Priority:
|
||||
# Nothing here looks the address up. Priority:
|
||||
# 1. sovran_systemsOS.elementCalling.externalIP (explicit pin, if set)
|
||||
# 2. /var/lib/secrets/external-ip (the shared cache)
|
||||
# 3. run the detector now (it refreshes the cache)
|
||||
# 4. STUN auto-detection (use_external_ip) as the fallback, with a
|
||||
# warning — this is where broken installs used to silently end up
|
||||
# advertising a private IP, causing "call connects but no video".
|
||||
# 2. /var/lib/secrets/external-ip — the address Njal.la reported for the
|
||||
# last DDNS update (modules/core/njalla.nix). The runner rewrites that
|
||||
# file only when the address changes, and livekit-external-ip.path
|
||||
# then re-runs this script.
|
||||
# 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 ""}'
|
||||
|
||||
PUBLIC_IP="$EXTERNAL_IP"
|
||||
if [ -z "$PUBLIC_IP" ] && [ -f /var/lib/secrets/external-ip ]; then
|
||||
PUBLIC_IP=$(tr -d '[:space:]' < /var/lib/secrets/external-ip 2>/dev/null)
|
||||
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
|
||||
|
||||
# Reject non-routable addresses (loopback, private, link-local, CGNAT).
|
||||
# A detected/pinned address like this must never be advertised.
|
||||
if [ -n "$PUBLIC_IP" ] && printf '%s' "$PUBLIC_IP" | grep -qE \
|
||||
'^(0\.|127\.|10\.|100\.64\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)'; then
|
||||
echo "WARNING: external IP '$PUBLIC_IP' is not routable; falling back to STUN auto-detection." >&2
|
||||
PUBLIC_IP=""
|
||||
if 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
|
||||
echo "ERROR: $PUBLIC_IP is not a public address, so remote peers cannot reach LiveKit there." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$PUBLIC_IP" ]; then
|
||||
cat > /run/livekit/livekit.yaml <<EOF
|
||||
cat > /run/livekit/livekit.yaml <<EOF
|
||||
port: 7880
|
||||
rtc:
|
||||
use_external_ip: false
|
||||
@@ -244,22 +246,7 @@ rtc:
|
||||
includes:
|
||||
- $IFACE
|
||||
EOF
|
||||
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
|
||||
echo "LiveKit will advertise public IP: $PUBLIC_IP"
|
||||
|
||||
# Webhooks → lk-jwt-service. The JWT service validates the HMAC
|
||||
# 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
|
||||
# configs (new domains, externalIP, full-access list), mirroring the domain
|
||||
# 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.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 #######
|
||||
# Diagnostic only — never a hard dependency of livekit/caddy. Catches the
|
||||
# classic "call connects but no media" setup errors at boot instead of at
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
./core/no-sleep.nix
|
||||
./core/cpu-performance.nix
|
||||
./core/local-domain-loopback.nix
|
||||
./core/public-ip.nix
|
||||
|
||||
# ── Always on (no flag) ───────────────────────────────────
|
||||
./php.nix
|
||||
|
||||
+112
-3
@@ -3,10 +3,22 @@
|
||||
lib.mkIf config.sovran_systemsOS.services.nextcloud {
|
||||
|
||||
# ── PostgreSQL database ───────────────────────────────────
|
||||
# Cluster-wide tuning (shared_buffers, autovacuum) lives in
|
||||
# configuration.nix so it is shared with Matrix Synapse.
|
||||
services.postgresql = {
|
||||
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 ──────────────
|
||||
systemd.services.nextcloud-db-init = {
|
||||
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
|
||||
psql -U postgres -c "CREATE DATABASE nextclouddb WITH OWNER ncusr TEMPLATE template0 LC_COLLATE = 'C' LC_CTYPE = 'C';"
|
||||
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 ───────────────────────
|
||||
systemd.services.nextcloud-init = {
|
||||
description = "Download, extract, and fully configure Nextcloud";
|
||||
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" "postgresql.service" "phpfpm-nextcloud.service" "nextcloud-db-init.service" "redis-nextcloud.service" ];
|
||||
wants = [ "network-online.target" "redis-nextcloud.service" ];
|
||||
requires = [ "postgresql.service" "nextcloud-db-init.service" ];
|
||||
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 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.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 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 = [
|
||||
"*/5 * * * * caddy /run/current-system/sw/bin/php -f /var/lib/www/nextcloud/cron.php"
|
||||
];
|
||||
|
||||
+56
-14
@@ -1,29 +1,70 @@
|
||||
{ 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
|
||||
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 = ''
|
||||
; ── Error handling (production) ─────────────────────────────────
|
||||
display_errors = Off
|
||||
display_startup_errors = Off
|
||||
log_errors = On
|
||||
|
||||
display_errors = On
|
||||
display_startup_errors = On
|
||||
; ── Limits ──────────────────────────────────────────────────────
|
||||
max_execution_time = 10000
|
||||
max_input_time = 3000
|
||||
memory_limit = 1G;
|
||||
opcache.enable=1;
|
||||
opcache.memory_consumption=512;
|
||||
opcache_revalidate_freq = 240;
|
||||
opcache.max_accelerated_files=20000;
|
||||
memory_limit = 1G
|
||||
post_max_size = 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
|
||||
@@ -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.allowedUDPPorts = lib.mkIf cfg.services.bitcoin [ 3051 ];
|
||||
|
||||
# ── 6. NWC / LNURL — Sovran Hub integration ───────────────────
|
||||
# Sovran_Bitcoin's albyhub.nix and lnurl.nix handle the base services.
|
||||
|
||||
@@ -94,16 +94,23 @@ EOF
|
||||
# ── Synapse service ─────────────────────────────────────────
|
||||
services.matrix-synapse = {
|
||||
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 = [
|
||||
"/run/matrix-synapse/runtime-config.yaml"
|
||||
];
|
||||
settings = {
|
||||
database = {
|
||||
name = "psycopg2";
|
||||
# Recycle pooled connections less often (fewer reconnects).
|
||||
txn_limit = 10000;
|
||||
args = {
|
||||
host = "localhost";
|
||||
database = "matrix-synapse";
|
||||
user = "matrix-synapse";
|
||||
cp_min = 5;
|
||||
cp_max = 15;
|
||||
};
|
||||
};
|
||||
push.include_content = false;
|
||||
@@ -120,6 +127,32 @@ EOF
|
||||
];
|
||||
presence.enabled = true;
|
||||
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 = [
|
||||
{
|
||||
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