Note
The portable worker, dispatcher, and unit completed a controlled Arch Linux
post-migration live validation on 2026-08-18. A captive activation exercised
the /api/accept POST path exactly once, then passed an interface-bound HTTPS
probe and NetworkManager's per-device IPv4 FULL check.
This repository contains the portable Phase 1 NetworkManager and systemd integration for the CDWiFi captive portal. It is fail-closed and inert until an administrator explicitly authorizes at least one saved NetworkManager connection UUID.
The validation used the portable multi-profile authorization and vendor-path installation described below. It retained a durable claim for the exact NetworkManager activation generation and did not expose the portal secret in arguments, logs, or persistent files.
cdwifi-autoaccept targets Linux laptops and desktops that boot with systemd,
use NetworkManager to manage the Wi-Fi interface, and have NetworkManager
connectivity checking enabled and configured so that the interface can reach
numeric IPv4 connectivity state 4 (FULL). Using iwd as NetworkManager's
Wi-Fi backend is expected to work but has not been validated separately;
NetworkManager must remain the connection owner.
Meeting the version floors below, or being able to install the .deb or .rpm,
is necessary but is not by itself evidence of end-to-end compatibility. The
project records distinct validation levels:
| Platform | Validation level |
|---|---|
| Arch Linux, tested 2026-08-18 | Live end-to-end: real CDWiFi activation, dispatcher invocation, exactly one portal POST, independent HTTPS proof, and per-device IPv4 FULL |
| Debian 13 | Required release gate: container package installation and removal with apt; no booted systemd, NetworkManager, Wi-Fi, or live portal test |
| Ubuntu 24.04 LTS | Required release gate: container package installation and removal with apt; no booted systemd, NetworkManager, Wi-Fi, or live portal test |
| Fedora 44 | Required release gate: container package installation and removal with dnf; no booted systemd, NetworkManager, Wi-Fi, or live portal test |
| openSUSE Leap 16.0 | Required release gate: container package installation and removal with zypper; no booted systemd, NetworkManager, Wi-Fi, or live portal test |
The container checks exercise native dependency resolution, installed paths, ownership and modes, the installed Python entry point, dispatcher syntax, systemd unit verification, operational-file integrity, and removal. Before upload, the build also exact-checks the complete package payload, including the documentation and license files that a distribution's installation policy may omit. The checks do not exercise systemd as PID 1, the system D-Bus, NetworkManager dispatcher behavior, interface-bound traffic, host SELinux or AppArmor policy, or CDWiFi. Results for one row do not establish compatibility with every Debian- or RPM-based system. The gate applies to one immutable package artifact: a revision remains unverified until all four jobs for that exact artifact have passed, and release publication is blocked if any job fails.
Server and minimal installations using systemd-networkd, wicked, standalone
iwd, or another network owner are outside the current support contract, as
are non-systemd systems, containers as runtime targets, WSL, and installations
forced onto systems below the declared dependency floors. Such systems may be
adaptable, but are supported only on a best-effort basis.
Each authorized NetworkManager profile has one file named
/etc/cdwifi-autoaccept/connections.d/<uuid>.json. The filename UUID must be a
canonical lowercase, non-nil UUID. The file must be a real root-owned regular
file (not a symlink) with no group or world write bit, containing a JSON object
with exactly these fields and values. The configuration CLI creates mode
0600:
{"schema_version":1,"connection_uuid":"<uuid>","eula_accepted":true}Unknown, missing, duplicate, or mistyped fields are rejected. The UUID in the object must exactly match the filename. The configuration directories must be real root-owned directories and must not be group- or world-writable.
The package never creates an authorization file. With no valid files it does nothing. Multiple profiles can be authorized independently; removing one profile's file revokes only that profile. Authorization means the administrator has accepted the portal EULA for that connection.
Create a production authorization offline, before connecting to the portal:
sudo install -d -o root -g root -m 0755 /etc/cdwifi-autoaccept
sudo install -d -o root -g root -m 0755 \
/etc/cdwifi-autoaccept/connections.d
connection_uuid=$(nmcli --get-values connection.uuid connection show 'CDWiFi')
sudo /usr/libexec/cdwifi-autoaccept \
--configure "$connection_uuid" --acknowledge-eulaThe command validates the UUID, writes the exact schema atomically, and refuses
to overwrite an existing file. --acknowledge-eula is mandatory. To exercise
configuration creation without touching /etc, use an already-created private
directory owned by the invoking user:
test_root=$(mktemp -d)
install -d -m 0700 "$test_root/connections.d"
./src/cdwifi_autoaccept.py \
--configure 11111111-2222-4333-8444-555555555555 \
--acknowledge-eula \
--config-directory "$test_root/connections.d"NetworkManager invokes the vendor dispatcher for connection events. The
dispatcher handles only up, strictly validates the interface and
CONNECTION_UUID, and checks that the corresponding authorization pathname is
a regular non-symlink file. This is only a start optimization. The dispatcher
does not read or source configuration and performs no HTTP. It escapes the
interface and asynchronously starts cdwifi-autoaccept@<interface>.service.
The bounded, hardened oneshot receives only the interface. The worker obtains
the live UUID from NetworkManager and validates the authorization itself using
no-follow opens, ownership and mode checks, and an exact JSON parse. It also
captures the system-bus ID, NetworkManager owner, active-connection object path,
connected state, and an IPv4 gateway in the CDWiFi 10.200.0.0/16 allowlist.
Those activation and authorization guards are rechecked around every sensitive
step.
For independent online proof, the worker first reads NetworkManager's
ConnectivityCheckUri over D-Bus. An http URI is upgraded to the same URI
over https; an https URI remains HTTPS. Unsafe or unavailable values are
ignored. The explicit https://ping.archlinux.org/nm-check.txt fallback is
also tried when distinct. Requests use curl with normal TLS verification,
forced IPv4, a fail-closed if!<interface> binding, no proxy, no redirects,
and bounded time and size. A TLS response with status 2xx proves the independent
probe, but final success additionally requires numeric per-device NetworkManager
IPv4 connectivity 4 (FULL) after an explicit connectivity check.
If the link is captive, the exact portal policy is:
- GET only
http://www.cdwifi.cz/, pinned to the guarded link gateway, with redirects and proxies disabled; a second bounded GET attempt is allowed. - Require HTTP 200 and exactly one POST form whose decoded action is literally
one of the two observed values,
./acceptor/api/accept. They map only to/acceptand/api/accept; every other spelling or URL is rejected. Require exactly one validsecretcontrol and one enabledeula=oncheckbox. - Recheck the activation and its authorization, then claim the activation in
/run/cdwifi-autoaccept/post-ledger/. The claim must pass both marker-file and ledger-directory durability checks before submission. - POST
secret=<dynamic value>&eula=onat most once to the same HTTP origin. The form body goes through standard input and is never placed in process arguments or a persistent form file. - Never retry an uncertain POST. Require a later HTTPS 2xx proof and per-device IPv4 FULL before reporting success.
The SHA-256 .json filename binds a content-free mode-0600 marker to the
system-bus generation, NetworkManager owner, and active-connection path.
O_CREAT|O_EXCL|O_NOFOLLOW selects one concurrent winner. Any safe existing
marker, including old JSON, a partial write, or a marker observed before
fsync, is conservatively claimed; unsafe metadata fails closed. A new claim
must pass file and directory fsync before POST, and any durability error keeps
the marker while suppressing POST.
Old JSON markers are forward-compatible. An older payload-validating worker
will fail closed on a new empty marker, so never delete the ledger merely to
roll back; wait for a new activation generation or reboot instead.
RuntimeDirectoryPreserve=yes retains locks and claims across oneshot exits and
upgrades. A rapid reconnect may still be conservatively lost if systemd
coalesces its start request; the worker favors no duplicate POST over retry.
The bounded portal GET response body and cookies stay in a mode-0700
per-request directory under systemd's PrivateTmp. Probe and POST response
bodies go to /dev/null, and response headers are not stored. The temporary
tree is torn down when the oneshot stops, including after a crash. Logs contain
no raw page, command stderr, cookie, form body, portal token, or token hash.
The supported runtime baseline is:
- NetworkManager 1.16 or newer, managing the target Wi-Fi interface with a
configured and enabled connectivity check, including
nmcliand per-device IPv4 connectivity state; - systemd 247 or newer, including
busctl,systemctl,systemd-escape, and all hardening directives used by the unit; - Python 3.11 or newer;
- curl 8.4 or newer, for a hard response-size bound even when the server does not declare a content length; and
- a shell and
grepas supplied by the supported distribution.
The captured portal page is deliberately outside this repository. Synthetic tests contain no real token. From the project root run:
python3 -m unittest discover -s tests -v
ruff check .
ruff format --check .
shellcheck NetworkManager/dispatcher.d/90-cdwifi-autoaccept live-canary.command
sh -n NetworkManager/dispatcher.d/90-cdwifi-autoaccept
bash -n live-canary.commandWhen just is installed, just check runs these checks plus verification of a
temporary staged root. The Justfile is developer convenience only; package
builds and CI call the underlying commands directly and do not require just.
The unit deliberately names the final vendor executable, so a bare source-tree
systemd-analyze verify reports that /usr/libexec/cdwifi-autoaccept is
missing. Verify the unit against the temporary staged root shown below.
Optional sanitized inspection of a locally captured page reports only method, normalized action, secret presence and length, and EULA value:
./src/cdwifi_autoaccept.py --inspect-html /path/to/private-portal-page.htmlThe release sequence publishes Debian-family and RPM-family assets first. Once the tagged source archive exists and its checksum can be fixed, a separate AUR packagebase tracks that archive:
- AUR package:
cdwifi-autoaccept; - Debian package from
GitHub Releases:
cdwifi-autoaccept_<version>-1_all.deb; and - RPM package from GitHub Releases:
cdwifi-autoaccept-<version>-1.noarch.rpm.
Tagged GitHub releases carry the .deb, .rpm, and a SHA256SUMS file that
covers both packages. Download all three into one directory and verify them
before installation with sha256sum -c SHA256SUMS.
The packaging recipes declare these direct runtime dependencies:
| Package | Declared dependencies |
|---|---|
| AUR | bash, curl>=8.4, grep, networkmanager>=1.16, python>=3.11, systemd>=247 |
| Debian | curl (>= 8.4), network-manager (>= 1.16), python3 (>= 3.11), systemd (>= 247); the Essential base supplies the shell and grep |
| RPM | /bin/sh, curl >= 8.4, grep, NetworkManager >= 1.16, python3 >= 3.11, systemd >= 247 |
Installing any native package is inert apart from a systemd daemon reload: it does not create configuration, acknowledge the portal EULA, authorize a connection, enable or start a service, or make a network request. Configure a connection explicitly with the command in Authorization model after installation.
VERSION is the canonical upstream MAJOR.MINOR.PATCH value used by native
package metadata and v<VERSION> release tags. It is build metadata and is not
part of the installed payload.
The portable vendor paths are /usr/libexec, /usr/lib/systemd/system,
/usr/lib/NetworkManager/dispatcher.d, /usr/share/doc, and
/usr/share/licenses. A direct source installation uses:
sudo install -Dm0755 src/cdwifi_autoaccept.py \
/usr/libexec/cdwifi-autoaccept
sudo install -Dm0755 NetworkManager/dispatcher.d/90-cdwifi-autoaccept \
/usr/lib/NetworkManager/dispatcher.d/90-cdwifi-autoaccept
sudo install -Dm0644 systemd/cdwifi-autoaccept@.service \
/usr/lib/systemd/system/cdwifi-autoaccept@.service
sudo install -Dm0644 README.md \
/usr/share/doc/cdwifi-autoaccept/README.md
sudo install -Dm0644 LICENSE \
/usr/share/licenses/cdwifi-autoaccept/LICENSE
sudo systemctl daemon-reloadFor a package payload, stage the same layout without changing the host:
staging_root=$(mktemp -d)
python3 packaging/stage.py --destdir "$staging_root"
systemd-analyze verify --recursive-errors=no --root="$staging_root" \
cdwifi-autoaccept@.service--destdir is mandatory and must name an absolute path other than /. The
stager copies only the five documented payload files with fixed modes. It does
not change ownership, invoke systemd or NetworkManager, access the network, or
create authorization and runtime state. Native package recipes should call the
Python stager directly rather than depend on just.
--recursive-errors=no excludes missing base-system units from the partial
staging root while still checking the staged service and executable. The
staging directory is a build artifact; do not install it as an extra root-level
directory.
There is nothing to enable. Installation or upgrade may run
systemctl daemon-reload only. It must never start or restart an instance,
create a connection authorization, or remove /run/cdwifi-autoaccept or its
ledger. NetworkManager will trigger a future authorized up event.
Important migration rule: a legacy dispatcher under
/etc/NetworkManager/dispatcher.d/ and this vendor dispatcher must not coexist
or operate together. Installing a native package does not migrate an existing
manual installation. Disable and remove the legacy dispatcher only as a later,
separately reviewed migration to the vendor layout; do not treat package
installation as authorization to alter a live manual arrangement.
After a reviewed migration, arm the observer before joining an authorized profile. Pass both the target interface and that profile's canonical UUID:
./live-canary.command wlan0 "$connection_uuid"The canary observes only. It records a baseline, waits up to five minutes for
the exact profile activation, and follows the dispatcher-created invocation.
It never calls systemctl start, performs HTTP, reconnects the device, or sends
a second portal request. If the profile is already active, the journal must
match the current activation generation or the result is inconclusive.
Correlated output is retained under
${XDG_STATE_HOME:-$HOME/.local/state}/cdwifi-autoaccept-canary/. Success still
requires a successful worker result, its independent-success journal record,
and per-device IPv4 FULL. A unit start, portal HTTP status, or redirect alone is
not success.
- Upgrade payload files atomically where the package system permits, then run daemon-reload. Do not start or restart the oneshot and do not clear the runtime ledger; an in-flight or already-claimed activation must stay claimed.
- Revoke one profile by removing only its administrator-owned JSON file. A worker that already captured authorization will recheck it before a POST.
- For package removal, remove the vendor dispatcher first so no new instance
can be requested, then remove the worker, unit, and documentation and run
daemon-reload. Preserve
/etc/cdwifi-autoacceptby default because it is administrator state. Leave the/runledger in place until reboot, especially if reinstalling during the same boot.
The service is a bounded oneshot (TimeoutStartSec=120); no enable, start,
restart, or stop hook is part of installation, upgrade, or normal removal.
Licensed under the Apache License, Version 2.0. See LICENSE. Unless explicitly stated otherwise, contributions intentionally submitted for inclusion in this project are provided under the same terms. Copyright ownership is recorded in COPYRIGHT.