Skip to content

About

Safely accept the České dráhy CDWiFi captive portal with NetworkManager and systemd

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

CDWiFi auto-accept

CI

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.

Platform support

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.

Authorization model

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-eula

The 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 and systemd flow

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:

  1. GET only http://www.cdwifi.cz/, pinned to the guarded link gateway, with redirects and proxies disabled; a second bounded GET attempt is allowed.
  2. Require HTTP 200 and exactly one POST form whose decoded action is literally one of the two observed values, ./accept or /api/accept. They map only to /accept and /api/accept; every other spelling or URL is rejected. Require exactly one valid secret control and one enabled eula=on checkbox.
  3. 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.
  4. POST secret=<dynamic value>&eula=on at 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.
  5. 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.

Requirements and offline validation

The supported runtime baseline is:

  • NetworkManager 1.16 or newer, managing the target Wi-Fi interface with a configured and enabled connectivity check, including nmcli and 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 grep as 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.command

When 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.html

Native packages and checksums

The 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.

Vendor installation and package staging

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-reload

For 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.

Next live canary

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, revocation, and removal

  • 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-autoaccept by default because it is administrator state. Leave the /run ledger 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.

License

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.

About

Safely accept the České dráhy CDWiFi captive portal with NetworkManager and systemd

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages