20 KiB
to-do: rewrite & convert to html for docs pages
Wi-Fi
MontaukOS drives Intel AX210-family adapters (the reference part is the AX211).
The driver scans, joins open and WPA2/WPA3-PSK networks, and presents itself to
the network stack as an ordinary Ethernet interface, so dhcp, ping, nslookup
and anything speaking sockets work over Wi-Fi exactly as they do over a cable.
wifi scan list nearby networks
wifi connect <ssid> <passphrase> join one
dhcp pick up an address
wifi status what you are connected to
wifi saved / wifi forget <ssid> networks remembered for next time
There is a graphical path too: a Wi-Fi entry in the desktop panel and a Wi-Fi tab in the Network app. See "The desktop side" below.
Layout
kernel/src/Drivers/Net/Wifi/
IwxTrans.cpp PCIe transport: MMIO, MSI-X, DMA rings, firmware boot,
host commands, RX processing, frame TX, key installation
IwxFw.cpp .ucode / .pnvm TLV parsing
IwxMvm.cpp post-ALIVE init, NVM, UMAC scan, RX dispatch
IwxConnect.cpp MLME: contexts, authenticate, associate, 802.11 <-> Ethernet
Wpa.cpp WPA2/WPA3-PSK supplicant (EAPOL 4-way + group rekey)
Ieee80211.hpp frame, element and RSN constants
Wifi.cpp subsystem facade: probe, scan table, syscalls, netif hooks
kernel/src/Libraries/Crypto.cpp SHA-1/SHA-256, HMAC, PBKDF2, AES, CMAC
kernel/src/Net/NetIf.cpp interface registry the Ethernet layer uses
Joining a network
SYS_WIFI_CONNECT blocks until the link is up or the attempt fails, and
returns a WIFI_ERR_* code the wifi tool turns into a specific message
(wrong passphrase, unsupported security, AP out of range, and so on).
The sequence:
- Look up the BSS. The SSID is matched against the scan table, strongest
signal first. If it is not there, one scan is run automatically and the
lookup retried, so
wifi connectworks without scanning first. - Negotiate ciphers. The AP's RSN element (kept verbatim in the scan table) picks the pairwise cipher and AKM. CCMP is preferred over GCMP, plain PSK over PSK-SHA256.
- Derive the PMK. PBKDF2-HMAC-SHA1 over the passphrase with the SSID as salt, 4096 iterations. A 64-character hex string is taken as a raw PSK instead.
- Bring up firmware contexts. PHY, MAC, binding and station, then one TX queue on the management TID.
- Authenticate and associate. Open-system authentication, then an association request carrying the SSID, supported rates and, for encrypted networks, the RSN element the supplicant built. Both are retransmitted up to four times at 400 ms.
- Run the 4-way handshake. EAPOL-Key messages 1-4, then the pairwise key
and the group key go into the firmware with
ADD_STA_KEY. - Report the link up. Only now does
NetIfseewlan0as usable.
The data path
Once associated the driver translates between 802.11 and Ethernet II:
- TX - an Ethernet frame becomes a to-DS 802.11 data header plus an RFC 1042 LLC/SNAP shim carrying the EtherType. The protected bit is set once keys are installed and the firmware does the CCMP encryption.
- RX - the firmware decrypts and strips the MIC but leaves the 8-byte CCMP header, which is skipped; the LLC/SNAP shim is replaced by an Ethernet header built from addresses 1 and 3. EAPOL frames are diverted to the supplicant instead.
One TX queue carries management frames, EAPOL and non-QoS data. The firmware maps non-QoS data onto the management TID anyway, and the driver never negotiates block-ack sessions that would need per-TID queues.
Threading
The RX path runs under IwxProcessEvents()'s reentrancy guard and must not
send a host command - the command's completion is pumped by the very
function it would be re-entering. Anything needing a command (post-association
context updates, key installation, and therefore the whole EAPOL handshake) is
queued and applied from IwxConnectService(), which every idling core calls
and which is itself serialized. Transmitting is safe from either context: it
only writes a descriptor and rings the doorbell.
Never wait on the clock under a spinlock
kcp::Spinlock::Acquire() does cli, and Timekeeping::GetMilliseconds() is
driven by the APIC timer interrupt. A wall-clock timeout inside a spinlock
therefore cannot expire: the counter never advances, the loop never ends, and
the machine locks solid with interrupts off - no mouse, no keyboard, no
scheduler. This is the same trap ApicTimer.cpp documents for the idle path.
IwxSendCmd holds a lock across a wait for the firmware's reply, so that lock
is a kcp::Mutex (which keeps interrupts enabled), and the wait carries a spin
cap as well as the clock check so it terminates even if the clock is somehow
stuck. It also bails immediately once the firmware is known to have asserted,
because nothing after that will ever be answered.
A firmware command that goes unanswered now dumps the firmware's own error table on the first timeout - that names the command that asserted - and gives up on the adapter after three, rather than stalling for seconds per command.
Sample the clock after the work, not before it
IwxConnectService() sends host commands, and every one of them is a round
trip to the adapter that takes real milliseconds. Each step also stamps the
timestamp its deadline is measured from - EnterState() sets
g_stateEnteredMs, transmitting sets g_lastTxMs. A now read at the top of
the pass is therefore older than the stamps it is about to be compared
against, and because these are unsigned counters, now - g_stateEnteredMs
wraps to about 2^64 instead of going negative. Every deadline in the pass then
reads as long expired.
The symptom was a join that failed the instant it succeeded:
WiFi: [OK] Associated, AID 3
WiFi: [WARNING] Connection failed: timed out while joining the network
WiFi: [INFO] EAPOL frame received (99 bytes)
The two post-association context commands moved the clock forward, the timeout
check compared a stale now against the g_stateEnteredMs they had just set,
and the contexts came down before the access point's first EAPOL frame could
arrive - which is why message 1 shows up after the failure. So the clock is
read only once the command-sending work in the pass is done, and the
comparisons go through Elapsed(), which refuses to underflow.
Teardown unwinds contexts in the order they depend on each other
Firmware 89 asserts when a context is taken away while another still points at it, and the assert names the command rather than the reason. Three of these have been hit so far:
| UMAC error | Cause |
|---|---|
| 0x2010330F | PHY binding and link activation folded into one LINK_CONFIG |
| 0x2010330E | link removed while its station still existed |
| 0x2000320F | link deactivated while the MAC was still marked associated |
The last one is the teardown side of the same rule. While
MAC_CONFIG.is_assoc is set, the firmware's MAC context owns the link carrying
the BSS, so TearDown() sends a MAC_CONFIG MODIFY clearing is_assoc first,
and only then removes the station, deactivates the link, removes the link,
removes the MAC and drops the PHY context - the order
iwl_mvm_mld_vif_cfg_changed_station and the paths below it use on the way
down. tests/wifi/ap_mlme.py pins that order.
A bad access point must not become a bad computer
Three separate defects turned "the Wi-Fi connection went wrong" into "the whole machine went wrong". They are worth keeping straight because they have nothing to do with each other beyond sharing a trigger.
The command wait is a busy spin on a core the scheduler has been told not to
touch. ServiceEvents() runs from ApicTimer::ServiceDeferredWork(), which
sets cpu->reservedForKernelWork so a bottom half holding a process-context
mutex cannot be preempted into the process that would wait on it. The
scheduler honours that by refusing to place any process on that core and by
skipping it for the reschedule IPI, and on the BSP RunBspMaintenance() - the
thing that wakes sleeping processes - is not reached until the pass returns.
IwxSendCmd then waits for the firmware by spinning on IwxDelayUs, up to a
second. One pass of TearDown() is eight commands. Against an adapter that has
stopped answering, that pinned a core for the better part of ten seconds. The
symptom is not a Wi-Fi symptom at all: the cursor crawls, windows stop
repainting, everything stutters.
So a service pass now carries a budget - IwxBeginServicePass() /
IwxEndServicePass() - of roughly one command's worth of waiting in total.
Commands that do not fit are not sent at all (an abandoned command still holds
its ring slot, and a late answer would be misread as the next command's
completion) and are retried on the next trip round the idle loop. The bring-up
path does not bracket itself: it reserves its CPU deliberately and has nothing
to starve, so it keeps the full per-command timeout.
Counting consecutive failures never fires on the failure that matters. The give-up rule was three unanswered commands in a row, with any success resetting the count. An adapter that answers some commands and drops others - which is exactly what a marginal link leaves the firmware doing - therefore never reached the cutoff, while every drop still cost a full timeout. The stall was not a one-off; it repeated indefinitely, and the driver never concluded anything was wrong. It is a leaky bucket now: a failure adds one, a success drains one, so a firmware failing even a fraction of its commands trips the cutoff in bounded time.
IwxFwState::Error was a one-way door. Nothing anywhere cleared it. A
single firmware assert left StartJoin() returning WIFI_ERR_NO_ADAPTER -
which the user sees as no adapter is ready - for the rest of the uptime, and
the only way back was a reboot. Disconnecting and reconnecting could not help:
there was nothing to reconnect with. Since IwxReadFirmware() is idempotent
and the parsed image stays resident, the cure is to stop the device and run the
same bring-up again, which ServiceRecovery() now does. It is rate-limited to
one attempt per five seconds and capped at three, because the reset has its own
multi-second handshakes and an adapter that will not come back after three
tries is genuinely broken - repeating it forever would be its own kind of
stall.
The link is up only while the access point says so
IwxLinkUp() was nothing but g_state == Connected, and nothing moved it off
that state unless the access point was polite enough to send a
deauthentication frame. An access point that simply stops being there - a phone
hotspot that sleeps, wanders off channel, or drops the station without saying
so - left the link reported as up indefinitely. NetIf::Active() went on
choosing wlan0, every packet went into the void, and the desktop showed a
healthy connection while nothing resolved and nothing connected.
Beacon loss cannot be used to notice this: once associated, MacConfigCmd
stops asking for beacons and the firmware tracks them itself. What is
observable is that our own frames stop being acknowledged, since IwxTxComplete
gets a status per frame. A long enough run of failures - with any
acknowledgement, or any frame received from the BSS, restarting the count -
means the access point is gone, and the link comes down so the stack can fall
back to a cable and the panel can report the truth.
The threshold is counted in frames rather than seconds on purpose: a link that
is merely idle has nothing to send and must not be torn down for it. And
IwxTxComplete runs inside the RX pump, so it only sets a flag; the teardown
happens in IwxConnectService(), where sending the commands it needs is
allowed. tests/wifi/ap_mlme.py pins both halves of that.
The desktop side
Nothing in the GUI may block
SYS_WIFI_SCAN sweeps for up to twenty seconds and SYS_WIFI_CONNECT waits
out a whole handshake. Either one called from desktop.elf would freeze the
compositor - and with it the mouse, the panel and every window - for seconds at
a time. So the same work has a second, non-blocking entry point:
SYS_WIFI_SCAN_START |
starts a sweep and returns immediately |
SYS_WIFI_RESULTS |
copies the scan table out without touching the radio |
SYS_WIFI_CONNECT_ASYNC |
starts a join and returns immediately |
SYS_NETIFS |
lists the registered interfaces (see below) |
WifiInfo grew the fields that make polling enough to follow along:
scanning and scanGeneration for the sweep, joining and connState for
the handshake, and lastError for how the last join ended. The GUI reads
SYS_WIFI_INFO every 400 ms while something is in flight and every three
seconds otherwise.
The deadlines belong to the kernel, not the caller: ServiceAsync() runs from
ServiceEvents() - after the RX pump has returned, so it is allowed to send
commands - and aborts a scan that overruns, tears down a join that stalls, and
records lastError when one fails. A failed join is unwound the moment it
fails, so connState is back to idle by the time anyone looks; lastError is
what survives to be reported.
Wired and wireless are separate on the panel
The IP configuration is global to the stack, so "which interface does this
address belong to?" is not a question SYS_GETNETCFG can answer. SYS_NETIFS
reports each registered interface with its name, MAC, link state and an
active flag - the one NetIf::Active() currently sends through. The Ethernet
popup shows the address only while the wired interface is the active one, and
says "Not in use" when the cable is up but Wi-Fi is carrying the traffic; the
Wi-Fi popup does the mirror image. Each icon appears only when its hardware
does: no wired interface, no Ethernet icon; no adapter, no Wi-Fi icon.
The Wi-Fi icon stays white whatever the radio is doing. State belongs in the popup, and an icon that changes colour next to the clock is just noise.
Picking a network that needs a passphrase opens a real window - created with
desktop_create_window() and the four callbacks, exactly like the reboot and
shutdown dialogs in dialogs.cpp - rather than something painted into the
panel overlay. It therefore has a title bar, can be dragged and closed, appears
in the window list, and gets its text field, checkboxes and buttons from the
same mtk widgets the settings apps use.
What runs at startup
desktop.elf keeps looking for an adapter until one appears (firmware loads
well after login), starts one scan as soon as the firmware reports ready, and
when the results land joins the strongest saved network that is in range. Once
the link is up, and only if no address is configured, it spawns dhcp.elf.
Saved networks
0:/config/wifi.toml holds them:
[wifi]
autoconnect = true
[network.0]
ssid = "Home"
psk = "passphrase"
No default copy of this file ships in the image. It is created on the first
save, the way bluetooth.toml, display.toml and session.toml are. A shipped
default looks harmless - it only documents the schema - but it is laid down
again by anything that refreshes the system files, and it takes the user's saved
networks with it when it lands. That is exactly what happened the first time
this was written, and it is why the schema is documented here instead.
programs/include/montauk/wifi.h is the one implementation of reading,
writing and searching that file, shared by the panel, the Network app and the
wifi command, so all three agree on the schema. The passphrase is stored as
typed because that is what the join needs - the kernel derives the PMK from it,
or takes a 64-character hex string as a raw PSK. There is no key store to hide
it in: anyone who can read 0:/config can read the passphrases.
The interface registry
Net::NetIf replaced the Ethernet layer's direct calls into the E1000
drivers. Drivers register a name, a kind, and three function pointers; the
stack sends through NetIf::Active(), which prefers a wired interface with a
link and otherwise takes the first interface reporting one. A Wi-Fi-only
machine therefore has no link until it joins a network, and a machine with a
cable plugged in keeps using it.
SYS_NETSTATUS reports whichever interface is active, so ifconfig shows the
wireless counters once Wi-Fi is carrying traffic.
What is supported
| Open networks | yes |
| WPA2-PSK, CCMP or GCMP | yes |
| WPA2-PSK-SHA256 | yes |
| WPA3 transition mode (PSK advertised alongside SAE) | yes, joins via PSK |
| Group key rekeying | yes |
| WPA3-only (SAE) | no |
| Management frame protection required (MFPR) | no |
| WEP, original WPA / TKIP | no |
| 802.1X enterprise (EAP) | no |
| Block-ack aggregation, HT/VHT/HE rates | no - legacy rates only |
SAE needs finite-field or elliptic-curve arithmetic that does not belong in this kernel, and MFP needs BIP. Both are rejected up front with a specific log line rather than failing partway through a handshake. Mixed WPA/WPA2 networks that still broadcast under TKIP are refused for the same reason: the pairwise key would install but every broadcast frame would be dropped, which looks like a working connection that cannot get a DHCP lease.
Crypto
kernel/src/Libraries/Crypto.cpp exists because the supplicant runs in the
kernel and BearSSL is a userspace library. It provides SHA-1, SHA-256, HMAC
over both, PBKDF2-HMAC-SHA1, AES-128/256, RFC 3394 key wrap/unwrap and
AES-CMAC. It is not a general-purpose crypto library and should not be used
as one.
Testing
./tests/wifi/run.sh compiles the shipping sources for the host against a
small shim and drives them from Python. It is the real Crypto.cpp,
Wpa.cpp and IwxConnect.cpp, not a copy, with only the transport stubbed.
- Crypto primitives against the published vectors - FIPS-197 for AES, RFC 2202/4231 for HMAC, RFC 3394 for key wrap, RFC 4493 for CMAC, and the IEEE 802.11i Annex H.4 WPA passphrase vectors for PBKDF2.
- The supplicant against an independent authenticator using
hashlibandcryptography: messages 2 and 4 carry MICs that verify under a PTK the AP derived itself, the installed TK and GTK match the AP's, a wrong passphrase produces a MIC the AP rejects, group rekeys and message-3 retransmissions are answered, and RSN negotiation picks the right suites across eight real-world information elements. - The MLME and data path against a simulated AP that decodes every frame the driver emits: the authentication request, the association request and its elements (SSID, rates, capabilities, RSN), the handshake carried inside real 802.11 data frames, key installation arguments, and the encapsulation both ways - to-DS addressing, the protected bit, LLC/SNAP, sequence numbers, broadcast delivery, and the filtering of foreign-BSSID and null-data frames. Also the branches: open networks, retransmission and give-up when the AP is silent, authentication and association rejections, and an AP-initiated deauthentication bringing the link down.
- Firmware context ordering - the MLD command sizes and field offsets against the decoded Linux trace, and the order the teardown unwinds the contexts in, which is what the asserts above are about.
The harness clock advances on every host command
(CMD_ROUND_TRIP_MS in mlme_harness.cpp) rather than standing still. That
detail matters: a frozen clock makes every elapsed-time comparison in the
service loop trivially true or trivially false, and hid the underflow described
under "Sample the clock after the work, not before it" - the host tests passed
while the adapter could not join a network at all. Anything that reads
Timekeeping::GetMilliseconds() should be tested with time actually moving.
What is left needs the adapter, because it is the firmware's opinion rather
than the driver's logic: whether the firmware accepts the TX command and TFD
layout and actually radiates the frames, whether ADD_STA_KEY installs the
keys the driver asks for, whether the RX MPDU descriptor is read correctly off
real receptions, and whether association succeeds against a real AP's timing
and rate expectations. None of that can be exercised in QEMU, which has no
AX210-family device to emulate.