11 KiB
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:
ods-proxyis 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.BIND_ADDRESS=0.0.0.0in.env. Without this, the proxy binds to127.0.0.1:80and 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)
# 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:
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.<device>.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 |
<start>,<end>,<lease-time> |
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. Checkiw listoutput; 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-modeafter setup completes. Until that lands, the operator does it manually.
Security notes
- The unit runs
hostapdanddnsmasqas 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.
iptablesrules are tagged with--comment ods-ap-modesoap-mode.sh downremoves 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:
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
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
hostapdregulatory-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.