Files
light-heart-labs--dreamserver/ods/docs/AP-MODE.md
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:31:33 +08:00

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:

  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)

# 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. 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:

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 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.