9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
188 lines
11 KiB
Markdown
188 lines
11 KiB
Markdown
# 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.<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. 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`.
|