cleanup: clean up docs/ directory and consent source files

This commit is contained in:
daniel committed 2026-09-30 08:36:41 +02:00
1 parent ae5e84592d
commit 2eee6696ef
9 files changed
+5 -860

No files matched your search

-178
View File
@@ -1,178 +0,0 @@
# Consent Prompts
A program that has no entry in `0:/config/capabilities.toml` starts with no
capabilities. For five of them, it can ask the user the first time it needs
one, instead of failing. This works much like TCC on macOS: the desktop shows
a prompt. For installed programs the answer is remembered; for any other
binary it lasts until the process exits.
| Capability | The prompt says the app wants to... |
|-----------------|-----------------------------------------------------------------------|
| `storage_admin` | format, partition and mount disks |
| `network_admin` | change network settings and join Wi-Fi networks |
| `set_time` | change the system clock and time zone |
| `display_admin` | change the screen resolution and brightness |
| `log_read` | read the system log, which can reveal what other apps and devices are doing |
No other capability can be asked for. The eligible set is
`CAP_CONSENT_ELIGIBLE` in `Api/Syscall.hpp`.
## How a request flows
1. A syscall gated on an eligible capability calls
`Sched::Consent::Require()` (`kernel/src/Sched/Consent.cpp`) instead of
`HasCapability()`. All of these checks sit at the top of their syscall in
`Api/Syscall.cpp`, where the thread holds no locks and may block.
2. If the caller lacks the capability and is allowed to ask (see below), the
kernel queues a request and blocks the calling thread. A second thread of
the same process asking for the same capability waits on the same request.
3. The session's consent agent, the desktop, picks the request up with
`SYS_CONSENT_NEXT` from its main loop. If `0:/config/consent.toml` already
holds an answer for this program and capability, the desktop replies
without showing anything. Otherwise it opens the prompt.
4. `SYS_CONSENT_REPLY` either grants the capability or records a denial. The
waiting thread wakes, and its syscall proceeds or returns
`SYS_ERR_PERMISSION`.
If nobody answers within `CONSENT_TIMEOUT_MS` (120 s), the request is denied.
If the agent exits, every pending request is denied. If the app exits, its
requests are cancelled.
## Who may ask
A process may ask for a capability only when all of these hold:
- The capability is within its **consent ceiling**: the part of its parent's
delegable set, recorded at spawn, that is eligible for consent.
- The session has a registered agent. Registering (`SYS_CONSENT_REGISTER`)
requires `CAP_COMPOSITOR`, so only the desktop can be the agent.
- The process is in the agent's session and is not the agent itself.
- The agent still holds the capability as delegable.
- It has not already been refused that capability during its lifetime.
These rules have some consequences:
- **Standard sessions never prompt.** A standard session holds none of the
eligible capabilities, so every ceiling under it is empty.
- **Console sessions and SSH sessions never prompt.** They have no agent
(sshd is not in the desktop's session), so a remote user cannot put a
prompt on the local screen.
- **A sandboxing launcher still works.** If a parent spawns a child with
nothing delegable, the child's ceiling is empty and it cannot ask.
- After a refusal the process gets `SYS_ERR_PERMISSION` immediately every
time, so it cannot flood the user with prompts.
## Why this keeps the capability model's invariants
1. **Authority only narrows down the process tree.** A grant never exceeds
the process's consent ceiling, which is exactly what its parent could have
given it at spawn. The grant must also be within the agent's own delegable
set. Consent-granted capabilities are added only to the effective set,
never to the delegable set, so a consented app cannot pass them on to
anything it launches. Consent delays a delegation the parent could already
have made; it never exceeds one. The kernel re-checks all of this under
the scheduler lock when it applies the grant, so a grant can never land on
a process slot that has exited and been reused.
2. **Capabilities are kernel-owned.** The kernel identifies the program by
the path it loaded, never by a user name.
## Program identity
A stored answer is keyed on the program's path, and a path identifies a
program only if nobody else can replace the file. On the system volume,
writing anything under `0:/os` or `0:/apps` requires `CAP_SYSTEM_IMAGE`
(`Fs/ProtectedPaths.cpp`), which no session holds. So a program there is
**installed**, and its path is a trustworthy identity.
The kernel decides this, not the desktop: each `ConsentRequest` carries
`imageProtected`, computed from the resolved image path with the same rule
that guards writes. Paths are normalized by the same resolver the write check
uses, so `0:/os/../users/x.elf` is not mistaken for an installed program.
Any other binary, such as one in a user's home directory or on a data volume,
could be swapped for another at the same path. Its answers are never stored:
it is asked each time it runs, and the answer lasts until that process exits.
The same rule applies to static grants. `Sched::Spawn` starts any image
outside `0:/os` and `0:/apps` with no capabilities, whatever its launcher
asked for, and logs a warning. Otherwise a program with no authority could
overwrite a granted binary in, say, a home directory, and then run with the
grant the next time the shell or desktop launched it. A program installed
elsewhere gets its authority through consent instead. The Capabilities app
flags such grants.
## The prompt
The prompt is a normal window inside the desktop, not a full-screen overlay.
It is placed over the requesting app's window when that app has one, and at
the centre of the screen otherwise. It shows:
- the app's name: its menu label when the path belongs to an installed app,
otherwise the file name
- what it wants to do
- the full path
The prompt window belongs to the desktop itself (`programs/src/desktop/consent.cpp`),
not to the window server, so no process can map its pixels or send it input.
`SYS_WINMAP` and `SYS_WINSENDEVENT` require `CAP_COMPOSITOR` anyway. This
leaves clickjacking, where another window is raised over the prompt or steals
focus just as the user clicks. To prevent that, **Allow** stays disabled
until the prompt has been focused and on top for 700 ms without interruption.
Losing focus or being covered restarts the delay.
| Action | Result | Saved? |
|--------------------------------|---------------------------------|--------|
| Allow (click, once armed) | Granted | Installed programs only |
| Don't Allow (click, or Return once armed) | Denied | Installed programs only |
| Escape, or the title-bar close | Denied for this process only | No |
| Timeout, logout | Denied for this process only | No |
For a program that is not installed, the prompt also says that the answer
applies only until the program exits.
Only one prompt is shown at a time. Further requests wait in the kernel's
queue, which holds up to 16 requests.
## Stored decisions
Decisions are stored in `0:/config/consent.toml`:
```toml
[decision.1]
path = "0:/apps/wifitool/wifitool.elf"
capability = "network_admin"
decision = "allow"
```
A decision applies when the path and the capability match. Only installed
programs are ever stored, and the desktop looks up only requests the kernel
marked `imageProtected`. A new answer for the same path and capability
replaces the old one. Updating an installed program keeps its decisions,
because installing it already required `CAP_SYSTEM_IMAGE`. The file is a
protected path that requires `CAP_USER_ADMIN` to write, the same as
`capabilities.toml`: anything that can edit it could grant itself access.
The **Consent** tab of the Capabilities app lists the stored decisions and can
**Forget** one, so the program is asked again next time. A copy that is
already running keeps a granted capability until it exits.
## Syscalls
| Number | Name | Caller | Purpose |
|--------|------------------------|--------|---------------------------------------------|
| 191 | `SYS_CONSENT_REGISTER` | agent | Become the session's consent agent (needs `CAP_COMPOSITOR`) |
| 192 | `SYS_CONSENT_NEXT` | agent | Take the oldest queued request (`ConsentRequest`) |
| 193 | `SYS_CONSENT_REPLY` | agent | `CONSENT_ALLOW` / `CONSENT_DENY` a delivered request |
## Not covered yet
- **Elevation for standard users**, for example entering an administrator's
password in the prompt. This needs a different source of authority, because
a standard session has nothing to delegate.
- **Console sessions.** A text prompt in the terminal could be faked by
anything that prints to it.
- **Per-user decisions.** The table is system-wide, like the grants.
- **Revoking from a running process.** Forgetting a decision only affects
the next launch.
- **App-supplied reasons.** The prompt describes the capability in the
system's own words. A program cannot add text of its own.
-168
View File
@@ -1,168 +0,0 @@
# CPU Power and Thermal Management
MontaukOS manages CPU frequency, idle states, and package temperature on
Intel hardware. Without this, a modern many-core laptop part (the reference
target is a TUXEDO Gemini Gen2 with an i9-13900HX, 24 cores / 32 threads)
runs at whatever P-state the firmware left it, never drops below C1 when
idle, and heats until the hardware throttles at TjMax - or until the
embedded controller intervenes (on Clevo boards: forced full-volume beeping,
then a hard power-off).
Everything below is feature-gated by CPUID, so virtual machines (KVM masks
the power bits in CPUID.06H) and non-Intel CPUs skip it safely.
## Components
### HWP frequency scaling (`Hal/CpuPower.cpp`)
`Hal::CpuPower::InitializeBsp()` enables Hardware P-states
(IA32_PM_ENABLE), reads the performance range from IA32_HWP_CAPABILITIES,
and programs IA32_HWP_REQUEST with:
- min = lowest performance level, max = highest
- desired = 0 (fully autonomous hardware frequency selection)
- EPP = 0x80 (balanced; same as Linux "balance_performance")
This is the same mechanism intel_pstate uses on Linux and is the single
biggest thermal win: the CPU idles near its minimum frequency and ramps
only while there is work. APs apply the same request in `ApEntry` via
`InitializeAp()` (HWP request MSRs are per-logical-CPU).
CPUs with EPB but no HWP get IA32_ENERGY_PERF_BIAS = 6 (balanced) instead.
### C1E promotion
MSR_POWER_CTL bit 1 lets HLT-idle drop core voltage/frequency to minimum
(C1E) instead of parking at the current ratio. Set per-core on the BSP and
every AP. Gated on the DTS CPUID bit as a real-hardware heuristic, because
MSR_POWER_CTL is not architectural and hypervisors may not emulate it.
### Thermal governor (`Hal/CpuPower.cpp`)
A closed-loop passive-cooling controller on the BSP, stepped from
`Sched::RunBspMaintenance()` (reached from both the idle loop and the
timer tick, so it keeps running under full load). Every 500 ms it reads
the package digital thermal sensor (IA32_PACKAGE_THERM_STATUS readout
relative to TjMax from MSR_TEMPERATURE_TARGET) and adjusts the HWP
performance ceiling:
- **>= 85 C (passive):** step the ceiling down 2 ratio units (~200 MHz)
- **>= 94 C (hot):** step down 8 units per interval
- **<= 78 C (resume):** step back up 1 unit per interval
- between resume and passive: hold (hysteresis)
The ceiling propagates through a policy epoch counter: each CPU compares
the epoch in `Sched::Tick()` (and the AP idle loop) against the last one
it applied and rewrites its own IA32_HWP_REQUEST when it changed, since
MSR writes only affect the executing core. APs tick every 10 ms, so a
throttle decision reaches all cores within one tick.
Throttle engage/release transitions are logged (WARNING/OK, "CpuPower").
### Deep AP idle (`ACPI/CpuIdle.cpp`)
APs use deep MWAIT C-states while idle; C6 power-gates the core entirely,
which is where most of the idle-heat win lives on many-core parts.
The hint comes from ACPI `_CST` FFH entries (the GAS address low byte is
the MWAIT hint, same convention as Linux), validated against the CPUID.05H
sub-state enumeration and capped at the C6 family (0x2F). With no usable
`_CST`, the deepest CPUID-enumerated hint (up to C6) is used.
**The BSP stays in C1 (HLT).** On some laptops deep MWAIT states freeze
the LAPIC timer even though ARAT is advertised, and the BSP's periodic
1 ms tick is the system timekeeper (see the comment in
`Timekeeping::IdleOnce`). APs have no timekeeping duty: an idle AP is
woken by the reschedule IPI, and its coarse tick resumes on wake.
### S3 wake
IA32_PM_ENABLE, HWP requests, and MSR_POWER_CTL do not survive suspend.
`Hal::CpuPower::ReapplyAfterWake()` runs in the S3 resume path, redoes the
BSP setup, and bumps the policy epoch so every AP re-applies its HWP
request (and re-checks C1E) on its next tick.
### Battery (`ACPI/Battery.cpp`, `ACPI/EmbeddedController.cpp`)
Battery state comes from the ACPI Control Method Battery
(`_HID PNP0C0A`) and, for the AC adapter, `ACPI0003`:
- `_STA` bit 4 says whether a pack is installed. A laptop with the pack
removed still has the device, which is why "has a battery device" and
"has a battery" are separate answers.
- `_BIX` (or `_BIF` on older firmware) gives the power unit (mWh vs mAh),
the design capacity, and the last full charge capacity.
- `_BST` gives the charge/discharge state, the present rate, the remaining
capacity and the present voltage.
- `_PSR` on the adapter says whether it is plugged in.
Percentage is remaining / last-full, clamped to 100 and falling back to the
design capacity when firmware reports last-full as zero. Results are cached
for two seconds so that a panel repaint does not turn into EC traffic.
Almost all of this reaches the CPU through the **embedded controller**.
`Hal::EmbeddedController` finds the EC's command/data port pair from the
ECDT table when one exists, and otherwise from `_CRS` on the `PNP0C09`
device, then drives the polled read/write protocol (`0x80`/`0x81` with
OBF/IBF handshaking). It is wired into the AML interpreter as the
`EmbeddedControl` region space, so any `OperationRegion` in the DSDT that
targets the EC now works -- battery methods being the first user.
Supporting `_BST` also needed real package element storage in the AML
interpreter. Firmware overwhelmingly builds its result package with
`Store(x, Index(BST0, n))`, which requires `Index()` to name a writable
element rather than yield a value. `AML::PackageStore` provides that: a
persistent region for packages declared with `Name()`, and a transient ring
rewound at each top-level evaluation for packages built inside a method, so
polling `_BST` cannot exhaust it.
## Diagnostics
`SYS_POWERINFO` (149) fills a `PowerInfo` struct: package temperature,
TjMax, HWP state and performance range, the governor's current ceiling,
CPUID base/turbo frequency, the measured average active frequency
(APERF/MPERF over the 500 ms governor window), and the AP idle MWAIT hint.
Returns -1 when the CPU exposes no power features (e.g. under QEMU).
`SYS_BATTERYINFO` (188) fills a `BatteryInfo` struct: percentage, charge
state, AC presence, capacities, present rate, voltage and an estimated time
remaining. It returns -1 on a machine with no battery device at all, which
is how the desktop panel decides not to draw an indicator. Like
`SYS_POWERINFO` it is ungated read-only telemetry: the panel indicator has
to work in an unprivileged session, which holds no device capability to
spend on it.
The `power` program (`programs/src/power`) prints both:
```
power one-shot snapshot
power watch 1 Hz samples for 60 s (temp, avg freq, ceiling)
power watch 300 same, for 300 s
```
Boot log lines to look for (Kernel Log app):
```
[CpuPower] HWP enabled: perf range 4-54, EPP=0x80
[CpuPower] Thermal governor armed: TjMax=100C, passive=85C, hot=94C, ...
[CpuIdle] AP deep idle: MWAIT hint 0x20
[EC] Embedded Controller at cmd=66 data=62
[Battery] ACPI battery at \_SB.PCI0.LPCB.EC0.BAT0 (AC adapter present)
```
The desktop panel shows a battery icon left of the Wi-Fi and network icons,
using the icon pack's `battery-level-N[-charging]-symbolic` glyphs (one per
10% step, reloaded only when the step changes). Clicking it opens a popout
with the percentage in numbers, the charge state, time remaining and raw
capacity. The icon is absent entirely when `SYS_BATTERYINFO` fails, so a
desktop machine shows nothing there.
## Tuning
The governor constants live at the top of `Hal/CpuPower.cpp`
(`PassiveTempC`, `HotTempC`, `ResumeTempC`, `GovernorIntervalMs`, step
sizes) and were chosen to keep the package well below both TjMax (100 C
on the i9-13900HX) and the Clevo EC's emergency thresholds. If the fans
still spin up aggressively under sustained load, lower `PassiveTempC`
first; if builds feel throttled while cool, raise the recovery rate
instead of the trip points.
-100
View File
@@ -1,100 +0,0 @@
to-do: rewrite & convert to html for docs pages
# SSH
MontaukOS ships an SSH-2 server, `sshd`, that gives a remote client a shell on
the machine. It speaks enough of the protocol to interoperate with stock
OpenSSH clients:
```
ssh dan@montauk-box interactive shell
ssh dan@montauk-box 'ls 0:/' run one command
```
Remote access is **off by default**. Turn it on from Settings > SSH Server,
which flips `services.ssh.enabled` in `0:/config/init.toml`; `init` starts the
daemon on the next boot.
Two gates stand in front of a login: the account must exist in the MontaukOS
user database with a valid password, and it must be listed in the `[allow]`
table of `0:/config/ssh.toml`. A user absent from that table is refused even
with the right password. The Settings applet maintains the table; `sshd` never
writes it.
## Layout
```
programs/src/sshd/
main.cpp transport framing, key exchange, auth, session/channel loop
crypto.hpp host key storage, DH group 14 arithmetic, cipher/MAC wrappers
programs/src/sshserver/
main.cpp Settings applet: enable the service, edit the allow table
programs/include/montauk/ssh.h
shared policy helpers used by both
```
The crypto primitives (SHA-256, HMAC, AES-CTR, RSA signing, the DRBG) come from
BearSSL. The modular exponentiation for Diffie-Hellman is in `crypto.hpp`
because BearSSL exposes no public bignum API.
## What it implements
| | |
|---|---|
| Key exchange | `diffie-hellman-group14-sha256` |
| Host key | `rsa-sha2-256`, 2048-bit, generated on first start |
| Cipher | `aes128-ctr` |
| MAC | `hmac-sha2-256` |
| Compression | none |
| Auth | password only |
Client-initiated rekeying works, so long sessions survive OpenSSH's 1 GiB /
one-hour rekey threshold. Channel windows are tracked in both directions, so
sessions that move more than a window's worth of data keep flowing.
## What it does not
No public key authentication, no port or X11 forwarding, no SFTP or SCP. One
session channel per connection, and connections are served one at a time --
there is no `fork`, so a second client waits for the first to finish. A
pre-authentication timeout keeps an idle peer from holding the daemon shut.
`exec` requests are run by typing the command into an interactive shell rather
than executing it directly, so the prompt and the echoed command come back
mixed into the output. This is why `scp` and friends will not work as-is.
## The host key
Generated on first start and written to `0:/config/ssh_hostkey.toml`, separate
from the policy file so the unprivileged Settings applet -- which rewrites
`ssh.toml` whenever the allow table changes -- never round-trips private key
material.
That separation is hygiene, not protection: **the filesystem has no permission
model**, so any local process can read the host key. Closing that hole needs
FS-level access control, at which point the key file should be restricted to
the account `sshd` runs as. A key found in an older `ssh.toml` is migrated to
the new file on first start so the host key does not change under existing
clients.
## Debugging
`sshd` calls `montauk::print`, which reaches the boot console only. **It has no
usable diagnostics once the desktop is up**, pending the unified syslog.
Worth understanding, because it catches out anything written as a daemon:
`montauk::print` is `SYS_PRINT`, which writes the kernel *terminal*, not the
kernel *log*. Only in-kernel `KernelLogStream` writes raise `g_kernelLogDepth`,
and only those append to the ring buffer `SYS_LOG` reads -- so daemon output
never shows up in `klog`. On top of that, `Sys_Print` returns early once
`g_suppressKernelLog` is set, which the desktop does at startup, so the output is
discarded outright from then on. `init` spawns services with `spawn` rather than
`spawn_redir`, so there is no stream to capture either.
Until there is somewhere for it to go, debugging a handshake means watching the
boot console, or temporarily pointing the print calls at a file.
Failures print a numeric code -- `key exchange failed (N)`, `authentication
failed (N)` -- where N identifies the step; see the `kex_error` assignments in
`main.cpp`. Running the client with `ssh -vvv` and lining its trace up against
those codes is usually the fastest way to find where a handshake diverged.
-395
View File
@@ -1,395 +0,0 @@
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:
1. **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 connect` works without scanning first.
2. **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.
3. **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.
4. **Bring up firmware contexts.** PHY, MAC, binding and station, then one TX
queue on the management TID.
5. **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.
6. **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`.
7. **Report the link up.** Only now does `NetIf` see `wlan0` as 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:
```toml
[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 `hashlib` and
`cryptography`: 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.