Anonix: a fail-closed Tor-over-VPN appliance in one NixOS flake
I released anonix, a NixOS flake that forces every application through Tor over a WireGuard tunnel. The firewall does the killswitch work, and each security claim gets proven by VM tests on every commit.
Disclaimer: anonix is an experiment. It is young, it has had no independent security review, and a failure could silently deanonymize you. Nobody whose safety, freedom, or livelihood depends on staying anonymous should use it. If a system failure would have real-world consequences for you, use Whonix, Tails, or Qubes instead.
I just released anonix, a NixOS flake for maximal anonymity. Every application is transparently forced through Tor. Tor rides a WireGuard VPN tunnel. The firewall drops everything else. If either layer is down, no packet leaves the machine.
apps ─▶ Tor (transparent proxy) ─▶ VPN tunnel ─▶ Tor guard ─▶ Tor net ─▶ exit ─▶ Internet
(nft nat redirect) (default route) (as user `tor`)
This sits in the same space as Whonix, Tails, and Qubes. Those are all mature projects, and if one of them fits your threat model you should probably just use it. What anonix adds is a combination none of them ship: a whole system as one flake you can read, Tor-over-VPN as the architecture rather than an option, and security claims machine-checked by VM tests on every commit. That last piece is the one I care about most, so most of this post is about it.
The firewall is the killswitch
There is no separate killswitch daemon watching the tunnel and flipping rules when it drops. Mutable daemon state is exactly the failure mode I wanted to avoid. Instead, the nftables ruleset is written so the safe state is the only one you can reach. The filter output chain is policy drop, and it lets precisely three things out:
# Return traffic only on the VPN tunnel.
oifname "wg-tunnel" ct state established,related accept
# Tor may only egress via the VPN tunnel.
oifname "wg-tunnel" meta skuid <tor-uid> accept
# Encrypted WireGuard packets to the VPN endpoint.
ip daddr <endpoint> udp dport 51820 accept
The oifname "wg-tunnel" pin on Tor’s packets is the rule everything else depends on. If the tunnel drops, the kernel reroutes Tor’s packets to the physical NIC, they stop matching, and they’re dropped. Without that pin, a dropped tunnel leaves the DHCP default route in place and Tor would happily reconnect over clearnet, leaking your real IP to the guard and “this subscriber uses Tor” to your ISP.
A blanket ct state established,related accept on every interface is what most firewall configs do. Here it would be a hole: in-flight Tor streams would spill onto the clearnet NIC the moment the tunnel drops, because conntrack still considers them established. Scoping the accept to wg-tunnel closes that.
The reverse case is handled by NAT: all TCP is redirected to Tor’s TransPort on loopback. If Tor is dead, the redirect target answers with a reset and nothing ever reaches the wire. Tor down, VPN down, or both: zero packets leave.
Matching Tor by numeric UID instead of username is a small Nix-specific detail I liked. NixOS assigns tor a static UID, and a number resolves without /etc/passwd, so the same ruleset passes the build-time nft -c check inside the sandbox (where the user doesn’t exist) and works at runtime.
IPv6 is disabled outright, because Tor’s transparent proxy is v4-only and an unproxied v6 route is a classic deanonymization leak. QUIC gets dropped for the same reason: UDP cannot traverse Tor, so browsers fall back to TCP HTTP/2, which is torified.
Isolation by topology, not by rules
Applications don’t run on the gateway. They run in a workstation microVM whose only network path is an internal bridge to the gateway. Traffic arriving on that bridge is redirected into Tor by the same NAT logic, and the forward chain is policy drop, so workstation packets can never be forwarded to the WAN. A clearnet leak from the workstation is topologically impossible, not just firewalled off: there is no route to the physical NIC to misconfigure.
This is Whonix’s gateway/workstation split, but on one machine without a desktop hypervisor stack. Whonix wants VirtualBox or KVM on a host OS you also have to trust; Qubes does it better but demands dedicated hardware. A microVM on a minimal NixOS host sits in between. I tried to be clear in the README about the trade-off: Xen-based compartmentalization is a stronger boundary than a microVM on a shared kernel.
Security claims as CI failures
Every claim above is either proven by a NixOS VM test that runs on every commit, or listed as untested in the README.
The killswitch test builds a two-node QEMU network: the gateway under test, and a wan node that plays the VPN endpoint and runs tcpdump on the wire. The tunnel comes up with a real WireGuard handshake against throwaway keys. Then the test kills one component at a time and asserts on captured packets:
with subtest("kill only the VPN -> fail-closed, no clearnet leak"):
gateway.succeed("systemctl stop wg-quick-wg-tunnel.service")
gateway.fail("ip link show wg-tunnel")
leak = capture_egress("/tmp/vpn.pcap", "vpn-down")
assert leak == "", f"CLEARNET LEAK after VPN was killed:\n{leak}"
gateway.wait_until_succeeds(
"journalctl -k --no-pager | grep -q KILLSWITCH-DROP-OUT")
capture_egress makes the gateway genuinely try to leak: curl to a raw IP over HTTP and HTTPS, DNS to an outside resolver, ping. The assertion runs against the pcap from the other node: any IP packet the gateway emitted that isn’t encrypted WireGuard to the endpoint fails the test. It doesn’t check that the rules look right, it checks the wire.
Other tests in the suite prove that the workstation loses all connectivity when the gateway dies, that the ruleset matches the design when no VPN key is present, that the duress path works, and that updates keep /persist and the previous generation. An eval-time check asserts option-level invariants (root locked, users immutable, no sudo) so they can’t silently drift in a refactor.
Just as important is the second table in the README: what is not proven. That traffic actually exits through a working Tor circuit needs a live network, so the VM tests prove containment, not the circuit. Secure Boot enrollment and TPM2 unlock need real firmware. I’d rather have an honest “untested” list than a claims list that sounds impressive, especially for a young project in a space where the established players have years of adversarial review behind them.
Nothing survives a reboot, nothing readable survives a theft
The rest of the system follows the same principle:
- Impermanence. Root is tmpfs, wiped every boot. Only a short keep-list under
/persistsurvives. Logs are RAM-only. - Encrypted at rest.
/nixand/persistare LUKS-encrypted, with the disk layout declared in Nix via disko. Imaging the disk yields no age identity, no Tor guard state, no logs. - Managed Secure Boot. lanzaboote signs the kernel and initrd with your own keys, generated on
/persistand auto-enrolled. TPM2 unlock is sealed to PCRs 7 and 11 and falls back to the passphrase if the boot chain changes. - Duress passphrase. An optional second passphrase at the unlock prompt crypto-erases
/persistand boots a working decoy system. Deniable to someone watching you boot; not to offline forensics, since the second keyslot is visible. The limit is stated plainly in the README rather than waved away.
Even the update model is fail-closed: no substituters on the running system. Updates are built elsewhere and applied from a USB stick, the same flow as the fully offline install.
The daily-driver bits
CI runs lint (statix, deadnix), every VM test with KVM on the GitHub runners, and builds the offline installer ISO with dummy VPN values as a release artifact. sbomnix generates SPDX and CycloneDX SBOMs of the full system closure and grype scans them, failing the pipeline on any Critical CVE. just ci reproduces the whole pipeline locally.
If you just want to poke at it, just vm boots the gateway in QEMU. It has no real VPN key, so the tunnel never comes up and the killswitch keeps the guest offline, which is itself a nice demo: the default state of this system is “nothing gets out.”
The code is at github.com/lanathlor/anonix, MIT licensed. The README carries the full threat model, including what’s out of scope (a global passive adversary correlating traffic, your VPN provider colluding with an exit, firmware below UEFI). If Whonix, Tails, or Qubes fits your threat model, use them — but if what you want is the whole thing as one flake you can read, test, and rebuild bit-for-bit, that’s the gap this fills.