The Mullvad VPN app and its daemon now ship with Desktop Only, as the stock
NixOS service with nothing configured on top of it: no local-network boot
service, no DNS or firewall edits of our own, no Hub tile, no Feature Manager
card, no toggles. The account, the relay, the kill switch and the app's other
switches belong to the app.
modules/mullvad.nix is the whole change to the system: a role gate around
services.mullvad-vpn = {
enable = true; # daemon, pkgs.mullvad
gui.enable = true; # app, pkgs.mullvad-vpn
};
Server + Desktop and Bitcoin Node are machines that serve; a VPN client with its
own routing and DNS policy is not something their services asked for, so only
roles.desktop builds it. Every other option stays at the distribution's default
(early boot blocking off, the mullvad-exclude wrapper on), and the only
consequences are the ones the NixOS module already defines — the tun module, and
systemd-resolved with NetworkManager pointed at it while the client is enabled.
The Hub and the desktop shell are untouched: no tile, no feature card, no
service description, no icon, no GNOME folder edit. An app that is simply
installed has no decision to put in front of the user, and no state to report.
README, SECURITY.md (what the app's own kill switch and DNS behaviour mean, so
the change in network behaviour is not a surprise), CHANGELOG, the custom.nix
template (the mkForce line that removes it) and the installer's Desktop card
document it as an app rather than a service.
tests/test_mullvad_desktop_only.py guards the gate and the restraint: the client
builds on Desktop and nowhere else, the module adds no services, no DNS or
firewall edits and no packages, the Hub and desktop shell never mention it, and
it ships no tile icon.
Checked by evaluating the real module against the revision flake.lock pins
(3d23ea05a8a3) with eval-config.nix, four cases: Desktop -> daemon and GUI on,
one unit (mullvad-daemon), mullvad + mullvad-vpn in systemPackages, every other
option at the distribution default; Bitcoin Node and Server + Desktop -> nothing
at all, not even the tun module or resolved; Desktop with
`services.mullvad-vpn.enable = lib.mkForce false;` -> no unit, no package.
The Hub, GDM and sudo now share /etc/shadow as the single source of
truth. /var/lib/secrets/{free,root}-password become root-only display
copies, written only by system_password.py and verified against shadow
before being shown. The GNOME login keyring is decoupled from the login
password (blank master password, pam_gnome_keyring off), so nothing
needs re-keying or wiping when the password changes and the keyring
never pops an unlock prompt.
- add app/sovran_systemsos_web/system_password.py: the single module
that verifies / sets / displays the free + root passwords and drives
the legacy-migration onboarding step (same endpoints and UI)
- add modules/password-model.nix: PAM gnome-keyring off, blank
login.keyring seed with a one-time migration of old encrypted
keyrings, first-boot-only password application (no more boot-revert),
LIBCRYPT for the Hub
- server.py: login verifies against shadow and heals stale display
copies; change-password takes the current password; security reset
sets both passwords through the same writer (root is shown as a
password again, not a hash); all keyring wipes removed
- credentials.nix: drop root-password-setup, free-password-setup and
free-password-migration (replaced by password-model.nix) and the
keyring wipe in change-free-password
- configuration.nix: drop enableGnomeKeyring and the keyring tmpfiles
hack (replaced by password-model.nix)
- service-detail.js: "Current password" field; credentials changed
outside the Hub render with a hint instead of a stale value
Installed machines keep their exact passwords: the first-boot services
no-op once an account has a real hash, and the upgrade never writes
/etc/shadow. The legacy-migration onboarding step and "reset all
passwords" keep working.
The Hub no longer asks a STUN server, a public DNS resolver or a "what
is my IP" service for the home IP address. The DDNS update asks Njal.la
to use the address the request comes from ("&auto"), reads back the
address Njal.la says it recorded and saves it to
/var/lib/secrets/external-ip. Njal.la is the only third party that
learns the address; it has to, to publish it.
- Add app/sovran_systemsos_web/ddns_update.py, installed as
/etc/sovran/ddns-update.py and run by sovran-ddns-update.service. It
runs curl --ipv4 without redirects, accepts only a public IPv4
address, and rewrites the file atomically and only when the address
changes. Stored "&a=${IP}" URLs are converted when they are used and
"&quiet" is dropped.
- Rewrite modules/core/njalla.nix around that runner and delete
modules/core/public-ip.nix. Setting a sovran_systemsOS.publicIP.*
option now fails with a message that says where the address comes
from. Activation removes the old scripts in /var/lib/sovran. The
existing external-ip file keeps working.
- server.py reads the saved address and starts
sovran-ddns-update.service after a domain is saved, instead of looking
the address up itself.
- Element calling uses sovran_systemsOS.elementCalling.externalIP if
set, otherwise the saved address, and fails with a clear message when
neither exists or the address is not public. It no longer falls back
to STUN. livekit-external-ip.path re-runs livekit-turn-setup and
starts LiveKit when the address changes.
- Add tests/test_ddns_update.py.
The public IP was previously detected independently in three places,
each contacting a different third party: the Hub (HTTPS echo via
api.ipify.org / ifconfig.me / icanhazip.com on every API call and
background tick), DDNS (myip.opendns.com via OpenDNS), and LiveKit
(embedded STUN). Consolidate into a single detector with one shared
cache so every consumer reads the same value with minimal exposure.
- add modules/core/public-ip.nix: installs /var/lib/sovran/public-ip.py
(pure Python stdlib, no new deps) writing /var/lib/secrets/external-ip
- detection chain (first success wins): explicit pin, fresh cache
(default TTL 300s), STUN binding request over UDP (one packet, no
metadata), DNS myip.opendns.com query, then OPT-IN HTTPS echo
(publicIP.httpsEcho, empty by default — never contacted unless listed)
- privacy: while the cache is fresh zero third parties are contacted;
at most one party learns the IP per refresh interval, via the least
exposing mechanism available
- hub (server.py): _get_external_ip() now reads the shared detector /
cache instead of calling ipify/ifconfig/icanhazip directly
- ddns (njalla.nix): use the shared detector instead of a separate
OpenDNS dig; allow the hardened service to write /var/lib/secrets
- element-calling: livekit-turn-setup falls back to the shared
detector on cold boot; add LiveKit webhooks to lk-jwt-service
(sfu_webhook) so abrupt disconnects are cleaned up immediately;
set LIVEKIT_SANITY_CHECK_INTERVAL_SECONDS=60 as a missed-webhook
guard; drop the dead services.livekit.settings block and set
openFirewall=false (Caddy fronts the SFU; no public 7880/tcp)
- new options: sovran_systemsOS.publicIP.{stunServer,stunPort,
dnsResolver,httpsEcho,cacheTTL}
- Add modules/core/local-domain-loopback.nix: systemd service and
activation script that write configured service domains to a
Sovran-managed block in /etc/hosts (127.0.0.1 / ::1) so requests
originating on this computer reach Caddy without NAT loopback.
- Import local-domain-loopback.nix in modules/modules.nix.
- server.py: add _validate_domain_value, _is_loopback_address,
_resolve_all_addresses, _trigger_hosts_update helpers.
- server.py: update _check_domain_reachable to use --resolve so
reachability is checked locally via Caddy, not via NAT loopback.
- server.py: update _evaluate_domain_checklist, api_services inline DNS
check, and api_domains_check to recognise loopback resolution as an
intentional local override rather than a DNS mismatch.
- server.py: call _trigger_hosts_update from api_domains_set after
saving a service domain so the /etc/hosts entry is applied immediately.
- Add app/tests/test_loopback_diagnostics.py with 47 tests covering
domain validation, loopback detection, diagnostic checklist logic,
composite health, and api_domains_check."