Skip to content

feat(networking): add captive-portal helper for tailnet-owned DNS - #449

Draft
Bad3r wants to merge 63 commits into
mainfrom
feat/captive-portal
Draft

feat(networking): add captive-portal helper for tailnet-owned DNS#449
Bad3r wants to merge 63 commits into
mainfrom
feat/captive-portal

Conversation

@Bad3r

@Bad3r Bad3r commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Summary

Connecting to a captive-portal SSID (observed on AC-WIFI) leaves tpnix with no usable DNS and no reachable sign-in
page. tailscaled registers a resolvconf entry that supersedes NetworkManager's, so /etc/resolv.conf carries only
100.100.100.100 and the dnsmasq at 127.0.0.1 from modules/hosts/common/private-dns-hosts.nix is shadowed.
resolvconf -l lists NetworkManager's nameserver 127.0.0.1 while /etc/resolv.conf does not.

With MagicDNS and split DNS both disabled tailnet-wide, the coordination server pushes global resolvers 194.242.2.2
and 1.1.1.1, so 100.100.100.100 forwards every query to the public internet. A portal drops those upstreams before
authentication, so lookups time out rather than being hijacked. The portal's DNS hijack is what produces the sign-in
redirect, so nothing surfaces the portal and NetworkManager's connectivity check settles on limited instead of
portal.

This adds a captive-portal helper that releases DNS back to dnsmasq, locates the portal by querying the access point's
resolver directly with dig, opens it, and restores the saved Tailscale state afterwards.

  • packages/captive-portal/ with the script and its writeShellApplication wrapper.
  • modules/apps/captive-portal.nix and modules/custom-overlays/captive-portal.nix following the dnsleak pattern.
  • Enabled at the common baseline in modules/hosts/common/apps-enable.nix.
  • programs.tailscale.extended.operator in modules/apps/tailscale.nix, so tailscaled takes a state change from the
    owner rather than root alone. It reaches the daemon through services.tailscale.extraSetFlags, which nixpkgs replays
    from a root oneshot on every boot, and an assertion rejects a name no host account carries.
  • modules/packages/captive-portal-check.nix, a runtime check that drives the packaged script against stubbed dig,
    curl, nmcli, ip, tailscale and xdg-open. Nothing else executes this script: a host build lints it with
    shellcheck and stops there.
  • A "Sign in to a captive portal" section in docs/networking/README.md covering the resolver chain, the helper, a
    manual fallback, and two host-specific traps.

Details worth review:

  • A portal counts as confirmed on a redirect, a page served in place of the expected payload, RFC 6585's 511, or a
    canary resolving to an address on this LAN. The first three also catch a proxy that intercepts HTTP and leaves DNS
    alone. The sign-in URL comes from what a link, form or meta refresh points at, falling back to the address the canary
    resolved to, because the first absolute URL in a page is as often a DOCTYPE's w3.org DTD or a CDN script tag. Link
    metadata is stripped before extraction, wrapped attributes are flattened, and control bytes are excluded from
    extracted targets, while a Location target containing one is rejected before it reaches diagnostics or xdg-open.
  • --restore starts the node only when the snapshot recorded one that was running. With no snapshot there is no
    evidence the node was ever up, and starting one the user stopped is not a restore. The call is a flagless
    tailscale up, which checkForAccidentalSettingReverts in cmd/tailscale/cli/up.go short-circuits to a
    WantRunning-only edit when no flag is set, so it cannot clear this node's RunSSH.
  • DNS goes back before the run state, and the two halves report separately. A refused tailscale set --accept-dns=true
    is the operator wall and names it; a refused tailscale up happens after DNS is already back, so it reports the
    stopped node instead and still runs the NetworkManager reload. Both keep the snapshot, so --restore stays
    retryable.
  • Under sudo the snapshot is keyed off SUDO_UID, because sudo-rs enforces env_reset with no opt-out and
    modules/hosts/common/sudo.nix keeps only SSH_AUTH_SOCK, so XDG_RUNTIME_DIR is gone and root's own
    /run/user/0 would hide the snapshot from a later plain --restore. Root still inherits no DISPLAY, Wayland
    socket or session bus, so the browser half is documented rather than fixed: pass --no-open and open the URL, which
    prints either way.
  • That same SUDO_UID-keyed directory sits inside a tree the invoking user owns for the run's whole life, so
    save_prefs and restore_dns both refuse to use it if it is a symlink: mkdir -p is a silent no-op against one
    that already resolves to a directory, hand_state_to_invoker uses chown -h for the snapshot entries, and a plain
    read and rm -f would follow a symlink on the restore side. The guards prevent root from handing a root-owned
    target's ownership to the invoking user, or reading and unlinking a file the invoking user named. No privilege
    boundary crosses on hosts where sudo is wheel-wide, so this is hardening rather than a fix for an active escalation
    here.
  • LibreWolf is the default browser and ships network.captive-portal-service.enabled false, so it never raises a
    sign-in bar; the helper opens the portal URL directly.

Test plan

  • nix fmt reports 0 changed.
  • nix build --impure --expr '(import <nixpkgs> {}).callPackage ./packages/captive-portal { }' succeeds, which also
    runs shellcheck through writeShellApplication.
  • nix build path:.#checks.x86_64-linux."packages/captive-portal-runtime" exits 0. Scenarios cover classification
    (DNS hijack, inline portal page, 30x with and without a Location, RFC 6585's 511, a sign-in target read from a
    link rather than from a DOCTYPE or a CDN tag, one address per arm of is_private_v4 plus the public address that
    must not match any), every path that released DNS giving it back, the refused-release and refused-restore
    mirrors, the snapshot's retry contract, --down, and the failures that used to pass for success: a transfer that
    died after the response line, a scratch file that cannot be created and one that outlived the run, a snapshot
    that cannot be written or unlinked or is reached through a symlinked state directory, an unreadable
    tailscale debug prefs, nmcli failing to list devices, a --device name nmcli does not know, wrapped link
    attributes, loopback aliases, raw NUL and ESC-bearing portal targets, control-bearing redirects, and --no-open
    browser suppression. Existing scenarios were confirmed non-vacuous by mutating the fix back out and watching the
    named assertion fail. The new cases directly assert the raw-NUL extraction, control-free output and browser
    argument, redirect fallback, and --no-open suppression contracts.
  • nix eval path:.#nixosConfigurations.tpnix.config.environment.systemPackages finds captive-portal exactly once,
    proving module discovery, overlay, and host closure wiring.
  • nix flake check --accept-flake-config --no-build path:. exits 0.
  • pre-commit hooks pass, including shellcheck, statix, deadnix, treefmt, and typos.
  • End-to-end run on an open network: /etc/resolv.conf moved 100.100.100.100 to 127.0.0.1 and back, the network
    was classified as having no portal, and CorpDNS, WantRunning, and RunSSH were unchanged afterwards.
  • Not yet exercised against a live portal; the portal-found branch is covered only by the probe classifier.
  • The sudo halves of the state-directory choice are not covered by the runtime check: the builder is uid 1000 and
    cannot create /run/user, so neither the SUDO_UID fallback nor the chown that hands the snapshot back can run
    there. The check asserts the precedence an ordinary run does cross, and its header records what it cannot reach.

Note: nix flake check --no-build first fails with a did not exist in the store during evaluation error, on
browsers/firefoxpwa-module-eval and hm-apps/rclone-protondrive-otp. That is --no-build refusing those checks' IFD
fixtures, unrelated to this change; building them directly succeeds and the full check then exits 0.

tailscaled registers a resolvconf entry that supersedes NetworkManager's, so /etc/resolv.conf on tpnix carries only
100.100.100.100 and the dnsmasq at 127.0.0.1 configured by modules/hosts/common/private-dns-hosts.nix is shadowed. With
MagicDNS and split DNS both off, the tailnet pushes global resolvers 194.242.2.2 and 1.1.1.1, so every query leaves for
the public internet. A captive portal drops those upstreams before authentication, so lookups time out instead of being
hijacked: the sign-in redirect never fires and NetworkManager's connectivity check reports limited rather than portal.
Observed on SSID AC-WIFI, where no portal page was reachable at all.

captive-portal releases DNS back to dnsmasq, locates the portal by querying the access point's resolver directly with
dig, opens it, and replays the saved CorpDNS and WantRunning values on --restore. Restore invokes tailscale up only when
--down stopped a running node, because tailscale up resets every pref it is not passed and would otherwise clear the
node's RunSSH setting.

LibreWolf is the default browser and ships network.captive-portal-service.enabled false, so it raises no sign-in bar of
its own; the helper opens the portal URL directly.

The script carries a shellcheck shell=bash directive instead of a shebang because writeShellApplication supplies the
interpreter line.

Validation:
  nix fmt (0 changed)
  nix build --impure --expr '(import <nixpkgs> {}).callPackage ./packages/captive-portal { }'
  nix eval path:.#nixosConfigurations.tpnix.config.environment.systemPackages (captive-portal present once)
  nix flake check --accept-flake-config --no-build path:. (exit 0)
  end-to-end run: /etc/resolv.conf 100.100.100.100 -> 127.0.0.1 -> 100.100.100.100 with CorpDNS/WantRunning/RunSSH intact
@github-project-automation github-project-automation Bot moved this to Backlog in ❄️ NixOS Aug 9, 2026
@Bad3r Bad3r added type(enhancement) Net-new capability or intentional improvement. area(networking) Networking services, DNS, VPN, SSH transport, or connectivity. area(packages) Package definitions, overrides, overlays, or package-source selection. area(docs) Repository documentation is a significant affected surface. host(tpnix) Specific to the tpnix host or its runtime contract. labels Aug 9, 2026
@claude

claude Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Bad3r's task in 7m 32s —— View job


Review complete

  • Read the diff and current head state
  • Review packages/captive-portal/captive-portal.sh
  • Review the Nix modules and runtime check
  • Post findings

1 finding, posted inline.

# Tag File What
1 [ISSUE] packages/captive-portal/captive-portal.sh:284-291 &amp; ends in ;, and ; is in extract_url's anchor class, so the ?url=/&url= hole closed in b3413e8 is reopened by the encoding HTML requires for that separator. Decoding runs at line 291, after the anchoring grep. A page with href="/login?res=x&amp;url=http://detectportal.firefox.com/success.txt" and a relative <form action> makes tier 2 print the canary's own URL as the portal and pass it to xdg-open. Fix: move the decode into the existing sed ahead of the grep chain.

The ?url= scenario at modules/packages/captive-portal-check.nix:467 passes for two independent reasons (the anchor excludes the raw form and that page's <form action> is absolute), so it does not cover the entity-encoded variant.

Checked and found clean: is_loopback_host path/userinfo/case handling and both call sites; the 30* control-byte and scheme gates; the 204/200|511 clean-canary rules and the 256-byte bound; the sinkhole drop before --resolve; snapshot save/restore ordering, the symlink guards, chown -h, and every exit-status path in login mode; nmcli/dig status propagation under pipefail; the tr '\n\000' flattening and its interaction with the <link> strip and the (^|[;[:space:]]) anchor; runtimeInputs coverage for every binary the script calls; modules/apps/tailscale.nix (operator assertion, extraSetFlags merge), modules/apps/captive-portal.nix, and modules/custom-overlays/captive-portal.nix against the dnsleak precedent and custom-overlays-base.nix; the darwin build concern for the new check (moot — modules/meta/systems.nix declares only x86_64-linux).
· branch feat/captive-portal

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple logic bugs in the helper script regarding state preservation and portal detection.

  • packages/captive-portal/captive-portal.sh: save_prefs overwrites the state file on every execution. If the script is run while DNS is already released (e.g., a second attempt), it saves the modified state as the "original," preventing automatic restoration of Tailscale DNS.
    Fix: Add [ -f "$state_file" ] && return 0 at the start of save_prefs.

  • packages/captive-portal/captive-portal.sh: is_private_v4 uses flawed shell globs (e.g., 10.*, 172.1[6-9].*) that incorrectly match public or CGNAT ranges (e.g., 100.64.0.1 matches 10.*, and 172.160.0.1 matches 172.1[6-9].*).
    Fix: Use more restrictive patterns like 10.[0-9]* or validate the character following the prefix is a dot.

  • packages/captive-portal/captive-portal.sh: probe_portal interprets a 200 OK response for the gstatic generate_204 probe as a successful "clean" connection. This probe is defined to return 204; a 200 response typically indicates a portal hijack.
    Fix: Specifically verify that the gstatic probe returns 204.

  • packages/captive-portal/captive-portal.sh: The script calls tailscale and nmcli which require root privileges, but it is designed to run in a user context (using XDG_RUNTIME_DIR and xdg-open). Running the entire script with sudo will cause xdg-open to fail or behave unsafely.
    Fix: Use sudo selectively for networking commands or document the required PolKit/sudoers configuration.

Comment thread packages/captive-portal/captive-portal.sh
Comment thread packages/captive-portal/captive-portal.sh

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7a88bd1008

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/captive-portal/captive-portal.sh
Comment thread packages/captive-portal/captive-portal.sh Outdated
Comment thread packages/captive-portal/captive-portal.sh Outdated
Comment thread packages/captive-portal/captive-portal.sh Outdated
Comment thread packages/captive-portal/captive-portal.sh Outdated
Comment thread packages/captive-portal/captive-portal.sh Outdated
Comment thread packages/captive-portal/captive-portal.sh Outdated
Bad3r added 7 commits August 9, 2026 13:14
…eadable state file

save_prefs re-snapshotted on every login-mode run, so a retry after a failed sign-in recorded CorpDNS=false as the
original value; the following --restore then read corp_dns=false, skipped `tailscale set --accept-dns=true`, and still
printed "DNS returned to Tailscale". Tailscale DNS stayed off with a success message on screen. The snapshot now stands
until --restore consumes it, and it is written through a temporary so a failing `tailscale debug prefs` cannot leave the
0-byte file the old `>"$state_file"` redirect truncated before the pipeline's status was known.

restore_dns read that file with a bare `read -r corp_dns want_running <"$state_file"` inside an if body, where errexit is
not suppressed: on a 0-byte file read returned 1 and killed the script before `tailscale set --accept-dns=true`, `rm -f
"$state_file"`, and `nmcli general reload dns-full`, so every later --restore repeated the same silent abort. Reproduced
against the previous script with a stubbed tailscale: exit 1, no tailscale invocation, state file left in place. Both
fields now fall back to restoring Tailscale when the file is empty, short, or holds anything other than true/false.

`grep '^nameserver' /etc/resolv.conf` ran unguarded at the end of restore_dns and after release_dns. A network that hands
out no DNS before authentication leaves resolv.conf with no nameserver line, and grep's exit 1 then killed the run
before probing, with DNS released and no restore attempted. show_resolvers reports the empty case instead, and names the
127.0.0.53 or 127.0.0.1 stub for what it is, since on a systemd-resolved host resolv.conf never shows the change.

No trap existed for INT or TERM, and probing three canaries takes up to ~24s. A Ctrl-C in that window returned the user
to the shell with Tailscale DNS off and nothing saying --restore was needed; the handler prints that and re-raises.

Validation:
  nix fmt (0 changed)
  shellcheck -s bash packages/captive-portal/captive-portal.sh
  stubbed tailscale/nmcli: --restore against empty, garbage, and honest false state files
The generate_204 canary carried an empty expected string, and the 200|204 arm only inspected the body when that string
was non-empty, so any 200 from connectivity-check.gstatic.com counted as clean. An inline proxy that answers
/generate_204 with its sign-in page instead of redirecting is a common portal shape, and it was read as an open network:
with the other two canaries unreachable the run reported "no portal detected" and, in login mode, handed DNS back to
Tailscale while the user was still walled off. The status is the whole answer for that URL, so 204 is clean and 200 is
the portal. Reproduced against 7a88bd1 with stubbed dig/curl: exit 1 and no URL, against exit 0 and
http://portal.lan/login now.

The is_private_v4 recheck then ran even when the same iteration had already matched the expected payload, so a canary
that answered "success" from a private address was reported as a hijack anyway. Same fixture: 7a88bd1 printed
http://192.168.1.50 for a correct success.txt, and the per-host verdict now short-circuits that check.

is_private_v4 itself missed 169.254.0.0/16 and 100.64.0.0/10, which is where a portal answers when it has issued no
lease and on carrier or guest networks respectively.

device_gateway read $3 out of `ip -4 route show default`, which is the gateway only when the route has a via. An on-link
default route ("default dev wlan0 scope link") put the device name there, so the fallback URL became http://wlan0 and
went to xdg-open. It now reads the field after via and validates the dotted quad, leaving no gateway when there is none.

Device selection took whichever connected wifi or ethernet device nmcli listed first, so a docked laptop probed an
arbitrary one of its two links. The candidate list is sorted wifi-first and then by name, and the devices passed over
are named on stderr.

The per-probe mktemp moved to a single file removed by an EXIT trap, so an interrupted run no longer leaks it.

Validation:
  nix fmt (0 changed)
  shellcheck -s bash packages/captive-portal/captive-portal.sh
  stubbed dig/curl/ip: 204-clean, inline-200 portal, private-address-clean canary, CGNAT hijack, on-link default route
…strand a release

--probe could not report what it found. "No portal detected" and "portal at $portal" both exited 0, so `if
captive-portal --probe` could not tell a clean network from one holding a sign-in page, and the URL was buried among the
diagnostics on stdout. Exit status now names the outcome (0 portal, 1 clean, 2 usage, 3 inconclusive, 4 uninspectable),
stdout carries the URL alone, and every diagnostic goes to stderr. usage() documents the table.

The inconclusive-with-no-gateway path exited 1 on the spot. In login mode release_dns had already turned Tailscale DNS
off, so that exit left the host with DNS released, no portal found, no restore, and no reminder that --restore was
needed: unlike every other exit in the script. Reproduced against 7a88bd1 with stubbed dig/curl returning nothing:
tailscale set --accept-dns=false with no matching --accept-dns=true and the state file left in $XDG_RUNTIME_DIR. That
path now restores before exiting 3.

The gateway fallback is a guess, not a detection: on a network that merely blocks the canaries it is the user's own
router. It was announced with the same "portal at $portal" line a confirmed hijack produced, so the two were
indistinguishable once the browser opened. It now says no portal was confirmed and what the address actually is.

`captive-portal --device --probe` consumed --probe as the device name, stayed in login mode, and released real DNS for a
request that asked to change nothing, then failed with "has no DHCP-provided resolver". A value starting with - is
rejected as usage.

save_prefs failing now exits 4 explicitly instead of relying on errexit to unwind release_dns.

Validation:
  nix fmt (0 changed)
  shellcheck -s bash packages/captive-portal/captive-portal.sh
  stubbed tailscale/nmcli/ip/dig/curl: --device --probe rejected; probe clean exits 1 with empty stdout; probe with a
    portal exits 0 with the URL alone; login clean releases then restores; login inconclusive restores and exits 3
The "Why the portal never appears" section presented the NetworkManager dnsmasq chain as a property of the hosts in this
repository. modules/hosts/common/private-dns-hosts.nix only sets networking.networkmanager.dns = "dnsmasq" and disables
services.resolved for hosts that declare privateDnsHostsSecretKeys, and modules/tpnix/policy.nix is the only declaration
("signalx_hosts"). Evaluating the other host contradicts the doc: system76 reports networkmanager.dns
"systemd-resolved", services.resolved.enable true, and environment.etc."resolv.conf".source
/run/systemd/resolve/stub-resolv.conf, a static symlink whose 127.0.0.53 stub reads identically before and after
captive-portal releases DNS. An operator there followed "Verify both facts" and saw none of the promised output. The
section now separates the two resolvers, opens with readlink -f /etc/resolv.conf to tell them apart, and sends the
systemd-resolved host to resolvectl status, where tailscaled installs the tailnet resolvers against the tailscale0 link.

The by-hand fallback hardcoded `nmcli -t -f IP4.DNS device show wifi0`. wifi0 is the name modules/tpnix/networking.nix
pins through a .link file; system76 keeps the kernel's wlan0, so the command matched no device, resolver came out empty,
and the following dig ran with an empty @ argument. It discovers the connected wireless device instead and says why.

Also documents what the helper now guarantees: the URL alone on stdout, the exit-status table, that status 3 is the
gateway rather than a confirmed portal, and which paths restore DNS on their way out.

Validation:
  nix fmt (1 changed, table reflow)
  nix eval path:.#nixosConfigurations.system76.config.networking.networkmanager.dns
  nix eval path:.#nixosConfigurations.system76.config.services.resolved.enable
  nix eval path:.#nixosConfigurations.system76.config.environment.etc."resolv.conf".source
  nix eval path:.#nixosConfigurations.tpnix.config.networking.networkmanager.dns
…stubbed network tools

captive-portal is instantiated only through the overlay in modules/custom-overlays/captive-portal.nix, so a host build
lints the script with shellcheck and stops there. Every decision it makes depends on what dig, curl, nmcli, ip and
tailscale answer, none of which exists in a build, so nothing had ever executed it. The eleven defects fixed in 7cee48b,
1a45cb3 and 45e7d1a all lived in that gap.

All five tools are package arguments, so callPackage substitutes stubs and writeShellApplication's PATH prefix puts them
ahead of the sandbox: the packaged script runs unmodified against a scripted network. Each scenario pins one failure
that shipped, verified by building this check against 7a88bd1:

  --device --probe consumed the flag as a device name and released real DNS
  a 200 from generate_204 counted as a clean network instead of an inline portal
  a canary answering "success" from a private address was reported as a hijack
  100.64.0.0/10 and 169.254.0.0/16 answers were not recognised as hijacks
  an inconclusive probe with no gateway exited leaving Tailscale DNS off
  an on-link default route printed http://wifi0 as the portal URL
  a retry re-snapshotted the released prefs, so --restore left DNS off and said otherwise
  an empty or truncated state file aborted --restore before it restored anything
  the device pick followed nmcli's listing order rather than preferring wifi

/etc/resolv.conf does not exist in the sandbox, which is also what a portal that hands out no lease produces, so the
login scenarios additionally cover the unguarded grep that used to abort between releasing DNS and probing.

Validation:
  nix fmt (0 changed)
  nix build path:.#checks.x86_64-linux."packages/captive-portal-runtime" (exit 0)
  same check against 7a88bd1's script: fails on the first scenario
  per-finding comparison of both scripts' helpers under stubs: is_private_v4, device_gateway, device pick
probe_portal returned 2 for "every probe was inconclusive" and the caller passed that value straight to exit, so a run
that fell back to the gateway exited 2, which usage() and docs/networking/README.md both define as invalid usage. The
empty-gateway branch was the only inconclusive path that reached the documented 3, because that one is written out
literally. probe_portal now returns the status the script exits with, and the check gained the case that missed it:
inconclusive probes with a gateway present exit 3, print http://192.168.1.1 on stdout, and say no portal was confirmed.

Validation:
  nix fmt (0 changed)
  shellcheck -s bash packages/captive-portal/captive-portal.sh
  nix build path:.#checks.x86_64-linux."packages/captive-portal-runtime" (exit 0)
  stubbed dig/curl/ip: --probe with unreachable canaries exits 3 with the gateway URL and touches no tailscale state
…aces

The file header presented NetworkManager's dnsmasq at 127.0.0.1 as the resolver tailscaled shadows. That holds only for
hosts selected by modules/hosts/common/private-dns-hosts.nix, which is tpnix alone; system76 evaluates to
networkmanager.dns "systemd-resolved" with services.resolved enabled, and tailscaled installs its configuration there
rather than through resolvconf. The behaviour the script implements is the same in both cases, so the header now names
both resolvers instead of one, matching the correction made to docs/networking/README.md in dfad85e.

Validation:
  nix fmt (0 changed)
  shellcheck -s bash packages/captive-portal/captive-portal.sh
  nix build path:.#checks.x86_64-linux."packages/captive-portal-runtime" (exit 0)
Comment thread packages/captive-portal/captive-portal.sh
Comment thread packages/captive-portal/captive-portal.sh Outdated
Comment thread packages/captive-portal/captive-portal.sh Outdated
Comment thread modules/packages/captive-portal-check.nix
The exit-3 outcome means no probe confirmed a portal and the address printed is whatever answers as the default gateway,
which on a network that merely blocks detectportal.firefox.com, captive.apple.com and connectivity-check.gstatic.com is
the user's own router. Handing that to xdg-open opened the router admin page unasked. Only a confirmed detection now
reaches the browser; the guess is printed, and the closing text says it was not opened and that DNS stays released until
--restore runs.

The runtime check gained the case. It runs the guess first and the confirmed detection second, so the wait for the
detection's xdg-open doubles as the settle time the guess had to produce one: the script backgrounds that call, so a log
read taken the instant it exits proves nothing. Verified by ungating the launch, which fails the check with "a gateway
guess must not be opened in a browser".

Validation:
  nix fmt (0 changed)
  shellcheck -s bash packages/captive-portal/captive-portal.sh
  nix build path:.#checks.x86_64-linux."packages/captive-portal-runtime" (exit 0; exit 1 with the launch ungated)
  stubbed run: exit 3 prints http://192.168.1.1 on stdout with no xdg-open call
Comment thread packages/captive-portal/captive-portal.sh
Comment thread packages/captive-portal/captive-portal.sh Outdated
…ract

4043c3a pinned curl's --resolve because curlStub answers from the URL alone and nothing
else would catch its loss. digStub has the identical shape: it answers from the host name
alone via "${!#}" and never inspected "@$resolver", so deleting "@$resolver" from the dig
call in probe_portal (packages/captive-portal/captive-portal.sh) left every scenario green
while production queried the ambient resolver instead, 100.100.100.100 under --probe
(docs/networking/README.md). $answer would stop describing anything the access point
returned, and the hijack arm plus both sinkhole guards would grade an address no portal
chose.

The extraction loop matches curlStub's own shape, a `for arg in "$@"` scan rather than a
whole-command-line substring match, so a failure names just the resolver value it got, not
the full argument list.

Confirmed non-vacuous: dropping "@$resolver" from the dig call fails the check with the
stub's own "must query the access-point resolver" message.

Validation: nix build path:.#checks.x86_64-linux."packages/captive-portal-runtime" (exit 0), plus that mutation
(exit 1).
@Bad3r

Bad3r commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Both automated-review findings from the latest review-with-tracking pass are addressed.

Implemented

  • packages/captive-portal/captive-portal.sh, modules/packages/captive-portal-check.nix (ca8e324): save_prefs
    refuses to write the snapshot through a symlinked state_dir. Under sudo, that directory sits inside a tree the
    invoking user owns; unguarded, mkdir -p is a silent no-op against a pre-existing symlink, and
    hand_state_to_invoker's chown follows it, handing a root-owned target's ownership to the invoking user. No
    privilege boundary crosses on hosts where sudo is wheel-wide, so this is hardening, not a fix for an active
    escalation. Added a sandbox-safe regression scenario (the guard fires unconditionally, independent of
    SUDO_UID/root) and confirmed it fails without the guard.
  • modules/packages/captive-portal-check.nix (fe46ce7): digStub now pins the @resolver argument the same way
    curlStub already pins --resolve (4043c3a), closing the identical test-coverage gap on dig's half of the
    classification contract. Uses a per-argument extraction loop matching curlStub's own style rather than a
    whole-command-line substring match. Confirmed non-vacuous.

Rejected: none. Both findings verified accurate against current code with no false claims.

Decisions escalated to the user: none. Both fixes were fully derivable from the repo (the existing --resolve
precedent, the existing test-harness patterns, the documented sudo captive-portal path), so no AskUserQuestion
was needed.

Consequential fixes beyond the literal comments: updated the PR body's "Details worth review" and "Test plan"
sections to describe the new symlink-hardening behavior and the newly enumerated regression scenario, keeping the
PR description consistent with the shipped code.

Reliability notes: both fixes land inside existing, already-proven patterns rather than new machinery: the
symlink guard reuses save_prefs's established note/return 1 failure idiom (same shape as its two sibling
guards), and its regression test reuses the XDG_RUNTIME_DIR-override harness already exercised by the adjacent
denied-create/denied-write scenarios. The digStub fix mirrors curlStub's already-reviewed
argument-validation shape for consistency. Every new assertion was confirmed non-vacuous by reverting the
corresponding fix and watching that specific assertion fail, not just by re-running the suite green.

Validation: nix build path:.#checks.x86_64-linux."packages/captive-portal-runtime" exits 0 (plus each mutation
exiting nonzero as expected); repo formatter reports 0 changed; shellcheck clean; pre-commit's git hooks
(shellcheck, statix, deadnix, nix-parse, treefmt, typos) passed on both commits.

Comment thread packages/captive-portal/captive-portal.sh
Comment thread packages/captive-portal/captive-portal.sh
Comment thread packages/captive-portal/captive-portal.sh Outdated
Bad3r added 4 commits August 10, 2026 18:47
…his machine

probe_portal checked the scheme of a 30x Location and of $found (the value extract_url reads from
formaction/action, url, or href), but never the host. curl --resolve only pins where the request goes: a portal
or a filtering resolver in front of it chooses Location and the page body freely, so
Location: http://127.0.0.1/ or <form action="http://127.0.0.1/"> reached xdg-open unfiltered, the exact harm the
sinkholed-canary guard on $answer (127.0.0.0/8, 0.0.0.0) exists to stop, on the two paths it does not cover.

is_loopback_host applies that same exclusion, plus localhost and ::1 which is_private_v4 never had to name since
$answer only ever carries a dotted-quad, to both $redirect and $found. A rejected $redirect falls through to the
hijack check the way an unmatched status already does; a rejected $found falls back to http://$answer the way an
empty one already does.
…orm target

extract_url's only exclusion was w3.org, so a relative <form action="/login"> left tier 1 and tier 2 empty and
tier 3 took the first absolute href on the page. A stylesheet or script sitting ahead of the form in <head>, the
same shape 28eb90a's absolute-action scenario already covers, then won: the run printed the asset URL as the
portal and xdg-open'd it, and the sign-in form was never shown. The two existing relative-action scenarios both
happened to use a w3.org href, so the existing filter hid the gap.

An asset extension is never a sign-in target in any of the three tiers, so the filter sits next to the w3.org one
in the shared pipeline rather than in tier 3 alone. Finding nothing still falls back to http://$answer, the host
that served the page, the same as the existing "no usable link" case.
…ss exists

probe_portal returns 3 with nothing on stdout when device_gateway found no via (an on-link default route, or no
default route at all), and the caller already exits 3 on an empty $portal without printing one. usage() and the
status table in docs/networking/README.md both stated unconditionally that status 3 prints a gateway guess, so a
wrapper written against either contract would read an empty string as a URL. The in-source comment above
probe_portal already had the qualifier right; the two documented contracts did not.

No behavior changes: probe_portal's own comment already named "when there is one", and the two runtime-check
scenarios for the no-gateway path (empty stdout, exit 3) already pass unchanged.
…acted link

7df8b4c added is_loopback_host so a Location or an extracted link naming this machine, localhost, or ::1 is
dropped the same way a canary sinkholed to one already was, but this section still described only the scheme
check that predated it: "Nor is a Location header trusted for its scheme: only http and https targets are ever
printed or opened." That understated the current guard and left a reader unaware the host is checked too.
@Bad3r

Bad3r commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Sixteenth review round resolved

Three threads, four commits. All three held; two were implemented past the letter of the suggested diff to close a gap the comment itself named but the diff didn't cover.

Implemented

  • packages/captive-portal/captive-portal.sh, modules/packages/captive-portal-check.nix (7df8b4c): added is_loopback_host (127.0.0.0/8, 0.0.0.0, localhost, ::1) and applied it at both sites the comment named: $redirect in the 30x branch, and $found at the extraction site, which had the identical gap since extract_url only ever constrained the scheme. The suggested diff covered only $redirect; the comment's own text said both needed it. Also closed a ?/#-suffix gap the suggested glob left open (0.0.0.0?x=1 and localhost#x would otherwise still pass). Two new scenarios: a loop over four loopback Location spellings, and a loopback formaction.
  • packages/captive-portal/captive-portal.sh, modules/packages/captive-portal-check.nix (85082fd): added an asset-extension exclusion (css/js/mjs/png/jpe?g/gif/svg/ico/woff2?/ttf/eot) to extract_url's shared filter pipeline, next to the w3.org one, exactly as suggested. A relative <form action="/login"> with an absolute stylesheet href in <head> previously fell through to tier 3 and printed the asset URL. New scenario reproduces the comment's exact page shape.
  • packages/captive-portal/captive-portal.sh, docs/networking/README.md (d903108): usage() and the README's status table both said status 3 unconditionally prints a gateway guess; probe_portal only does when device_gateway found one, and the in-source comment above it already said so. Both now read "if there is one". No behavior change; the two no-gateway scenarios already asserted empty stdout on exit 3.

Rejected: none. All three findings verified accurate against current code.

Decisions escalated to the user: none. All three fixes were fully derivable from the repo (the sinkhole-guard precedent already on $answer, the w3.org filter precedent already in extract_url, the in-source comment probe_portal already carried), so no AskUserQuestion was needed.

Consequential fixes beyond the literal comments:

  • docs/networking/README.md (08b2cf2): the security-model paragraph above the status table still said a Location header is trusted only for its scheme, which 7df8b4c made incomplete without also saying the host is now checked. Updated it to describe both checks.

Reliability notes: both .sh fixes reuse an established pattern rather than inventing one: is_loopback_host is is_private_v4's sibling, called from both sites through one shared predicate instead of two copies of an inline case, and the asset filter is a fourth line in the same grep -v chain the w3.org filter already established. Every new scenario was traced by hand against the unpatched pipeline before the fix landed (the extract_url case tier-by-tier), confirming each reproduces the exact failure the comment described rather than passing vacuously.

Validation: nix build path:.#checks.x86_64-linux."packages/captive-portal-runtime" exits 0 after each of the four commits; repo formatter reports 0 changed; pre-commit's git hooks (shellcheck, statix, deadnix, nix-parse, treefmt, typos) passed on all four.

Comment thread packages/captive-portal/captive-portal.sh
Comment thread packages/captive-portal/captive-portal.sh Outdated
Comment thread packages/captive-portal/captive-portal.sh
Bad3r added 3 commits August 10, 2026 19:12
…symlinked state directory

save_prefs (ca8e324) refuses to write through a symlinked state_dir, but restore_dns reaches the same path with
no such check: it reads $state_file and later rm -f's it. sudo captive-portal --restore is the documented
invocation (docs/networking/README.md), and under sudo, state_dir falls back to /run/user/$SUDO_UID/captive-portal,
a name the invoking user controls. state_dir being a symlink is a parent-directory path component, dereferenced by
the kernel on every access through it, so a symlink planted there has root's read and root's rm -f both operate
inside whatever directory the invoking user named, on the half of this script that runs as root more often than
save_prefs does.

The check exits 4 rather than returning, matching restore_dns's other failure paths: its only call site does
restore_dns; exit 0 with no rc capture, so a bare return would be swallowed into a false success.
…racted link against this machine

is_loopback_host tested ${1#*//} directly, which is the whole authority including userinfo: http://x@127.0.0.1/
strips to x@127.0.0.1/, matches no arm, and the 30x branch prints it and hands it to xdg-open exactly as the bare
form would. Same at the extraction site: <form action="http://a@localhost/pwn"> was printed as the sign-in URL.
Userinfo is a field of the authority curl's redirect_url and extract_url's regex both leave untouched.

The path is stripped first (${authority%%/*}) so the trailing patterns only ever see a bare host or host:port, and
userinfo is stripped with ##*@ rather than #*@: a parser splits on the last @ in a multi-userinfo authority
(http://a@b@127.0.0.1/ resolves host=127.0.0.1 in both the WHATWG URL algorithm and urllib.parse), and #*@ takes
the first, leaving that shape unguarded.
…he body

85082fd's extension filter only catches an asset URL ending in one, so the two commonest <link> shapes still won
tier 3: a preconnect hint (<link rel="preconnect" href="https://fonts.gstatic.com">) has no path at all, and a
stylesheet behind a font-service query string (.../css2?family=Inter) has no dot before its extension. On the
relative-form-action page shape 85082fd already covers, tier 3 returned the font host, and the run printed and
opened it instead of falling back to the address that answered the hijacked lookup.

A <link> element is metadata: never a submit target, never a refresh, so its href is dropped before any tier reads
the body rather than guessed at by extension. The extension filter stays for an <a href> or <form action> pointing
at an asset directly, which the strip does not touch.
@Bad3r

Bad3r commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Seventeenth review round resolved

Three threads, three commits. All three held, all against code from the two immediately preceding rounds: two on is_loopback_host/extract_url from round sixteen, one on save_prefs's sibling restore_dns from round fifteen.

Implemented

  • packages/captive-portal/captive-portal.sh, modules/packages/captive-portal-check.nix (2f23361): restore_dns now refuses a symlinked state_dir the same way save_prefs already does, before either the read or the later rm -f reaches it. Uses exit 4 rather than return, matching restore_dns's other failure paths (its only call site is restore_dns; exit 0 with no rc capture, so a bare return would have been swallowed into a false success). New scenario mirrors the existing save_prefs symlink test for --restore.
  • packages/captive-portal/captive-portal.sh, modules/packages/captive-portal-check.nix (7348ebb): is_loopback_host now strips the path and the userinfo before matching, closing a bypass where http://x@127.0.0.1/ matched no arm. Implemented past the letter of the suggested diff: used ##*@ (last @) rather than the suggested #*@ (first @), since a multi-userinfo authority (http://a@b@127.0.0.1/) resolves host=127.0.0.1 under both the WHATWG URL algorithm and urllib.parse, and #*@ would have left that shape unguarded. New scenario for the double-@ case specifically catches this; confirmed non-vacuous by mutating ## back to # and watching only that scenario fail.
  • packages/captive-portal/captive-portal.sh, modules/packages/captive-portal-check.nix (f54f7e2): extract_url now strips every <link> element before any tier reads the body, exactly as suggested, closing the gap where a preconnect hint or a query-string-suffixed stylesheet href had no extension for the round-sixteen filter to match. The extension filter stays for an <a href>/<form action> pointing at an asset directly.

Rejected: none. All three findings verified accurate against current code.

Decisions escalated to the user: none. All three fixes were fully derivable from the repo and from cross-checking against standard URL-parsing behavior (Node's WHATWG URL, Python's urllib.parse), so no AskUserQuestion was needed.

Consequential fixes beyond the literal comments:

  • ## Summary / PR body (this update): the "Details worth review" bullet describing the symlink guard named only save_prefs. Updated it to cover restore_dns too, now that both halves refuse a symlinked state_dir.

Reliability notes: the userinfo fix is the one place this round improved on its own suggested diff rather than just applying it, verified independently against two different URL-parsing implementations before committing to ##*@ over #*@. All three new regression scenarios were confirmed non-vacuous by reverting the corresponding fix and watching the specific assertion fail, not just by re-running the suite green; the restore_dns scenario additionally asserts the planted file survives the run, not just that the exit code is right, so it can't pass on a guard that fires too late to matter.

Validation: nix build path:.#checks.x86_64-linux."packages/captive-portal-runtime" exits 0 after each of the three commits; repo formatter reports 0 changed; pre-commit's git hooks (shellcheck, statix, deadnix, nix-parse, treefmt, typos) passed on all three.

Comment thread packages/captive-portal/captive-portal.sh Outdated
Comment thread packages/captive-portal/captive-portal.sh
Comment thread packages/captive-portal/captive-portal.sh Outdated
Bad3r and others added 3 commits August 20, 2026 09:04
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Comment thread packages/captive-portal/captive-portal.sh Outdated
Comment thread modules/packages/captive-portal-check.nix
Bad3r added 2 commits August 21, 2026 11:54
The runtime check covered single-line link stripping and lowercase loopback spellings, but it did not constrain wrapped metadata attributes or case-insensitive and unspecified IPv6 loopback targets. Add those scenarios and document the existing URL flattening, host normalization, and symlink-safe ownership behavior.
Network-controlled form, href, meta-refresh, and Location values reach diagnostics and xdg-open. GNU grep also treats a NUL-bearing response as binary and can suppress extraction. Normalize NUL before parsing, exclude control bytes from extracted targets, reject control-bearing redirects, and cover the behavior with raw-NUL, ESC, and --no-open runtime scenarios.

Validation: bash -n packages/captive-portal/captive-portal.sh; shellcheck packages/captive-portal/captive-portal.sh; nix build path:.#checks.x86_64-linux."packages/captive-portal-runtime".
@Bad3r

Bad3r commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Review feedback resolved

Implemented:

  • f14c8a41 preserves the pre-existing worktree patch while synchronizing to the live PR head. It adds runtime coverage for wrapped <link> attributes and case-insensitive and unspecified-IPv6 loopback targets, with comments documenting the related existing behavior.
  • 0acca353 addresses both current unresolved findings. Extracted targets now normalize NUL before GNU grep, exclude control bytes, and retain only a safe prefix. Control-bearing Location targets are rejected before the http(s) arm. The runtime check covers raw NUL, ESC-bearing form and redirect targets, control-free stdout/stderr/browser arguments, and the redirect fallback.
  • The runtime check now proves --no-open still returns the portal URL and suppresses xdg-open in a dedicated log after the background-process settle window.

Rejected items: None. Both current unresolved findings were validated as actionable and implemented.

Decisions requested: None. No preference, identity, or consequential policy value was missing from the repository contract.

Consequential follow-on fixes:

  • The PR body was synchronized with the current chown -h implementation and the new URL-sanitization and regression-coverage contracts.
  • The networking documentation now states that control-bearing extracted targets are excluded and control-bearing Location targets are rejected before diagnostics or browser launch.

Validation:

  • bash -n packages/captive-portal/captive-portal.sh
  • shellcheck packages/captive-portal/captive-portal.sh
  • Targeted formatter on the three touched files, with zero changes.
  • Repository pre-commit hooks passed, including shellcheck, statix, deadnix, treefmt, and typos.
  • nix build path:.#checks.x86_64-linux."packages/captive-portal-runtime" passed.

Reliability: The new paths fail closed for malformed network-selected URLs, keep stdout suitable for wrappers, keep --no-open behavior explicit, and preserve the existing DNS and Tailscale lifecycle checks around portal detection and restoration.

Comment on lines +284 to +291
tr '\n\000' ' ' <"$body_file" |
sed 's/<link[^>]*>//gI' |
grep -oiE '(^|[;[:space:]])('"$1"')=["'"'"']?https?://[^"'"'"'<>[:space:][:cntrl:]]+' |
grep -oiE 'https?://[^"'"'"'<>[:space:][:cntrl:]]+' |
grep -viE '^https?://([^/]*\.)?w3\.org([/:?]|$)' |
grep -viE '\.(css|js|mjs|png|jpe?g|gif|svg|ico|woff2?|ttf|eot)([?#]|$)' |
head -1 |
sed 's/&amp;/\&/g' || true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[ISSUE] &amp; ends in ;, and ; is in the anchor class, so the ?url=/&url= hole b3413e8 closed is reopened by the encoding HTML requires for that very separator.

A literal & inside an attribute value must be written &amp;, so a portal page that parks the originally-requested address in a query parameter emits ...&amp;url=http://.... Tier 2 anchors on (^|[;[:space:]]), the character immediately before url= is the ; of &amp;, and the match fires. Decoding happens at line 291, after the anchoring grep has already run, so it cannot help.

Concretely, with a relative form action (so tier 1 finds nothing):

<a href="/login?res=x&amp;url=http://detectportal.firefox.com/success.txt">continue</a>
<form action="/auth"></form>

tier 2 extracts http://detectportal.firefox.com/success.txt, and the run prints the canary's own URL as the portal and hands it to xdg-open — on a proxy portal that is a public host the browser will never reach from inside the garden. The scenario at modules/packages/captive-portal-check.nix:467 pins the raw ?url= form and passes for two independent reasons (the anchor excludes it and that page's <form action> is absolute), so it does not cover this variant.

Decoding before the anchored grep fixes it without touching the anchor: &amp;url= becomes &url=, and & is not in the class, while content="0; url=..." and content="0;url=..." still match. The trailing decode then becomes redundant and must go, or &amp;amp; would be decoded twice.

Suggested change
tr '\n\000' ' ' <"$body_file" |
sed 's/<link[^>]*>//gI' |
grep -oiE '(^|[;[:space:]])('"$1"')=["'"'"']?https?://[^"'"'"'<>[:space:][:cntrl:]]+' |
grep -oiE 'https?://[^"'"'"'<>[:space:][:cntrl:]]+' |
grep -viE '^https?://([^/]*\.)?w3\.org([/:?]|$)' |
grep -viE '\.(css|js|mjs|png|jpe?g|gif|svg|ico|woff2?|ttf|eot)([?#]|$)' |
head -1 |
sed 's/&amp;/\&/g' || true
tr '\n\000' ' ' <"$body_file" |
sed -e 's/<link[^>]*>//gI' -e 's/&amp;/\&/g' |
grep -oiE '(^|[;[:space:]])('"$1"')=["'"'"']?https?://[^"'"'"'<>[:space:][:cntrl:]]+' |
grep -oiE 'https?://[^"'"'"'<>[:space:][:cntrl:]]+' |
grep -viE '^https?://([^/]*\.)?w3\.org([/:?]|$)' |
grep -viE '\.(css|js|mjs|png|jpe?g|gif|svg|ico|woff2?|ttf|eot)([?#]|$)' |
head -1 || true

Fix this →

@Bad3r
Bad3r marked this pull request as draft September 8, 2026 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area(docs) Repository documentation is a significant affected surface. area(networking) Networking services, DNS, VPN, SSH transport, or connectivity. area(packages) Package definitions, overrides, overlays, or package-source selection. host(tpnix) Specific to the tpnix host or its runtime contract. type(enhancement) Net-new capability or intentional improvement.

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant