# AP mode — first-boot Wi-Fi access point When a ODS device boots fresh, it can host its own Wi-Fi network so the recipient's phone can reach the setup wizard or redeem a factory owner card without already being on a configured network. This is the "true out-of-box" flow: take it out of the box, scan QR #1 to join the AP, then scan QR #2 to open setup or ODS Talk. This page describes the AP-mode machinery: scripts, systemd unit, host-agent endpoint, and the operator workflow to enable it. ## Why this is opt-in **The systemd unit is shipped but disabled by default.** Bringing up an AP is destructive: it takes the wireless interface off NetworkManager, applies iptables NAT rules, and runs `hostapd` + `dnsmasq` on the host. If a user is running ODS on their existing laptop, auto-enabling that would disconnect them from their own Wi-Fi. For a hardware product (ODS Mini, Strix Halo Node), the image-build pipeline can enable the unit. For the DIY install, the operator opts in explicitly. ## Architecture ``` ┌────────── Phone (during onboarding) ──────────┐ │ joins "ODS-Setup-XXXX" AP via QR scan │ │ browser opens any URL │ └────────────────────┬──────────────────────────┘ │ HTTP ▼ ┌─────────────────────────────────────────────┐ │ dnsmasq (on the device) │ │ DHCP: hands phone an IP in 192.168.7.x │ │ DNS: every name → 192.168.7.1 (gateway) │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ iptables (on the device) │ │ PREROUTING: DNAT :80/:443 → 192.168.7.1 │ │ (the gateway address — the proxy listens │ │ there once BIND_ADDRESS=0.0.0.0) │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ ods-proxy (Caddy) on 0.0.0.0:80 │ │ routes /setup → dashboard:3001 │ │ (the dashboard is loopback-bound; the │ │ proxy is what fields LAN traffic) │ └─────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────┐ │ dashboard:3001 (loopback) │ │ serves /setup → first-boot wizard │ └─────────────────────────────────────────────┘ Once the wizard completes: * sentinel written (PR-6) * operator (or PR-11) runs `systemctl disable --now ods-ap-mode` * NetworkManager regains wlan0, device joins the home network ``` ### Prerequisites for the DNAT to actually deliver traffic The iptables PREROUTING rule sends AP-client traffic to `192.168.7.1:80` / `:443`. For something to answer there, two things have to be true on the host: 1. **`ods-proxy` is enabled and running.** That's the Caddy service that listens on port 80 and routes `/setup`, `/chat`, `/api/*`, `/auth/*` to the right backend. Without it, AP clients hit an empty port 80 and the connection fails. The first-boot install flow enables it by default. 2. **`BIND_ADDRESS=0.0.0.0` in `.env`.** Without this, the proxy binds to `127.0.0.1:80` and the AP-side interface (`192.168.7.1`) can't reach it. The DNAT target IP would refuse the connection. If either is missing, the captive portal redirect lands the phone on a dead port. The AP-mode systemd unit doesn't enforce these — it's the operator's responsibility to ensure the host is configured to receive what AP mode redirects. ## Components | Component | Path | Purpose | |---|---|---| | `ap-mode.sh` | `scripts/ap-mode.sh` | Bring-up / tear-down / status. Reads config from `/etc/ods/ap-mode.conf`. | | `ods-ap-mode.service` | `scripts/systemd/ods-ap-mode.service` | systemd unit. Disabled by default — enable per-device. | | `ap-mode.conf.example` | `scripts/ap-mode.conf.example` | Annotated example operator config. | | `/v1/ap-mode/status` | `bin/ods-host-agent.py` | Read-only status endpoint. Used by the wizard to know "am I running on the device's own AP?" | ## Setup (operator workflow) ```bash # 1. Install hostapd, dnsmasq, iptables (NetworkManager is already required for PR-8). sudo apt install hostapd dnsmasq iptables # 2. Drop the operator config in place. Edit SSID + password per device. sudo install -d -m 0755 /etc/ods sudo install -m 0600 /ods/scripts/ap-mode.conf.example /etc/ods/ap-mode.conf sudo $EDITOR /etc/ods/ap-mode.conf # set ODS_AP_SSID + ODS_AP_PASSWORD # 3. Install the systemd unit. Manual installs must render __INSTALL_DIR__ # before copying the unit into /etc/systemd/system. sudo cp /ods/scripts/systemd/ods-ap-mode.service /etc/systemd/system/ sudo sed -i 's|__INSTALL_DIR__|/ods|g' /etc/systemd/system/ods-ap-mode.service sudo systemctl daemon-reload # 4. Enable + start. sudo systemctl enable --now ods-ap-mode # 5. Verify. sudo systemctl status ods-ap-mode sudo /ods/scripts/ap-mode.sh status ``` When the wizard finishes and the device should join the home network instead: ```bash sudo systemctl disable --now ods-ap-mode ``` Factory owner cards use the same AP QR for QR #1. QR #2 is the owner magic-link URL generated from Setup / Owner, and should resolve to the LAN-local auth host (`http://auth..local/magic-link/...`) unless the operator intentionally prints a public/Tailscale URL. ## Config reference All settings are bash variables sourced from `/etc/ods/ap-mode.conf`. See `scripts/ap-mode.conf.example` for the annotated version. | Variable | Default | Notes | |---|---|---| | `ODS_AP_SSID` | `ODS-Setup` | Network name. Include a per-unit suffix to avoid collisions. | | `ODS_AP_PASSWORD` | empty | WPA2 passphrase. Empty → open AP (allowed but warned). The example placeholder is refused so images do not ship a known password. | | `ODS_AP_INTERFACE` | `wlan0` | Must support AP mode. Check with `iw list \| grep -A4 'Supported interface modes' \| grep AP`. | | `ODS_AP_GATEWAY_IP` | `192.168.7.1` | IP that resolves every hostname (captive-portal trick). | | `ODS_AP_NETMASK` | `255.255.255.0` | | | `ODS_AP_DHCP_RANGE` | `192.168.7.10,192.168.7.50,1h` | `,,` | | `ODS_AP_CHANNEL` | `6` | 2.4 GHz only; 1 / 6 / 11 are the non-overlapping channels. | ## Platform support Linux only. Tested target: Ubuntu 22.04+ / Debian 12+ / Fedora 41+ with NetworkManager. Requires: - `hostapd` (the daemon) - `dnsmasq` (DHCP + DNS) - `iptables` (NAT) - `nmcli` (to release / reclaim the wireless interface) The script refuses to run on non-Linux or when any binary is missing — better to fail loudly than misconfigure the host. ## Known limitations - **Driver compatibility.** Not every wireless chipset supports AP mode. Realtek's `rtl8821ce`, for instance, won't work. Check `iw list` output; the script warns when the interface doesn't advertise AP mode. - **Only 2.4 GHz today.** 5 GHz / WiFi 6 setup adds regulatory-domain complexity that's out of scope for v1. - **systemd-networkd hosts.** The script assumes NetworkManager. If you've switched to systemd-networkd, the "release interface" / "reclaim interface" steps won't apply cleanly. PR welcome. - **No browser-side captive-portal detection ping.** iOS / Android probe well-known URLs (`captive.apple.com`, `connectivitycheck.gstatic.com`) on join. We catch all DNS at the AP, so those probes get the dashboard HTML and the OS marks the network as "captive." That works but isn't standards-compliant; some old Android versions may complain. Documented as a v1 trade-off. - **No automatic teardown.** The wizard (PR-11) will call `systemctl disable --now ods-ap-mode` after setup completes. Until that lands, the operator does it manually. ## Security notes - The unit runs `hostapd` and `dnsmasq` as root because they bind privileged sockets. That's the standard pattern; this isn't a "ODS-specific" elevation. - The captive-portal DNS catches *every* DNS query from clients on the AP. That's intentional — it's how the wizard auto-opens. It also means malicious clients on the AP can't reach upstream services from your network during the wizard window. That's a feature. - `iptables` rules are tagged with `--comment ods-ap-mode` so `ap-mode.sh down` removes exactly those rules and nothing else. - **Don't expose the AP to the internet.** The dashboard's auth surface assumes a trusted LAN; the AP is part of "the device's trusted LAN" for the wizard window only. - **Treat owner cards as keys.** Owner QR links are reusable until revoked and are not device-bound in v1. If a printed card is lost or photographed, revoke it from Setup / Owner and print a fresh card. ## Troubleshooting ### `hostapd` fails to start Run it in the foreground to see what it complains about: ```bash sudo hostapd /run/ods-ap-mode/hostapd.conf ``` Common causes: - Driver doesn't support AP mode (see "Known limitations") - Another wpa_supplicant / hostapd process holds the interface — `sudo pkill wpa_supplicant` - Regulatory domain isn't set — `sudo iw reg set US` (or your country) ### `dnsmasq` fails to start ```bash cat /run/ods-ap-mode/dnsmasq.log ``` Most commonly: another DNS daemon is bound to :53. `sudo systemctl stop systemd-resolved` or change the listen address. ### Phone joins the AP but doesn't open the wizard - Verify the captive-portal redirect: `curl -v http://anything.example/` from another machine on the AP, should land on the dashboard. - iOS sometimes caches "this network has no internet" from a previous join — Forget Network and rejoin. ### Stuck in AP mode after the wizard `systemctl disable --now ods-ap-mode` and `sudo nmcli device set wlan0 managed yes`. NetworkManager should pick the interface back up within a few seconds. ## What's NOT here yet - **Wizard integration** (PR-11) — detecting "running in AP mode" from the React side, showing different copy, gracefully handing off after WiFi config. - **5 GHz support** — needs `hostapd` regulatory-domain glue. - **Programmatic enable/disable from the dashboard API.** Deliberately omitted in this PR. Toggling an AP from an HTTP endpoint is a great way to lock yourself out of a remote box. Until we have a strong "are you sure" + recovery story, operator-only via `systemctl`.