Skip to content

pi5-provisioned-wifi: Add reference for Wi-Fi credentials provisioned into /var - #37

Open
jetm wants to merge 2 commits into
mainfrom
pi5-provisioned-wifi
Open

jetm wants to merge 2 commits into
mainfrom
pi5-provisioned-wifi

Conversation

@jetm

@jetm jetm commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Problem

Bringing up the Pi 5's onboard Wi-Fi on Avocado OS runs into two traps, and nothing in the references shows how to avoid them:

  • /etc on a running device is an overlay whose writable layer is in /run, so a wpa_supplicant config written there is gone after a reboot, and the stock unit (which reads /etc/wpa_supplicant) cannot be used.
  • systemd-networkd starts before the extension merge, so on a cold boot it never sees the extension's wlan0 network file and leaves the interface unmanaged.

There is also no example of keeping network credentials out of the image and out of extensions.

Change

Adds the pi5-provisioned-wifi reference:

  • A wifi-station extension with the Broadcom drivers and firmware, wpa_supplicant, iw, a networkd file for wlan0 (DHCP, route metric 200) and wifi-station.service. The unit reads /var/lib/wifi-station/wpa_supplicant.conf and stays inactive when the file is absent. It runs networkctl reload after it starts, which is the first point after the merge.
  • scripts/provision-wifi.sh, which derives the WPA key on the provisioning host and sends only that key and the SSID over SSH into a 0600 file in a 0700 directory. The passphrase never reaches the device. wpa_passphrase is not used because it needs a terminal on stdin and printed nothing over SSH.
  • getting_started.md with install, build, deploy, provisioning, verification, how to change the credentials, and what the protection does not cover: anyone who can read the device's storage can read the key, since encrypted /var is not enabled and the Pi 5 has no TPM.

Testing

On a Pi 5 running the 2026 next feed (kernel 6.12.87, avocado-cli 1.0.0-rc.4), built and deployed with avocado build and avocado deploy:

  • The script printed the expected config shape (network blocks: 1 psk lines of 64 hex: 1 quoted psk lines: 0) and left root:root 700 on the directory and root:root 600 on the file.
  • After starting the unit, wpa_state=COMPLETED on a WPA2-PSK network, wlan0 routable by DHCP, and 3 of 3 pings to 1.1.1.1 through wlan0 with 0% loss.
  • After a cold reboot with nothing done by hand: the unit was enabled and active, wlan0 was routable, and the same 3 of 3 pings succeeded. An earlier build of the same extension without the networkctl reload line left wlan0 unmanaged on a cold boot.
  • The derived key appears in no process argument, in the journal, or in /run, /etc and /usr. The only copy is the file in /var.
  • Derivation matches the standard PBKDF2 test vector (IEEE / password).

Not tested: WPA3-only networks, hidden networks, and a country code. A Pi 5 outside this setup would need its own regulatory domain; this reference leaves the world default.

avocado.lock is in the reference's .gitignore, as no other reference tracks it.

… into /var

Bringing up the Pi 5's onboard Wi-Fi on Avocado OS has two traps that a
first attempt runs into. /etc on a running device is an overlay whose
writable layer lives in /run, so a wpa_supplicant config written there
disappears on reboot and the stock unit, which reads /etc, cannot be
used. And networkd starts before the extension merge, so on a cold boot
it never sees the extension's wlan0 file and leaves the interface
unmanaged.

The reference ships the radio, wpa_supplicant and a unit that reads its
config from /var/lib/wifi-station, staying inactive until provisioning
writes it. The unit asks networkd to reload once it starts, which is the
first point that runs after the merge. The credentials never enter the
image or an extension: scripts/provision-wifi.sh derives the WPA key on
the provisioning host, because wpa_passphrase needs a terminal and prints
nothing over ssh, and sends only the key to a 0600 file in a 0700
directory. A reference users copy should not teach passphrases in
images.

Tested on a Pi 5 running the 2026 next feed: the unit joins a WPA2-PSK
network, the key appears in no process argument, journal, /run, /etc or
/usr, and the device rejoins after a cold reboot without help.

Signed-off-by: Javier Tia <javier@peridio.com>
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Avocado References — Build Check

Reference Target install build Result
pi5-provisioned-wifi raspberrypi5 ✅ ✅ PASS

Totals: 1 ✅ passed · 0 ❌ failed

Per-reference logs: workflow run

# only after the merge, which makes it the first point that can ask networkd to
# re-read its configuration. The leading "-" keeps a reload failure from
# failing the Wi-Fi unit.
ExecStartPost=-/usr/bin/networkctl reload

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The extension on_merge hook is a better place for this reload than ExecStartPost. The comment above says this unit is the first point after the merge, but avocadoctl runs on_merge commands after every merge. rubicon/avocado.yaml already runs networkctl reload there.

How it can occur: with ExecStartPost, networkd reloads only when wifi-station.service starts. A user changes 25-wlan0.network (for example a new route metric or a static IP) and runs avocado deploy. The unit is already running, so it does not restart and networkd keeps the old file.

Impact: the change has no effect until a reboot, and the user thinks the deploy did not work. Users who copy this pattern for an interface without a creds-gated unit get no reload at all.

Suggested fix, in the wifi-station extension in avocado.yaml:

    on_merge:
      - networkctl reload
      - systemctl restart --no-block wifi-station.service
    on_unmerge:
      - systemctl stop --no-block wifi-station.service

Then remove ExecStartPost and its comment. The on_unmerge line also stops wpa_supplicant when the extension is removed from the runtime. Without it, the process continues to run from a deleted /usr until reboot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed on the deploy case: with ExecStartPost alone a changed 25-wlan0.network waits for a reboot. I added an on_merge hook that restarts wifi-station.service and an on_unmerge hook that stops it, so a deploy restarts the unit and the reload runs again.

I kept networkctl reload in ExecStartPost instead of moving it to on_merge. On a cold boot with credentials stored, the merge hook ran before D-Bus was up and the journal shows Warning: Command 'networkctl reload' failed: Failed to connect to system bus: Connection refused. wlan0 then stayed unmanaged. With the reload in the unit, a cold boot gives Network File: /usr/lib/systemd/network/25-wlan0.network and wifi-station active. Fixed in 4b8dfe6.

Comment thread pi5-provisioned-wifi/avocado.yaml Outdated
enable_services:
- wifi-station.service
packages:
kernel-module-brcmfmac: "*"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This extension ships the brcmfmac modules but has no modprobe: list. avocadoctl runs depmod after the merge, but it loads only the modules listed in modprobe:. udev coldplug for the SDIO radio runs before the merge, so no process loads brcmfmac from this sysext. rubicon/avocado.yaml sets modprobe: [brcmfmac] for this reason.

The cold-boot test passed, so the module probably comes from the BSP extension or the rootfs. That means one of two things:

  • These packages are not necessary, and two copies of the module can get out of sync after a kernel or BSP update.
  • The load order works by chance.

Impact if the load order works by chance: after a feed update changes the order, wlan0 does not appear and wifi-station.service waits on the device until the job times out.

Suggested fix: run modinfo -n brcmfmac and lsmod on the device to find which file loads. Then add this to the extension:

    modprobe:
      - brcmfmac

Or, if the BSP already ships the module and the firmware, remove the kernel-module-* and linux-firmware-rpidistro-* packages here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The module comes from the BSP extension, so I removed the kernel-module-*, linux-firmware-rpidistro-* and wireless-regdb-static packages here. avocado-bsp already carries them and loads them with a udev trigger at merge. The extension keeps wpa-supplicant and iw, and a comment says where the radio comes from. After the change, brcmfmac loaded on a cold boot and wlan0 appeared. Fixed in 4b8dfe6.

# any character is safe.
conf=$(python3 -I - "$IN" <<'PY'
import hashlib, sys
lines = open(sys.argv[1], encoding="utf-8").read().split("\n")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

split("\n") keeps \r and trailing spaces.

How it can occur: the user saves wifi.txt with CRLF line endings (a Windows editor, or a file copied from a Windows share), or adds a trailing space.

Impact: \r becomes part of the SSID and the passphrase, so the derived key is wrong. The shape check on the device still prints psk lines of 64 hex: 1, so the script reports success. The 4-way handshake then fails, and nothing tells the user the cause.

Suggested fix:

lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
...
if len(ssid) > 32:
    sys.exit("SSID must be 32 bytes or less")
print("SSID: " + repr(lines[0]), file=sys.stderr)

splitlines() removes the \r. Printing repr() of the SSID lets the user see the exact value that the script used. The 32-byte check rejects an SSID that the AP cannot have.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I could not reproduce \r reaching the key. The script reads the file in text mode, where Python's universal newlines turn \r\n into \n, and the new test tests/test-provision-wifi.sh checks a CRLF input. I did not switch to splitlines(): it also splits on form feed and other separators, which would cut a passphrase that contains one. The script now prints the SSID it stored, so a trailing space shows up in the output, and it rejects an SSID over 32 bytes. Trailing spaces are kept on purpose, since a passphrase can end in one.

The extension repeated the radio's kernel modules, firmware and
regulatory database that the BSP extension already ships and loads, so
a version bump had to be made in two places. It also never restarted
wifi-station on a deploy, so a changed network file waited for a reboot.

Drop the radio packages and keep wpa-supplicant and iw. Add on_merge and
on_unmerge hooks for the unit. The networkd reload stays in the unit's
ExecStartPost: at a cold boot the merge hook runs before D-Bus is up and
`networkctl reload` fails there with "Failed to connect to system bus",
which left wlan0 unmanaged.

provision-wifi.sh now rejects an SSID over 32 bytes, prints the SSID it
stored so a typo is visible, and keeps a passphrase containing a form
feed intact by splitting on newlines only. tests/test-provision-wifi.sh
covers these cases and the file ownership and modes.

Signed-off-by: Javier Tia <javier@peridio.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants