sshd-localhost.nix turns sshd on for every role so that "ssh root@localhost"
works, and binds it to 127.0.0.1 only ("zero network exposure", as its
own comment says, and as sshd.nix expects: the sshd feature "extends this
to 0.0.0.0 and opens port 22 on the firewall when the user enables remote
SSH").
It never turned off services.openssh.openFirewall, which NixOS defaults
to true and applies whether or not sshd listens on the ports. So port 22
was open in the firewall on every role, Desktop Only included, with
nothing behind it. No listener means no live exposure today; it does mean
the firewall was not saying what the documentation says, and the day
something does bind 22 on a wider address (a listenAddresses change, a
second daemon) it would be reachable without anyone having opened it.
openFirewall is now mkDefault false there. Nothing that wants SSH
published loses it: sshd.nix (feature sshd) and remote-deploy.nix both
add 22 explicitly, and only when enabled. Found by evaluating the real
module set; grepping for allowedTCPPorts does not see a NixOS default.
Evaluated with nix eval, TCP firewall ports:
before after
Server + Desktop 22 80 443 3051 8937 80 443 3051 8937
Bitcoin Node Only 22 3051 8937 60847 3051 8937 60847
Desktop Only 22 (none)
Desktop + features.sshd 22 22
Desktop + deploy.enable 22 3389 22 3389
and sshd's listen addresses are unchanged: 127.0.0.1 by default,
127.0.0.1 and 0.0.0.0 with the sshd feature. Desktop Only now opens no
TCP port at all; UDP 5353 (mDNS) is the only port open there, and
SECURITY.md says so.
Add tests/test_ssh_exposure.py.
c33457f put an address check (sovran_lan_only) on the Hub, RTL and
Mempool sites. It was written for ports 80/443 being forwarded and a
Host header selecting a site, and that only ever applied to the Hub. RTL
and Mempool are sites on ports of their own (:3051, :60847): a request
on 80/443 cannot select them, whatever Host it carries.
Checked with Caddy 2.9.1 and the Caddyfile this module generates for
Server + Desktop with every domain configured, serving the public sites
on a stand-in port: Host: sovransystemsos.local, localhost:8937,
127.0.0.1 and x:3051 all get an empty 200, and Host: matrix.example.org
gets the Synapse stand-in. With the Hub off Caddy the guard has nothing
left to guard, and it could not be made right for the two sites that
remain:
- IPv6. A laptop's global address on the LAN looks exactly like a
stranger's. The choice was between letting all of 2000::/3 through,
which is the whole IPv6 internet and is what c33457f does, and
refusing every LAN device that connects over a global address unless
the operator copies the ISP's prefix into a Nix option.
- They do not need it. RTL has a random 20-character password
(pwgen -s 20, about 119 bits) and, since 0.15.12, which Sovran_Bitcoin
pins, a 30-minute lockout keyed on the client address. Mempool shows
public chain data. If someone forwards 3051 or 60847 that is the same
exposure as any other port on the machine, and SECURITY.md says not to.
Remove the snippet and its two imports, and tests/test_caddy_lan_only.py
with them. What is still worth pinning moves to test_hub_direct.py: no
address filter anywhere in caddy.nix, the two sites are plain proxies to
their loopback ports, and no option for a declared prefix is left
half-wired.
Behaviour change: RTL and Mempool answer any client that can reach :3051
or :60847, as they did before c33457f. In practice that is the local
network, because nothing asks you to forward those ports.
Checked with the real generator and Caddy 2.9.1: Node Only generates
`auto_https off` and the two plain sites and validates. Run live next to
the real Hub, a LAN client gets the Hub, RTL and Mempool; a stranger's
address gets RTL and Mempool (by design) and a 403 from the Hub; port 80
is not listening on Node Only.
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.
The reported bug was the Hub being reachable from outside the local
network when Server + Desktop is active. c33457f guards the Caddy site
for sovransystemsos.local, but the Hub is an application that also
listens on a port of its own (8937), and a check in Caddy does nothing
for a client that never goes through Caddy. Whether a client could reach
the Hub depended on which door it used.
The Hub now asks the question itself. LanOnlyMiddleware is registered
outermost, so a client that is not on this computer or the local network
gets a bare 403 before authentication is considered; it never sees the
login page.
- Local means loopback, 10/8, 172.16/12, 192.168/16, 100.64/10
(Tailscale and other CGNAT/VPN ranges) and 169.254/16 over IPv4, and
::1, fc00::/7 and fe80::/10 over IPv6.
- IPv6 global addresses are not on the list. A global address belonging
to a laptop on the LAN cannot be told apart from a stranger's by the
address alone, and 2000::/3 is every public IPv6 address there is.
- ::ffff:a.b.c.d is read as the IPv4 address inside it.
- sovran_systemsOS.hub.extraLanNetworks adds networks (IPv4 or IPv6 CIDR)
for setups whose own devices use addresses outside those ranges. It is
checked at build time. The app ignores an entry it cannot parse and
refuses 0.0.0.0/0 and ::/0: it must never widen its policy by
guessing, and "everyone" is hub.lanOnly = false, asked for by name.
- sovran_systemsOS.hub.lanOnly (default true) turns the check off.
- The first refusal from each address is logged, naming the option to
change, so an operator whose own device is refused can find out why.
The list is capped so a scanner cannot fill the journal or memory.
Behind Caddy the policy applies to the real client, not to Caddy: uvicorn
takes the address from X-Forwarded-For only when the peer is 127.0.0.1.
Checked, not only reviewed:
- The real app with the config.json the module really generates (nix
eval on the nixpkgs revision flake.lock pins, read back from the
derivation), on a real socket with the source address chosen per
request: 127.0.0.1, 192.168/16, 100.64/10 and a declared extra
network are served; 203.0.113.9, 8.8.4.4 and an address just outside
the declared /28 get 403 on /login, / and /api/ping. /auto-login still
answers 303 to loopback and 403 to everyone else.
- With c33457f's Caddy guard in front, an IPv6 client at 2001:db8::9
passes Caddy (it is inside 2000::/3) and is refused by the Hub; an
IPv4 stranger has the connection closed by Caddy; a LAN client is
served.
- hub.extraLanNetworks accepts 203.0.113.0/28, 2001:db8:abcd::/48 and
bare hosts, and fails the build for /33, 300.1.1.1/8, 0.0.0.0/0, ::/0,
2001:db8::/129 and junk, with a message that says what to write.
Add tests/test_lan_policy.py and tests/test_hub_lan_only.py, and a note
in SECURITY.md.
The Hub (sovransystemsos.local), Ride The Lightning (:3051) and Mempool
(:60847) sites are meant for the home network. With ports 80/443
forwarded for public services, Caddy also receives requests from other
clients, so these sites now check the client address as well as the Host
header.
A new snippet, sovran_lan_only, closes the connection unless the client
is on this computer or the local network: private_ranges, 100.64.0.0/10
(Tailscale), 169.254.0.0/16, fe80::/10 and fc00::/7. IPv6 global
addresses (2000::/3) are not filtered: computers on the network often
connect over their own global address, which cannot be told apart from
one on the internet by the address alone. Only the three local sites
import the snippet; the domain sites for public services are unchanged.
Clients with a public IPv4 address on the local network are no longer
served on these sites. The Hub is still available on port 8937.
Checked with Caddy 2.11.4 and the Caddyfile the generator writes: public
IPv4 clients get the connection closed on all three sites, local clients
are served, and the public domain sites answer as before.
Add tests/test_caddy_lan_only.py and a note in SECURITY.md.
Server + Desktop publishes services under the operator's own domain, and
the DNS record for that domain points at the home connection, so anyone
can look up the home IP address. None of the places that offer Server +
Desktop said so.
- README: new section "Server + Desktop and your home IP address" (what
becomes public, what does not, the alternatives, and what happens
technically), plus a note on the role table and in the security
overview.
- SECURITY.md: a matching section, the consequence noted next to "Public
web services exposed only when enabled by the operator", and the
supported versions row no longer pins 1.0.x.
- ISO installer: the Server + Desktop role card ends with the warning.
- Hub: the domain setup text (onboarding, feature setup and domain
reconfiguration share renderDomainNeedsHtml) and the upgrade dialog
carry the same notice.
- Add tests/test_exposure_guards.py. It fails if one of these places
loses the notice or the README anchor stops resolving.