Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
7a88bd1
feat(networking): add captive-portal helper for tailnet-owned DNS
Bad3r Aug 9, 2026
7cee48b
fix(captive-portal): keep the first prefs snapshot and survive an unr…
Bad3r Aug 9, 2026
1a45cb3
fix(captive-portal): classify each canary on its own answer
Bad3r Aug 9, 2026
45e7d1a
fix(captive-portal): give each outcome its own exit status and never …
Bad3r Aug 9, 2026
dfad85e
docs(networking): scope the resolver chain to the host that runs dnsmasq
Bad3r Aug 9, 2026
db14441
test(captive-portal): pin classification and restore behaviour under …
Bad3r Aug 9, 2026
327abed
fix(captive-portal): return the documented status for a gateway guess
Bad3r Aug 9, 2026
87d1318
docs(captive-portal): describe the resolver the helper actually displ…
Bad3r Aug 9, 2026
3901756
fix(captive-portal): print the gateway guess instead of opening it
Bad3r Aug 9, 2026
8c34353
fix(tailscale): declare the operator user for non-root state changes
Bad3r Aug 9, 2026
c232c92
fix(captive-portal): stop at a refused tailscaled change instead of f…
Bad3r Aug 9, 2026
499e343
fix(captive-portal): put DNS back before the run state, and never sta…
Bad3r Aug 9, 2026
7e8f831
test(captive-portal): isolate a refused subcommand and cover the path…
Bad3r Aug 9, 2026
7198ed9
fix(captive-portal): report a refused `tailscale up` as itself, not t…
Bad3r Aug 10, 2026
b7560a8
test(captive-portal): pin what --down changes and what --restore owes…
Bad3r Aug 10, 2026
23f44cd
fix(captive-portal): keep one snapshot path across the sudo boundary
Bad3r Aug 10, 2026
324dbec
fix(captive-portal): stop leaking the probe payload on every run
Bad3r Aug 10, 2026
98f077d
test(captive-portal): make the hijack-range scenario actually reach i…
Bad3r Aug 10, 2026
86eafeb
fix(captive-portal): stop releasing DNS when nothing recorded what to…
Bad3r Aug 10, 2026
84386da
fix(captive-portal): keep nmcli's and rm's exit codes out of the stat…
Bad3r Aug 10, 2026
d01f47b
fix(captive-portal): stop classifying a canary from a transfer that f…
Bad3r Aug 10, 2026
506ae29
fix(captive-portal): stop reporting DNS as handed back on the run tha…
Bad3r Aug 10, 2026
988a5a5
fix(captive-portal): tell an unknown --device apart from one with no …
Bad3r Aug 10, 2026
0f0a722
feat(captive-portal): declare package meta
Bad3r Aug 10, 2026
424545f
fix(captive-portal): take the sign-in target from a link, not the fir…
Bad3r Aug 10, 2026
7677a19
feat(captive-portal): detect the proxy portal that answers 511
Bad3r Aug 10, 2026
e49413c
fix(captive-portal): keep mktemp's exit code out of the status table
Bad3r Aug 10, 2026
89d0e41
fix(captive-portal): pin the probe's destination past any inherited p…
Bad3r Aug 10, 2026
9d26dfd
fix(captive-portal): guard the other bare unlink on the refusal path
Bad3r Aug 10, 2026
00b9bdd
fix(captive-portal): stop calling a loopback answer a portal
Bad3r Aug 10, 2026
039f6da
fix(captive-portal): only ever open an http or https target
Bad3r Aug 10, 2026
3665b9a
fix(captive-portal): stop a portal's own page from clearing the canary
Bad3r Aug 10, 2026
2da650c
test(captive-portal): cover the reload failure the run is meant to su…
Bad3r Aug 10, 2026
b3413e8
fix(captive-portal): anchor the url= alternative and close the w3.org…
Bad3r Aug 10, 2026
55aea8c
fix(captive-portal): bound the body a hostile portal can write
Bad3r Aug 10, 2026
a5a475b
fix(captive-portal): anchor every attribute name, not just url=
Bad3r Aug 10, 2026
38b5e12
fix(captive-portal): refuse --probe and --restore together instead of…
Bad3r Aug 10, 2026
ab108b3
fix(captive-portal): declare the license the repository actually grants
Bad3r Aug 10, 2026
0fa66d4
fix(captive-portal): refuse --down where nothing stops the node
Bad3r Aug 10, 2026
435f9bb
docs(captive-portal): record the flag pairings that are now usage errors
Bad3r Aug 10, 2026
9a5bfec
fix(captive-portal): drop a canary that resolves to this machine
Bad3r Aug 10, 2026
28eb90a
fix(captive-portal): let a submit target outrank any other link on th…
Bad3r Aug 10, 2026
2e114a5
fix(captive-portal): decode the entity HTML requires in an attribute …
Bad3r Aug 10, 2026
6c27bf1
fix(captive-portal): let only the canary that expects no payload be c…
Bad3r Aug 10, 2026
8210588
fix(captive-portal): create the scratch file before anything is released
Bad3r Aug 10, 2026
4043c3a
test(captive-portal): pin --resolve, the flag the whole classificatio…
Bad3r Aug 10, 2026
d1b37f4
fix(captive-portal): give the meta refresh its own tier ahead of a pl…
Bad3r Aug 10, 2026
cc0d5c4
fix(captive-portal): stop reporting every tailscaled failure as a ref…
Bad3r Aug 10, 2026
bca36ce
test(captive-portal): make the tailscale stub stateful so --down can …
Bad3r Aug 10, 2026
ca8e324
fix(captive-portal): refuse to write the snapshot through a symlinked…
Bad3r Aug 10, 2026
fe46ce7
test(captive-portal): pin @resolver, dig's half of the --resolve cont…
Bad3r Aug 10, 2026
7df8b4c
fix(captive-portal): reject a redirect or extracted link that names t…
Bad3r Aug 10, 2026
85082fd
fix(captive-portal): stop a stylesheet href from beating a relative f…
Bad3r Aug 10, 2026
d903108
docs(captive-portal): scope the exit-3 contract to when a gateway gue…
Bad3r Aug 10, 2026
08b2cf2
docs(captive-portal): document the host check on Location and an extr…
Bad3r Aug 10, 2026
2f23361
fix(captive-portal): refuse to read or unlink the snapshot through a …
Bad3r Aug 10, 2026
7348ebb
fix(captive-portal): strip userinfo before matching a redirect or ext…
Bad3r Aug 10, 2026
f54f7e2
fix(captive-portal): strip <link> elements before extract_url reads t…
Bad3r Aug 10, 2026
19f2911
Update captive-portal.sh
Bad3r Aug 20, 2026
a668adc
Update captive-portal.sh
Bad3r Aug 20, 2026
c9fcb65
Update captive-portal.sh
Bad3r Aug 20, 2026
f14c8a4
test(captive-portal): cover wrapped metadata and loopback aliases
Bad3r Aug 21, 2026
0acca35
fix(captive-portal): reject control bytes in portal targets
Bad3r Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions docs/networking/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,3 +293,217 @@ NetworkManager or `.link` policy.
Changing a MAC address reduces one identifier exposed to a local network. It
does not prevent tracking through Wi-Fi network names, IP-level identifiers,
browser fingerprints, or account activity.

# Sign in to a captive portal

Hotel, airport, and campus networks hold a new client in a walled garden until
it authenticates. The sign-in page is discovered through DNS: the access point's
resolver answers every name with an address it controls, and the first
plain-HTTP request lands on the portal. Hosts in this repository never receive
that answer, so the portal stays invisible.

## Why the portal never appears

Three modules compose into a resolver path that skips the access point, and the
middle one applies to one host today:

1. `modules/hosts/common/networking.nix` enables NetworkManager everywhere.
2. `modules/hosts/common/private-dns-hosts.nix` selects `dns=dnsmasq` and turns
`services.resolved` off, but only for hosts that declare
`privateDnsHostsSecretKeys` in the registry. tpnix is the only one, so tpnix
forwards through dnsmasq on `127.0.0.1` while system76 keeps
systemd-resolved and `dns=systemd-resolved`.
3. `modules/apps/tailscale.nix` runs tailscaled, which by default accepts the
tailnet's DNS configuration.

Which of the two local resolvers a host runs decides what the evidence looks
like, so establish that first:

```bash
readlink -f /etc/resolv.conf
```

A path under `/run/systemd/resolve/` is the systemd-resolved host.

On the dnsmasq host, tailscaled registers its own resolvconf entry, that entry
supersedes NetworkManager's, and `/etc/resolv.conf` ends up carrying only
`100.100.100.100`, so the dnsmasq at `127.0.0.1` is shadowed. Verify both facts:

```bash
resolvconf -l | grep -E 'resolv.conf from|nameserver'
grep '^nameserver' /etc/resolv.conf
```

`resolvconf -l` lists NetworkManager's `nameserver 127.0.0.1`, while
`/etc/resolv.conf` carries only the Tailscale addresses.

On the systemd-resolved host none of that shows: `/etc/resolv.conf` is a static
symlink to the `127.0.0.53` stub, and it reads the same before and after
anything below changes DNS. tailscaled hands its configuration to resolved
instead, so read it there:

```bash
resolvectl status
resolvectl dns
```

The tailnet resolvers appear against the `tailscale0` link, together with the
`~.` routing domain that claims every query.

`tailscale dns status` then shows where those queries go. With MagicDNS and
split DNS both off, the tailnet pushes global resolvers such as `194.242.2.2`
and `1.1.1.1`, so `100.100.100.100` forwards everything to the public internet.
A portal drops those upstreams before authentication, so queries time out rather
than being hijacked: nothing resolves, no redirect fires, and NetworkManager's
connectivity check reports `limited` instead of `portal`.

## Use the captive-portal helper

`captive-portal` (`packages/captive-portal`, enabled from
`modules/hosts/common/apps-enable.nix`) drives the whole sequence:

```bash
captive-portal --probe # report portal state and URL, change nothing
captive-portal # release DNS, locate the portal, open it
captive-portal --restore # return DNS to Tailscale after signing in
```

`--probe` queries the access point's resolver directly with `dig`, so it reports
the portal URL even while Tailscale still owns DNS. Add `--down` to stop
Tailscale entirely instead of only releasing DNS, and `--no-open` to print the
URL rather than launch a browser.
Comment thread
Bad3r marked this conversation as resolved.

`--probe` and `--restore` each name the whole purpose of a run, so they cannot be
combined, and `--down` belongs to a login run, the only one that stops anything.
Either pairing exits 2 rather than silently dropping one of the two.

A portal counts as confirmed on any of four answers to the probe hosts: a
redirect, a page served in place of the expected payload, RFC 6585's
`511 Network Authentication Required`, or a canary resolving to an address on
this LAN. The last is the DNS hijack; the first three also catch a proxy that
intercepts HTTP and leaves DNS alone, which is what guest networks that hand out
a real upstream resolver and enforce at the gateway do. The sign-in URL is taken
from what a link, form, or meta refresh in the page points at, falling back to
the address the canary resolved to.

A canary that resolves to this machine is dropped before the request goes out. A
resolver that sinkholes blocklisted names answers `127.0.0.1` or `0.0.0.0`, which
Pi-hole, AdGuard Home and a bare dnsmasq `address=/host/` all do by default, and
`curl --resolve` sends either to the local port 80. Whatever answers there is not
a portal. A `Location` header or an extracted link gets the same two checks:
only `http` and `https` targets are ever printed or opened, one naming this
machine, `localhost`, or `::1` is dropped the same way, and control bytes are
excluded from extracted targets, while a `Location` target containing one is
rejected. Everything here is chosen by an untrusted network and reaches
`xdg-open`. The probes also pass
`--noproxy '*'`, because an inherited `http_proxy` would send them somewhere
other than the address the access point's resolver named, which is the whole
basis of the classification.

Releasing and restoring DNS writes to tailscaled, which takes a state change only
from root and from the Unix user named by its operator pref. Reading is not
gated the same way, so `tailscale debug prefs` answering is no evidence that
`tailscale set` will. `programs.tailscale.extended.operator` declares that user,
defaults to `flake.lib.meta.owner.username`, and reaches the daemon through
`services.tailscale.extraSetFlags`, which nixpkgs replays from a root oneshot on
every boot. Confirm it landed:

```bash
tailscale debug prefs | jq -r .OperatorUser
```

An empty answer means only root can change DNS: the helper stops with status 4
before it changes anything rather than stranding a half-finished release, and
`sudo` is the workaround until the next switch applies the pref. One thing still
changes under it. `sudo-rs` enforces `env_reset` with no opt-out and
`modules/hosts/common/sudo.nix` keeps only `SSH_AUTH_SOCK`, so root inherits no
`DISPLAY`, no Wayland socket and no session bus: the backgrounded `xdg-open`
cannot reach your browser and fails silently. Pass `--no-open` and open the URL
yourself, which is printed either way. The snapshot does follow you: the same
reset drops `XDG_RUNTIME_DIR`, so the helper falls back to `SUDO_UID`'s runtime
directory rather than root's, and a plain `captive-portal --restore` afterwards
finds what the `sudo` run saved.

Only the portal URL goes to stdout; every diagnostic goes to stderr, and the
exit status names the outcome, so a wrapper can act on it:

| Status | Meaning |
| ------ | ----------------------------------------------------------------------------------- |
| 0 | a portal was found and its URL was printed |
| 1 | no portal: this network answers the probes normally |
| 2 | invalid usage |
| 3 | probes inconclusive; a gateway guess is printed if there is one, never opened |
| 4 | the network could not be inspected, or Tailscale state could not be read or changed |

Status 3 is a guess, not a detection, and prints nothing if the network gave no
default route to guess from. When one is printed, the address is the network's
gateway, which on a network that merely blocks the probe hosts is the local
router rather than a portal, so it is only printed: the browser is launched for
a confirmed portal and nothing else.

```bash
if url=$(captive-portal --probe); then
echo "sign in at $url"
fi
```

The helper saves `CorpDNS` and `WantRunning` under `$XDG_RUNTIME_DIR` before
changing anything, and `--restore` replays exactly those values. A snapshot it
cannot write stops the run with status 4 before any DNS change: releasing DNS
with no record of what to put back is the one state this helper must not leave. The snapshot is
taken once and kept until `--restore` consumes it, so retrying after a failed
sign-in still restores the state the first run found. A run that finds nothing
to sign in to restores DNS before it exits; only a run that found a portal
leaves DNS released, because signing in needs it. An interrupted run cannot
restore, so it prints the `--restore` reminder instead of leaving DNS released
in silence.

`$XDG_RUNTIME_DIR` is cleared at the last logout, so a `--restore` run after a
reboot finds no snapshot. That run re-enables tailnet DNS, which is the safe
direction, and leaves the run state alone: nothing recorded that the node was
up, and starting one that was stopped on purpose is not a restore. DNS goes back
before the run state for the same reason, so a node that refuses to come back up
still gets its resolvers. That run reports the run state rather than the operator
pref, which the DNS write it just completed had already cleared, and it exits 4
keeping the snapshot so `--restore` can be retried. An unreadable
`tailscale debug prefs` ends the same way rather than assuming the node is
already running, because a read that failed is not an answer.

## Sign in by hand

```bash
tailscale set --accept-dns=false
nmcli general reload dns-full
device=$(nmcli -t -f DEVICE,TYPE,STATE device status |
awk -F: '$3 == "connected" && $2 == "wifi" { print $1; exit }')
resolver=$(nmcli -t -f IP4.DNS device show "$device" | sed 's/^IP4\.DNS\[[0-9]*\]://' | head -1)
dig +short "@$resolver" A detectportal.firefox.com
curl -sSI http://detectportal.firefox.com/success.txt | head -5
```

The device is discovered rather than named because each host names it
differently: `modules/tpnix/networking.nix` pins tpnix's wireless NIC to
`wifi0`, while system76 keeps the kernel's `wlan0`.

Both `tailscale set` calls need the same write access as the helper: run them as
the operator user, or under `sudo`.

A private address in the `dig` answer, or a `Location:` header pointing off
site, is the portal. Open that URL, sign in, then run:

```bash
tailscale set --accept-dns=true
nmcli general reload dns-full
```

## Two portal traps specific to these hosts

LibreWolf is the default browser and ships with
`network.captive-portal-service.enabled` false, so it never raises a "Log in to
network" bar. Open the portal URL directly, and use a private window: portals
reject cached HSTS upgrades and stale cookies from an earlier session.

Portals bind an authenticated session to a MAC address. The shared baseline sets
`wifi.macAddress = "stable"`, which is deterministic per connection profile, so
reconnecting keeps the session. Deleting and re-creating the profile re-keys the
address and forces a fresh sign-in. See the MAC address policy section above.
56 changes: 56 additions & 0 deletions modules/apps/captive-portal.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
Package: captive-portal
Description: Sign in to a captive portal on a host whose DNS is claimed by Tailscale.
Homepage: https://github.com/Bad3r/nixos
Documentation: docs/networking/README.md
Repository: https://github.com/Bad3r/nixos

Summary:
* Releases DNS from Tailscale back to NetworkManager's dnsmasq so the access point's resolver answers again.
* Locates the sign-in page through the access point's own resolver and HTTP probes, then opens it.
* Restores the saved Tailscale DNS and run state once the portal accepts the session.

Options:
captive-portal: Release DNS, find the portal, and open it in the default browser.
captive-portal --probe: Report portal state and URL without changing DNS.
captive-portal --restore: Hand DNS back to Tailscale after signing in.
--device DEV: Inspect a specific device instead of the first connected wifi/ethernet.
--no-open: Print the portal URL instead of launching a browser.
--down: Stop Tailscale entirely rather than only releasing DNS.

Example Usage:
* `captive-portal --probe` -- Check whether the current network intercepts DNS or HTTP.
* `captive-portal` -- Sign in to a hotel or campus network.
* `captive-portal --restore` -- Return to tailnet DNS after the portal accepts the session.
*/
_:
let
CaptivePortalModule =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.programs.captive-portal.extended;
in
{
options.programs.captive-portal.extended = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable captive-portal.";
};

package = lib.mkPackageOption pkgs "captive-portal" { };
};

config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
};
};
in
{
flake.nixosModules.apps.captive-portal = CaptivePortalModule;
}
29 changes: 28 additions & 1 deletion modules/apps/tailscale.nix
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
authKeyFile: Optional file path containing a reusable auth key for non-interactive node registration.
extraSetFlags: Additional arguments passed to `tailscale set` after daemon startup.
interfaceName: Override the network interface name used by tailscaled (default `tailscale0`).
operator: Unix user allowed to change tailscaled state without root, applied as
`tailscale set --operator`. Defaults to `flake.lib.meta.owner.username`; null
leaves `tailscale up`, `down`, and `set` to root.
sshHostAlias: Host alias written to `~/.ssh/hosts/<alias>` when tailscale is enabled.
sshHostName: HostName used in the generated SSH match block (IP or MagicDNS name).
Defaults to the `tailnetIp` of the registry host marked `primary` in
Expand All @@ -25,6 +28,7 @@
{ config, lib, ... }:
let
fleetHosts = config.flake.lib.nixos.hosts or { };
ownerUsername = config.flake.lib.meta.owner.username or null;
primaryTailnetIp = lib.findFirst (ip: ip != null) null (
lib.mapAttrsToList (_: host: host.tailnetIp or null) (
lib.filterAttrs (_: host: host.primary or false) fleetHosts
Expand Down Expand Up @@ -68,6 +72,17 @@ let
description = "Network interface name used for the tailscale tunnel.";
};

operator = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = ownerUsername;
description = ''
Unix user allowed to change tailscaled state without root, passed as
`tailscale set --operator`. Without it `tailscale up`, `tailscale down`
and `tailscale set` answer only root, so user-facing tooling such as
captive-portal cannot release or restore DNS. null keeps that default.
'';
};

sshHostAlias = lib.mkOption {
type = lib.types.str;
default = "tailscale";
Expand All @@ -88,10 +103,22 @@ let
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];

# `tailscale set --operator` names a Unix user, and tailscaled resolves it
# at runtime: a name no host account carries fails the tailscaled-set unit
# after the switch instead of at eval.
assertions = lib.optional (cfg.operator != null) {
assertion = config.users.users ? ${cfg.operator};
message = "programs.tailscale.extended.operator is '${cfg.operator}', which is not a declared user.";
};

services.tailscale = lib.mkMerge [
{
enable = true;
inherit (cfg) package interfaceName extraSetFlags;
inherit (cfg) package interfaceName;
# nixpkgs runs `tailscale set` from a root oneshot only when this list
# is non-empty, so the operator pref is re-applied on every boot.
extraSetFlags =
cfg.extraSetFlags ++ lib.optional (cfg.operator != null) "--operator=${cfg.operator}";
}
(lib.mkIf (cfg.authKeyFile != null) {
inherit (cfg) authKeyFile;
Expand Down
20 changes: 20 additions & 0 deletions modules/custom-overlays/captive-portal.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
_:
let
Overlay =
{ config, lib, ... }:
let
cfg = config.programs.captive-portal.extended;
in
{
config = lib.mkIf cfg.enable {
nixpkgs.overlays = [
(final: _prev: {
captive-portal = final.callPackage ../../packages/captive-portal { };
})
];
};
};
in
{
flake.customOverlays.captive-portal = Overlay;
}
1 change: 1 addition & 0 deletions modules/hosts/common/apps-enable.nix
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ let
bzip2.extended.enable = lib.mkOverride 1100 true;
bzmenu.extended.enable = lib.mkOverride 1100 true;
cachix.extended.enable = lib.mkOverride 1100 true;
"captive-portal".extended.enable = lib.mkOverride 1100 true;
cargo.extended.enable = lib.mkOverride 1100 true;
certbot.extended.enable = lib.mkOverride 1100 true;
cewl.extended.enable = lib.mkOverride 1100 true;
Expand Down
Loading
Loading