hub: serve the Hub on its own port instead of through Caddy

Caddy fronted the Hub at http://sovransystemsos.local, but the Hub
already listens on 0.0.0.0:8937 itself, and nothing Caddy added is
something it needs:

- Not the name. That is avahi's: mDNS advertises a hostname, not a port,
  so the name resolves wherever the Hub listens.
- Not TLS (the site was plain http), not authentication, not cache
  headers. The header block duplicated NoCacheMiddleware, and its
  Clear-Site-Data ("cache") overrode the app's stronger ("cache",
  "storage").
- Not access control, and this is the point. With ports 80/443 forwarded
  for public services, a Host header on those ports reached the Hub. That
  second door is how the reported bug happened, and c33457f guards it
  with an address check instead of closing it.

The Hub is now served on port 8937 only, at
http://sovransystemsos.local:8937, and Caddy has no site for it. The
only thing Caddy answers on 80/443 is the public sites. Caddy keeps
Ride The Lightning (:3051) and Mempool (:60847), because those do need
it: Sovran_Bitcoin binds both to 127.0.0.1 and RTL's unit is sandboxed to
loopback besides, so Caddy is how the local network reaches them.

- caddy.nix: no Hub site. Caddy runs wherever RTL and Mempool do, which
  includes Bitcoin Node Only. There it did not run at all (enable was
  needsHttpsPorts || extraVhosts != ""), so :3051 and :60847 were open
  in the firewall with nothing listening. Ports 80/443 still follow
  needsHttpsPorts alone, so Node Only does not open them. The two sites
  are written only where their service exists; they were unconditional.
- sovran-hub.nix: 8937 follows the new hub.directPort, 60847 follows
  Mempool. It used to be `[ 8937 60847 ]` on every role, Desktop Only
  included.
- roles.nix: hub.directPort defaults to !roles.desktop: open on Server +
  Desktop and Bitcoin Node Only, closed on Desktop Only, where the Hub is
  reached from the machine itself through the desktop window on
  localhost.
- The bind stays 0.0.0.0, which is IPv4 only: with that bind [::1]:8937
  is refused and "localhost" falls back to 127.0.0.1. That is on purpose
  and is now said in the comment. An IPv6 listener would let in clients
  whose global addresses the Hub cannot tell from a stranger's, which is
  the question the previous commit declines to answer by guessing.
- README, SECURITY.md and two strings in index.html give the new URL.

Behaviour changes: the Hub's address gains :8937, and http://sovransystemsos.local
on port 80 no longer reaches it. Bitcoin Node Only now runs Caddy.

Evaluated with nix eval (nixpkgs as flake.lock pins it, Sovran_Bitcoin at
the locked revision), firewall TCP ports per role:

                      c33457f                      this commit
  Server + Desktop    22 80 443 3051 8937 60847    22 80 443 3051 8937
  Bitcoin Node Only   22 3051 8937 60847           22 3051 8937 60847   (Caddy now runs)
  Desktop Only        22 8937 60847                22

Port 22 is open on every role although sshd listens on loopback only;
the last commit of this series deals with that.

The Caddyfile the module really generates (the evaluated generator
script, run, then `caddy validate` with Caddy 2.9.1): Node Only gets the
two sites and nothing else; Server + Desktop with every domain
configured gets the seven domain sites plus :3051 and :60847 and no
mention of the Hub; with Bitcoin off there are no local-network sites;
Node Only with Bitcoin off and no domains leaves Caddy off.

Add tests/test_hub_direct.py and keep tests/test_caddy_lan_only.py for
the two sites it still covers.
This commit is contained in:
Security Fix
2026-10-02 02:24:29 -05:00
committed by naturallaw777
parent 361b25a8bd
commit 78bfc5b408
8 changed files with 287 additions and 68 deletions
+13 -10
View File
@@ -316,8 +316,8 @@ the tools of your selected mode already in place.
Prefer to keep using Windows, macOS, Linux, Android, or iOS? Install Prefer to keep using Windows, macOS, Linux, Android, or iOS? Install
Sovran_SystemsOS on a separate computer and let it run quietly on your local Sovran_SystemsOS on a separate computer and let it run quietly on your local
network, with or without a monitor. From any other device on the same network, network, with or without a monitor. From any other device on the same network,
open a browser, visit `http://sovransystemsos.local`, and manage everything open a browser, visit `http://sovransystemsos.local:8937`, and manage
from [The Sovran Hub](#the-sovran-hub). everything from [The Sovran Hub](#the-sovran-hub).
Your existing devices stay familiar. Sovran_SystemsOS provides the independent Your existing devices stay familiar. Sovran_SystemsOS provides the independent
infrastructure behind them. infrastructure behind them.
@@ -354,7 +354,7 @@ From one place, the Hub helps you:
│ │ │ │ │ │
Windows laptop Phone or tablet Mac or Linux Windows laptop Phone or tablet Mac or Linux
│ │ │ │ │ │
└──────── Browser: sovransystemsos.local ────┘ └─────── Browser: sovransystemsos.local:8937 ─┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
@@ -376,9 +376,10 @@ Keep using the devices you already own. Sovran_SystemsOS becomes the private
Bitcoin and digital infrastructure behind them. Bitcoin and digital infrastructure behind them.
> **Local access:** the Hub is available at > **Local access:** the Hub is available at
> `http://sovransystemsos.local` to devices connected to the same local > `http://sovransystemsos.local:8937` to devices connected to the same local
> network. It is protected by authentication and is not automatically exposed > network (not on Desktop, which publishes nothing). It is protected by
> to the public internet. > authentication, answers only your local network, and is not automatically
> exposed to the public internet.
--- ---
@@ -536,18 +537,20 @@ Open the Hub directly from the Sovran_SystemsOS desktop, or from any other
device on the same local network at: device on the same local network at:
```text ```text
http://sovransystemsos.local http://sovransystemsos.local:8937
``` ```
Sign in with your Sovran_SystemsOS credentials. Sign in with your Sovran_SystemsOS credentials. Desktop does not publish the Hub
on the network, so in that mode open it from the desktop.
<details> <details>
<summary><strong>If sovransystemsos.local does not open</strong></summary> <summary><strong>If sovransystemsos.local:8937 does not open</strong></summary>
1. Make sure the Sovran_SystemsOS machine is powered on, and allow it a few 1. Make sure the Sovran_SystemsOS machine is powered on, and allow it a few
minutes to finish starting. minutes to finish starting.
2. Make sure both devices are connected to the same local network, and that 2. Make sure both devices are connected to the same local network, and that
you entered the full address `http://sovransystemsos.local`. you entered the full address `http://sovransystemsos.local:8937`,
including the `:8937`.
3. Avoid guest Wi-Fi networks, which may prevent devices from seeing one 3. Avoid guest Wi-Fi networks, which may prevent devices from seeing one
another. another.
4. Temporarily disconnect any VPN that may interfere with local-network 4. Temporarily disconnect any VPN that may interfere with local-network
+28 -9
View File
@@ -35,21 +35,40 @@ external networks and are outside a “fully offline” model.
The local Hub currently uses HTTP. Authentication does not encrypt local network The local Hub currently uses HTTP. Authentication does not encrypt local network
traffic, so use a trusted LAN and avoid public or guest Wi-Fi. traffic, so use a trusted LAN and avoid public or guest Wi-Fi.
Caddy serves the Hub (`sovransystemsos.local`), Ride The Lightning (port 3051), The Hub is served on port 8937, on its own: Caddy does not front it. Server +
and Mempool (port 60847) only to this computer and to clients on your local Desktop and Bitcoin Node Only open that port in the firewall, so other devices
network (private, link-local, and VPN addresses), even when ports 80 and 443 are on your local network reach the Hub at `http://sovransystemsos.local:8937`.
forwarded to this computer for public services. Other IPv4 clients get the Forwarding ports 80 and 443 for public services does not put the Hub in front of
connection closed. IPv6 global addresses are not filtered. 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.
`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 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, 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 private, VPN and link-local addresses) and turns everyone else away, however
they reached it. Global IPv6 addresses are turned away too: a laptop on your they reached it. The Hub listens on IPv4 only, so IPv6 clients do not reach it
network and a stranger on the internet look the same by address alone. If your at all; if that ever changes, global IPv6 addresses would be turned away,
devices use addresses outside the local ranges, list their networks in because a laptop on your network and a stranger on the internet look the same
`sovran_systemsOS.hub.extraLanNetworks` in `custom.nix`; 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. `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.
Caddy serves Ride The Lightning (port 3051) and Mempool (port 60847) only to
this computer and to clients on your local network (private, link-local, and VPN
addresses), even when ports 80 and 443 are forwarded to this computer for public
services. Other IPv4 clients get the connection closed. IPv6 global addresses
are not filtered.
### Public services and your home IP address ### Public services and your home IP address
Server + Desktop publishes services under your own domain. The Dynamic DNS Server + Desktop publishes services under your own domain. The Dynamic DNS
@@ -264,7 +264,7 @@
<h3>Network</h3> <h3>Network</h3>
<div class="net-row"><span class="net-k">LAN</span><span class="ip-value" id="ip-internal">…</span></div> <div class="net-row"><span class="net-k">LAN</span><span class="ip-value" id="ip-internal">…</span></div>
<div class="net-row"><span class="net-k">WAN</span><span class="ip-value" id="ip-external">…</span></div> <div class="net-row"><span class="net-k">WAN</span><span class="ip-value" id="ip-external">…</span></div>
<div class="sub">sovransystemsos.local</div> <div class="sub">sovransystemsos.local:8937</div>
</div> </div>
</div> </div>
<div class="welcome-dyn" id="wc-more"></div> <div class="welcome-dyn" id="wc-more"></div>
@@ -520,7 +520,7 @@
<div class="security-reset-password-box" id="security-reset-new-password">&nbsp;</div> <div class="security-reset-password-box" id="security-reset-new-password">&nbsp;</div>
<p class="security-reset-password-warning"> <p class="security-reset-password-warning">
✍️ <strong>Write this down now.</strong><br /> ✍️ <strong>Write this down now.</strong><br />
You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local</em>. You will need it to log in to your computer<br />and the Sovran Hub at <em>sovransystemsos.local:8937</em>.
</p> </p>
<button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled> <button class="security-reset-reboot-btn" id="security-reset-reboot-btn" disabled>
I have written down my new password — Restart Entire System I have written down my new password — Restart Entire System
+45 -39
View File
@@ -14,13 +14,48 @@ let
|| config.sovran_systemsOS.features.haven || config.sovran_systemsOS.features.haven
|| config.sovran_systemsOS.features."nwc-wallets" || config.sovran_systemsOS.features."nwc-wallets"
|| config.sovran_systemsOS.features.element-calling; || config.sovran_systemsOS.features.element-calling;
# RTL and Mempool listen on loopback only: Sovran_Bitcoin binds them to
# 127.0.0.1, and RTL's unit is sandboxed to loopback besides. Caddy is how
# the local network reaches them (:3051 and :60847), so it has to run
# wherever they do. That includes Bitcoin Node Only, which has no
# domain-based service and so no other reason to run Caddy.
#
# The Hub is not one of these. It listens on 0.0.0.0:8937 itself, so it is
# served on its own port rather than through Caddy: the one service that
# runs as root has nothing in front of it that it does not need, and the
# public sites on ports 80/443 cannot be asked for it by Host header.
servesRtl = config.sovran_systemsOS.services.bitcoin;
servesMempool = servesRtl && config.sovran_systemsOS.features.mempool;
caddyEnabled = needsHttpsPorts || extraVhosts != "" || servesRtl;
# Sites for the local network, one per loopback-only service. Written after
# the public domain sites; each exists only where its service does.
bitcoinUiSites =
lib.optionalString servesRtl ''
:3051 {
import sovran_lan_only
reverse_proxy :3050
encode gzip zstd
}
''
+ lib.optionalString servesMempool ''
:60847 {
import sovran_lan_only
reverse_proxy :60845
encode gzip zstd
}
'';
in in
{ {
services.caddy = { services.caddy = {
# Only enable Caddy when at least one domain-based service needs it or # Caddy runs when a domain-based service needs it, when the operator has
# the operator has defined custom vhosts. This prevents Caddy from # defined custom vhosts, or when it is the way to reach RTL and Mempool.
# running on Desktop Only installs that have no web services configured. # Desktop Only has none of those, so Caddy stays off there.
enable = needsHttpsPorts || extraVhosts != ""; enable = caddyEnabled;
user = "caddy"; user = "caddy";
group = "root"; group = "root";
}; };
@@ -90,7 +125,7 @@ EOF
''} ''}
# ── LAN-only guard ────────────────────────────── # ── LAN-only guard ──────────────────────────────
# The Hub, RTL and Mempool sites below are meant for this home network # The RTL and Mempool sites below are meant for this home network
# only. Forwarding ports 80/443 on the router also lets other clients # only. Forwarding ports 80/443 on the router also lets other clients
# reach Caddy, so these sites check where a request comes from, not just # reach Caddy, so these sites check where a request comes from, not just
# which Host it asks for. Anyone else gets the connection closed. # which Host it asks for. Anyone else gets the connection closed.
@@ -222,40 +257,11 @@ $LIGHTNING {
EOF EOF
fi fi
# ── Sovran Hub (LAN access via mDNS) ──────────── # ── RTL and Mempool (local network) ─────────────
cat >> /run/caddy/Caddyfile <<EOF # Only where those services run; see bitcoinUiSites above.
cat >> /run/caddy/Caddyfile <<'LAN_SITES_EOF'
http://sovransystemsos.local { ${bitcoinUiSites}
import sovran_lan_only LAN_SITES_EOF
reverse_proxy localhost:8937
header {
Clear-Site-Data "\"cache\""
Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Pragma "no-cache"
Expires "0"
}
}
EOF
# ── RTL (LAN access) ────────────────────────────
cat >> /run/caddy/Caddyfile <<EOF
:3051 {
import sovran_lan_only
reverse_proxy :3050
encode gzip zstd
}
EOF
# ── Mempool (LAN access) ────────────────────────
cat >> /run/caddy/Caddyfile <<EOF
:60847 {
import sovran_lan_only
reverse_proxy :60845
encode gzip zstd
}
EOF
# ── Custom vhosts from custom.nix ────────────── # ── Custom vhosts from custom.nix ──────────────
cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF' cat >> /run/caddy/Caddyfile <<'CUSTOM_VHOSTS_EOF'
+21
View File
@@ -84,6 +84,27 @@
''; '';
}; };
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 { extraLanNetworks = lib.mkOption {
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
default = [ ]; default = [ ];
+14 -1
View File
@@ -487,6 +487,12 @@ os.environ["SOVRAN_HUB_ICONS"] = os.path.join("$out", "share", "sovran-hub", "i
import uvicorn import uvicorn
uvicorn.run( uvicorn.run(
"sovran_systemsos_web.server:app", "sovran_systemsos_web.server:app",
# IPv4 only, on purpose. The desktop launcher uses "localhost", which
# falls back to 127.0.0.1, and other devices reach the Hub over IPv4 too.
# An IPv6 listener would admit clients whose global addresses the Hub's own
# check cannot tell from a stranger's (see LanPolicy). Which devices may
# connect is up to the firewall (hub.directPort) and that check
# (hub.lanOnly), not this bind.
host="0.0.0.0", host="0.0.0.0",
port=8937, port=8937,
log_level="info", log_level="info",
@@ -599,7 +605,14 @@ in
environment.systemPackages = [ sovran-hub-web ]; environment.systemPackages = [ sovran-hub-web ];
networking.firewall.allowedTCPPorts = [ 8937 60847 ]; # The Hub is served on its own port, not through Caddy (see caddy.nix).
# Nothing here filters by client address: that is the Hub's own check
# (sovran_systemsOS.hub.lanOnly), and which networks can route to this
# computer at all is the router's call.
# 60847 is where Caddy serves Mempool, so it is open only when Mempool is.
networking.firewall.allowedTCPPorts =
lib.optionals cfg.hub.directPort [ 8937 ]
++ lib.optionals (cfg.services.bitcoin && cfg.features.mempool) [ 60847 ];
# ── Auto-launch Hub in browser on login ─────────────────────── # ── Auto-launch Hub in browser on login ───────────────────────
environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = '' environment.etc."xdg/autostart/sovran-hub-autolaunch.desktop".text = ''
+9 -7
View File
@@ -1,9 +1,10 @@
"""Guards for the LAN-only Caddy sites. """Guards for the LAN-only Caddy sites.
The Hub (http://sovransystemsos.local), Ride The Lightning (:3051) and Mempool Ride The Lightning (:3051) and Mempool (:60847) sites are for the home network.
(:60847) sites are for the home network. Caddy has to check where a request comes Caddy has to check where a request comes from because forwarding ports 80/443 on
from because forwarding ports 80/443 on the router lets other clients reach it too. the router lets other clients reach it too. The domain sites for the operator's
The domain sites for the operator's own public services must stay public. own public services must stay public. (The Hub is not a Caddy site: it is served
on its own port and checks its own clients.)
These read modules/core/caddy.nix like the nix-file checks in test_security.py: These read modules/core/caddy.nix like the nix-file checks in test_security.py:
nothing is run and nothing touches the network. nothing is run and nothing touches the network.
@@ -20,7 +21,7 @@ _ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
_PRIVATE_RANGES = ["192.168.0.0/16", "172.16.0.0/12", "10.0.0.0/8", _PRIVATE_RANGES = ["192.168.0.0/16", "172.16.0.0/12", "10.0.0.0/8",
"127.0.0.1/8", "fd00::/8", "::1"] "127.0.0.1/8", "fd00::/8", "::1"]
_LAN_SITES = ("http://sovransystemsos.local", ":3051", ":60847") _LAN_SITES = (":3051", ":60847")
def _read(*parts): def _read(*parts):
@@ -81,9 +82,10 @@ class LanOnlySites(unittest.TestCase):
snippet = _snippet(self.src) snippet = _snippet(self.src)
self.assertIsNotNone(snippet, "(sovran_lan_only) snippet not found") self.assertIsNotNone(snippet, "(sovran_lan_only) snippet not found")
self.assertRegex(snippet, r"(?m)^\s*abort @outside\s*$") self.assertRegex(snippet, r"(?m)^\s*abort @outside\s*$")
# defined before the first site that imports it # defined before the sites that import it are written (the sites come
# from bitcoinUiSites, which the generator appends after the snippet)
self.assertLess(self.src.index("(sovran_lan_only) {"), self.assertLess(self.src.index("(sovran_lan_only) {"),
self.src.index("import sovran_lan_only")) self.src.index("${bitcoinUiSites}"))
def test_guard_ranges(self): def test_guard_ranges(self):
nets = _allowed_networks(_snippet(self.src)) nets = _allowed_networks(_snippet(self.src))
+155
View File
@@ -0,0 +1,155 @@
"""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 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()